mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-18 09:27:14 +08:00
Restructure j/k navigation to support headlines (#707)
* Accessibility fixes * Restructure j/k navigation to support headlines * Focus modal content when open * Further focus adjustments * Fix tests * Remove unused functions
This commit is contained in:
parent
4d92aeba2e
commit
f64dd0ea90
17 changed files with 568 additions and 396 deletions
|
@ -209,7 +209,7 @@
|
|||
}
|
||||
|
||||
.menu-item {
|
||||
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 whitespace-nowrap;
|
||||
@apply w-full flex space-x-3 px-5 py-2 items-center hover:bg-gray-50 focus:bg-gray-50 whitespace-nowrap;
|
||||
}
|
||||
|
||||
/* Boxes */
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
/* Remove the default outline on focused elements */
|
||||
:focus,
|
||||
button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
menu {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
@ -44,6 +44,22 @@ solely client-side operations.
|
|||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="notebook-headline"]:hover [data-element="heading"],
|
||||
[data-element="section-headline"]:hover [data-element="heading"] {
|
||||
@apply border-blue-200;
|
||||
}
|
||||
|
||||
[data-element="notebook-headline"][data-js-focused] [data-element="heading"],
|
||||
[data-element="section-headline"][data-js-focused] [data-element="heading"] {
|
||||
@apply border-blue-300;
|
||||
}
|
||||
|
||||
[data-element="section-headline"]:not(:hover):not([data-js-focused])
|
||||
[data-element="heading"]
|
||||
+ [data-element="section-actions"]:not(:focus-within) {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="cell"][data-js-focused] {
|
||||
@apply border-blue-300 border-opacity-100;
|
||||
}
|
||||
|
@ -125,12 +141,6 @@ solely client-side operations.
|
|||
@apply text-gray-50 bg-gray-700;
|
||||
}
|
||||
|
||||
[data-element="section-headline"]:not(:hover)
|
||||
[data-element="section-name"]:not(:focus)
|
||||
+ [data-element="section-actions"]:not(:focus-within) {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
[data-element="clients-list-item"]:not([data-js-followed])
|
||||
[data-meta="unfollow"] {
|
||||
@apply hidden;
|
||||
|
|
|
@ -11,7 +11,7 @@ import "phoenix_html";
|
|||
import { Socket } from "phoenix";
|
||||
import topbar from "topbar";
|
||||
import { LiveSocket } from "phoenix_live_view";
|
||||
import ContentEditable from "./content_editable";
|
||||
import Headline from "./headline";
|
||||
import Cell from "./cell";
|
||||
import Session from "./session";
|
||||
import FocusOnUpdate from "./focus_on_update";
|
||||
|
@ -28,7 +28,7 @@ import morphdomCallbacks from "./morphdom_callbacks";
|
|||
import { loadUserData } from "./lib/user";
|
||||
|
||||
const hooks = {
|
||||
ContentEditable,
|
||||
Headline,
|
||||
Cell,
|
||||
Session,
|
||||
FocusOnUpdate,
|
||||
|
|
|
@ -13,8 +13,10 @@ import scrollIntoView from "scroll-into-view-if-needed";
|
|||
*
|
||||
* Configuration:
|
||||
*
|
||||
* * `data-focusable-id` - an identifier for the focus/insert navigation
|
||||
* * `data-cell-id` - id of the cell being edited
|
||||
* * `data-type` - type of the cell
|
||||
* * `data-session-path` - root path to the current session
|
||||
*/
|
||||
const Cell = {
|
||||
mounted() {
|
||||
|
@ -136,16 +138,23 @@ const Cell = {
|
|||
const input = getInput(this);
|
||||
|
||||
input.addEventListener("blur", (event) => {
|
||||
if (this.state.isFocused && this.state.insertMode) {
|
||||
// We are still in the insert mode, so focus the input
|
||||
// back once other handlers complete
|
||||
setTimeout(() => {
|
||||
// Wait for other handlers to complete and if still in insert
|
||||
// force focus
|
||||
setTimeout(() => {
|
||||
if (this.state.isFocused && this.state.insertMode) {
|
||||
input.focus();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
|
||||
"navigation",
|
||||
(event) => {
|
||||
handleNavigationEvent(this, event);
|
||||
}
|
||||
);
|
||||
|
||||
this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
|
||||
"cells",
|
||||
(event) => {
|
||||
|
@ -155,6 +164,7 @@ const Cell = {
|
|||
},
|
||||
|
||||
destroyed() {
|
||||
this._unsubscribeFromNavigationEvents();
|
||||
this._unsubscribeFromCellsEvents();
|
||||
|
||||
if (this.state.liveEditor) {
|
||||
|
@ -184,24 +194,31 @@ function getInput(hook) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Handles client-side cells event.
|
||||
* Handles client-side navigation event.
|
||||
*/
|
||||
function handleCellsEvent(hook, event) {
|
||||
if (event.type === "cell_focused") {
|
||||
handleCellFocused(hook, event.cellId, event.scroll);
|
||||
function handleNavigationEvent(hook, event) {
|
||||
if (event.type === "element_focused") {
|
||||
handleElementFocused(hook, event.focusableId, event.scroll);
|
||||
} else if (event.type === "insert_mode_changed") {
|
||||
handleInsertModeChanged(hook, event.enabled);
|
||||
} else if (event.type === "cell_moved") {
|
||||
handleCellMoved(hook, event.cellId);
|
||||
} else if (event.type === "cell_upload") {
|
||||
handleCellUpload(hook, event.cellId, event.url);
|
||||
} else if (event.type === "location_report") {
|
||||
handleLocationReport(hook, event.client, event.report);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCellFocused(hook, cellId, scroll) {
|
||||
if (hook.props.cellId === cellId) {
|
||||
/**
|
||||
* Handles client-side cells event.
|
||||
*/
|
||||
function handleCellsEvent(hook, event) {
|
||||
if (event.type === "cell_moved") {
|
||||
handleCellMoved(hook, event.cellId);
|
||||
} else if (event.type === "cell_upload") {
|
||||
handleCellUpload(hook, event.cellId, event.url);
|
||||
}
|
||||
}
|
||||
|
||||
function handleElementFocused(hook, focusableId, scroll) {
|
||||
if (hook.props.cellId === focusableId) {
|
||||
hook.state.isFocused = true;
|
||||
hook.el.setAttribute("data-js-focused", "true");
|
||||
if (scroll) {
|
||||
|
@ -277,7 +294,7 @@ function handleLocationReport(hook, client, report) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (hook.props.cellId === report.cellId && report.selection) {
|
||||
if (hook.props.cellId === report.focusableId && report.selection) {
|
||||
hook.state.liveEditor.updateUserSelection(client, report.selection);
|
||||
} else {
|
||||
hook.state.liveEditor.removeUserSelection(client);
|
||||
|
@ -291,7 +308,7 @@ function broadcastSelection(hook, selection = null) {
|
|||
if (hook.state.isFocused && hook.state.insertMode) {
|
||||
globalPubSub.broadcast("session", {
|
||||
type: "cursor_selection_changed",
|
||||
cellId: hook.props.cellId,
|
||||
focusableId: hook.props.cellId,
|
||||
selection,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import { getAttributeOrThrow } from "../lib/attribute";
|
||||
|
||||
/**
|
||||
* A hook used on [contenteditable] elements to update the specified
|
||||
* attribute with the element text.
|
||||
*
|
||||
* Configuration:
|
||||
*
|
||||
* * `data-update-attribute` - the name of the attribute to update when content changes
|
||||
*/
|
||||
const ContentEditable = {
|
||||
mounted() {
|
||||
this.props = getProps(this);
|
||||
|
||||
this.__updateAttribute();
|
||||
|
||||
// Set the specified attribute on every content change
|
||||
this.el.addEventListener("input", (event) => {
|
||||
this.__updateAttribute();
|
||||
});
|
||||
|
||||
// Make sure only plain text is pasted
|
||||
this.el.addEventListener("paste", (event) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
document.execCommand("insertText", false, text);
|
||||
});
|
||||
|
||||
// Unfocus the element on Enter or Escape
|
||||
this.el.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === "Escape") {
|
||||
this.el.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// While the element is focused, ignore the incoming changes
|
||||
this.el.addEventListener("focus", (event) => {
|
||||
this.el.setAttribute("phx-update", "ignore");
|
||||
});
|
||||
|
||||
this.el.addEventListener("blur", (event) => {
|
||||
this.el.removeAttribute("phx-update");
|
||||
});
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.props = getProps(this);
|
||||
|
||||
// The element has been re-rendered so we have to add the attribute back
|
||||
this.__updateAttribute();
|
||||
},
|
||||
|
||||
__updateAttribute() {
|
||||
const value = this.el.innerText.trim();
|
||||
this.el.setAttribute(this.props.attribute, value);
|
||||
},
|
||||
};
|
||||
|
||||
function getProps(hook) {
|
||||
return {
|
||||
attribute: getAttributeOrThrow(hook.el, "data-update-attribute"),
|
||||
};
|
||||
}
|
||||
|
||||
export default ContentEditable;
|
137
assets/js/headline/index.js
Normal file
137
assets/js/headline/index.js
Normal file
|
@ -0,0 +1,137 @@
|
|||
import { getAttributeOrThrow } from "../lib/attribute";
|
||||
import { globalPubSub } from "../lib/pub_sub";
|
||||
import { smoothlyScrollToElement } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* A hook managing notebook/section headline.
|
||||
*
|
||||
* Similarly to cells the headline is focus/insert enabled.
|
||||
*
|
||||
* Configuration:
|
||||
*
|
||||
* * `data-focusable-id` - an identifier for the focus/insert navigation
|
||||
* * `data-on-value-change` - name of the event pushed when the user edits heading value
|
||||
* * `data-metadata` - additional value to send with the change event
|
||||
*/
|
||||
const Headline = {
|
||||
mounted() {
|
||||
this.props = getProps(this);
|
||||
this.state = {
|
||||
isFocused: false,
|
||||
insertMode: false,
|
||||
};
|
||||
|
||||
const heading = getHeading(this);
|
||||
|
||||
// Make sure only plain text is pasted
|
||||
heading.addEventListener("paste", (event) => {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain").replace("\n", " ");
|
||||
document.execCommand("insertText", false, text);
|
||||
});
|
||||
|
||||
// Ignore enter
|
||||
heading.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
heading.addEventListener("blur", (event) => {
|
||||
// Wait for other handlers to complete and if still in insert
|
||||
// force focus
|
||||
setTimeout(() => {
|
||||
if (this.state.isFocused && this.state.insertMode) {
|
||||
heading.focus();
|
||||
moveSelectionToEnd(heading);
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
|
||||
"navigation",
|
||||
(event) => {
|
||||
handleNavigationEvent(this, event);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.props = getProps(this);
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this._unsubscribeFromNavigationEvents();
|
||||
},
|
||||
};
|
||||
|
||||
function getProps(hook) {
|
||||
return {
|
||||
focusableId: getAttributeOrThrow(hook.el, "data-focusable-id"),
|
||||
onValueChange: getAttributeOrThrow(hook.el, "data-on-value-change"),
|
||||
metadata: getAttributeOrThrow(hook.el, "data-metadata"),
|
||||
};
|
||||
}
|
||||
|
||||
function handleNavigationEvent(hook, event) {
|
||||
if (event.type === "element_focused") {
|
||||
handleElementFocused(hook, event.focusableId, event.scroll);
|
||||
} else if (event.type === "insert_mode_changed") {
|
||||
handleInsertModeChanged(hook, event.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
function handleElementFocused(hook, cellId, scroll) {
|
||||
if (hook.props.focusableId === cellId) {
|
||||
hook.state.isFocused = true;
|
||||
hook.el.setAttribute("data-js-focused", "true");
|
||||
if (scroll) {
|
||||
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;
|
||||
|
||||
const heading = getHeading(hook);
|
||||
|
||||
if (hook.state.insertMode) {
|
||||
// While in insert mode, ignore the incoming changes
|
||||
hook.el.setAttribute("phx-update", "ignore");
|
||||
heading.setAttribute("contenteditable", "true");
|
||||
heading.focus();
|
||||
moveSelectionToEnd(heading);
|
||||
} else {
|
||||
heading.removeAttribute("contenteditable");
|
||||
hook.el.removeAttribute("phx-update");
|
||||
hook.pushEvent(hook.props.onValueChange, {
|
||||
value: headingValue(heading),
|
||||
metadata: hook.props.metadata,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHeading(hook) {
|
||||
return hook.el.querySelector(`[data-element="heading"]`);
|
||||
}
|
||||
|
||||
function headingValue(heading) {
|
||||
return heading.textContent.trim();
|
||||
}
|
||||
|
||||
function moveSelectionToEnd(heading) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(heading);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
export default Headline;
|
|
@ -33,7 +33,7 @@ import monaco from "../cell/live_editor/monaco";
|
|||
*
|
||||
* ## Navigation
|
||||
*
|
||||
* This hook handles focusing cells and moving the focus around,
|
||||
* 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 using CSS accordingly (see assets/css/js_interop.css).
|
||||
|
@ -60,8 +60,7 @@ const Session = {
|
|||
mounted() {
|
||||
this.props = getProps(this);
|
||||
this.state = {
|
||||
focusedCellId: null,
|
||||
focusedSectionId: null,
|
||||
focusedId: null,
|
||||
focusedCellType: null,
|
||||
insertMode: false,
|
||||
keyBuffer: new KeyBuffer(),
|
||||
|
@ -96,6 +95,19 @@ const Session = {
|
|||
|
||||
document.addEventListener("mousedown", this.handleDocumentMouseDown);
|
||||
|
||||
this.handleDocumentFocus = (event) => {
|
||||
handleDocumentFocus(this, event);
|
||||
};
|
||||
|
||||
// Note: the focus event doesn't bubble, so we register for the capture phase
|
||||
document.addEventListener("focus", this.handleDocumentFocus, true);
|
||||
|
||||
this.handleDocumentClick = (event) => {
|
||||
handleDocumentClick(this, event);
|
||||
};
|
||||
|
||||
document.addEventListener("click", this.handleDocumentClick);
|
||||
|
||||
this.handleDocumentDoubleClick = (event) => {
|
||||
handleDocumentDoubleClick(this, event);
|
||||
};
|
||||
|
@ -142,7 +154,6 @@ const Session = {
|
|||
// DOM setup
|
||||
|
||||
updateSectionListHighlight();
|
||||
focusNotebookNameIfNew();
|
||||
|
||||
// Server events
|
||||
|
||||
|
@ -195,9 +206,9 @@ const Session = {
|
|||
|
||||
this.handleEvent(
|
||||
"location_report",
|
||||
({ client_pid, cell_id, selection }) => {
|
||||
({ client_pid, focusable_id, selection }) => {
|
||||
const report = {
|
||||
cellId: cell_id,
|
||||
focusableId: focusable_id,
|
||||
selection: decodeSelection(selection),
|
||||
};
|
||||
|
||||
|
@ -225,8 +236,10 @@ const Session = {
|
|||
destroyed() {
|
||||
this._unsubscribeFromSessionEvents();
|
||||
|
||||
document.removeEventListener("keydown", this.handleDocumentKeyDown);
|
||||
document.removeEventListener("keydown", this.handleDocumentKeyDown, true);
|
||||
document.removeEventListener("mousedown", this.handleDocumentMouseDown);
|
||||
document.removeEventListener("focus", this.handleDocumentFocus, true);
|
||||
document.removeEventListener("click", this.handleDocumentClick);
|
||||
document.removeEventListener("dblclick", this.handleDocumentDoubleClick);
|
||||
|
||||
setFavicon("favicon");
|
||||
|
@ -265,7 +278,7 @@ function faviconForEvaluationStatus(evaluationStatus) {
|
|||
*
|
||||
* @typedef LocationReport
|
||||
* @type {Object}
|
||||
* @property {String|null} cellId
|
||||
* @property {String|null} focusableId
|
||||
* @property {monaco.Selection|null} selection
|
||||
*/
|
||||
|
||||
|
@ -346,20 +359,22 @@ function handleDocumentKeyDown(hook, event) {
|
|||
cancelFocusedCellEvaluation(hook);
|
||||
} else if (keyBuffer.tryMatch(["0", "0"])) {
|
||||
restartRuntime(hook);
|
||||
} else if (keyBuffer.tryMatch(["Escape", "Escape"])) {
|
||||
setFocusedEl(hook, null);
|
||||
} else if (keyBuffer.tryMatch(["?"])) {
|
||||
showShortcuts(hook);
|
||||
} else if (
|
||||
keyBuffer.tryMatch(["i"]) ||
|
||||
(event.target === document.body &&
|
||||
hook.state.focusedCellId &&
|
||||
hook.state.focusedId &&
|
||||
key === "Enter")
|
||||
) {
|
||||
cancelEvent(event);
|
||||
enterInsertMode(hook);
|
||||
} else if (keyBuffer.tryMatch(["j"])) {
|
||||
moveCellFocus(hook, 1);
|
||||
moveFocus(hook, 1);
|
||||
} else if (keyBuffer.tryMatch(["k"])) {
|
||||
moveCellFocus(hook, -1);
|
||||
moveFocus(hook, -1);
|
||||
} else if (keyBuffer.tryMatch(["J"])) {
|
||||
moveFocusedCell(hook, 1);
|
||||
} else if (keyBuffer.tryMatch(["K"])) {
|
||||
|
@ -391,46 +406,61 @@ function handleDocumentMouseDown(hook, event) {
|
|||
return;
|
||||
}
|
||||
|
||||
// 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 focusable element, if one was clicked
|
||||
const focusableEl = event.target.closest(`[data-focusable-id]`);
|
||||
const focusableId = focusableEl ? focusableEl.dataset.focusableId : null;
|
||||
const insertMode = editableElementClicked(event, focusableEl);
|
||||
|
||||
if (focusableId !== hook.state.focusedId) {
|
||||
setFocusedEl(hook, focusableId, { scroll: false, focusElement: false });
|
||||
}
|
||||
|
||||
// If a cell action is clicked, keep the focus as is
|
||||
// If a cell action is clicked, keep the insert mode as is
|
||||
if (event.target.closest(`[data-element="actions"]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the cell element, if one was clicked
|
||||
const cell = event.target.closest(`[data-element="cell"]`);
|
||||
const cellId = cell ? cell.dataset.cellId : null;
|
||||
const insertMode = editableElementClicked(event, cell);
|
||||
|
||||
if (cellId !== hook.state.focusedCellId) {
|
||||
setFocusedCell(hook, cellId, false);
|
||||
}
|
||||
|
||||
// Depending on whether the click targets editor disable/enable insert mode
|
||||
// Depending on whether the click targets editor or input disable/enable insert mode
|
||||
if (hook.state.insertMode !== insertMode) {
|
||||
setInsertMode(hook, insertMode);
|
||||
}
|
||||
}
|
||||
|
||||
function editableElementClicked(event, cell) {
|
||||
if (cell) {
|
||||
const editorContainer = cell.querySelector(
|
||||
`[data-element="editor-container"]`
|
||||
function editableElementClicked(event, element) {
|
||||
if (element) {
|
||||
const editableElement = element.querySelector(
|
||||
`[data-element="editor-container"], [data-element="input"], [data-element="heading"]`
|
||||
);
|
||||
const input = cell.querySelector(`[data-element="input"]`);
|
||||
const editableElement = editorContainer || input;
|
||||
|
||||
return editableElement.contains(event.target);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses a focusable element if the user "tab"s anywhere into it.
|
||||
*/
|
||||
function handleDocumentFocus(hook, event) {
|
||||
const focusableEl = event.target.closest(`[data-focusable-id]`);
|
||||
|
||||
if (focusableEl) {
|
||||
const focusableId = focusableEl.dataset.focusableId;
|
||||
|
||||
if (focusableId !== hook.state.focusedId) {
|
||||
setFocusedEl(hook, focusableId, { scroll: false, focusElement: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters insert mode when markdown edit action is clicked.
|
||||
*/
|
||||
function handleDocumentClick(hook, event) {
|
||||
if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) {
|
||||
setInsertMode(hook, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enters insert mode when a markdown cell is double-clicked.
|
||||
*/
|
||||
|
@ -439,7 +469,7 @@ function handleDocumentDoubleClick(hook, event) {
|
|||
`[data-element="cell"][data-type="markdown"]`
|
||||
);
|
||||
|
||||
if (markdownCell && hook.state.focusedCellId && !hook.state.insertMode) {
|
||||
if (markdownCell && hook.state.focusedId && !hook.state.insertMode) {
|
||||
setInsertMode(hook, true);
|
||||
}
|
||||
}
|
||||
|
@ -508,8 +538,8 @@ function handleClientFollowToggleClick(hook, clientPid, clientListItem) {
|
|||
function mirrorClientFocus(hook, clientPid) {
|
||||
const locationReport = hook.state.lastLocationReportByClientPid[clientPid];
|
||||
|
||||
if (locationReport && locationReport.cellId) {
|
||||
setFocusedCell(hook, locationReport.cellId);
|
||||
if (locationReport && locationReport.focusableId) {
|
||||
setFocusedEl(hook, locationReport.focusableId);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -520,7 +550,7 @@ 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);
|
||||
setFocusedEl(hook, cellId);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -532,22 +562,22 @@ function initializeFocus(hook) {
|
|||
const hash = window.location.hash;
|
||||
|
||||
if (hash) {
|
||||
if (hash.startsWith("#cell-")) {
|
||||
const cellId = hash.replace(/^#cell-/, "");
|
||||
if (getCellById(cellId)) {
|
||||
setFocusedCell(hook, cellId);
|
||||
}
|
||||
} else {
|
||||
// Explicitly scroll to the target element
|
||||
// after the loading finishes
|
||||
const htmlId = hash.replace(/^#/, "");
|
||||
const element = document.getElementById(htmlId);
|
||||
if (element) {
|
||||
const htmlId = hash.replace(/^#/, "");
|
||||
const element = document.getElementById(htmlId);
|
||||
|
||||
if (element) {
|
||||
const focusableEl = elementelement.closest("[data-focusable-id]");
|
||||
|
||||
if (focusableEl) {
|
||||
setFocusedEl(hook, focusableEl.dataset.focusableId);
|
||||
} else {
|
||||
// Explicitly scroll to the target element
|
||||
// after the loading finishes
|
||||
element.scrollIntoView();
|
||||
}
|
||||
}
|
||||
} else if (hook.props.autofocusCellId) {
|
||||
setFocusedCell(hook, hook.props.autofocusCellId, false);
|
||||
setFocusedEl(hook, hook.props.autofocusCellId, { scroll: false });
|
||||
setInsertMode(hook, true);
|
||||
}
|
||||
}
|
||||
|
@ -613,15 +643,15 @@ function saveNotebook(hook) {
|
|||
}
|
||||
|
||||
function deleteFocusedCell(hook) {
|
||||
if (hook.state.focusedCellId) {
|
||||
hook.pushEvent("delete_cell", { cell_id: hook.state.focusedCellId });
|
||||
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
|
||||
hook.pushEvent("delete_cell", { cell_id: hook.state.focusedId });
|
||||
}
|
||||
}
|
||||
|
||||
function queueFocusedCellEvaluation(hook) {
|
||||
if (hook.state.focusedCellId) {
|
||||
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
|
||||
hook.pushEvent("queue_cell_evaluation", {
|
||||
cell_id: hook.state.focusedCellId,
|
||||
cell_id: hook.state.focusedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -631,17 +661,21 @@ function queueAllCellsEvaluation(hook) {
|
|||
}
|
||||
|
||||
function queueFocusedSectionEvaluation(hook) {
|
||||
if (hook.state.focusedSectionId) {
|
||||
hook.pushEvent("queue_section_cells_evaluation", {
|
||||
section_id: hook.state.focusedSectionId,
|
||||
});
|
||||
if (hook.state.focusedId) {
|
||||
const sectionId = getSectionIdByFocusableId(hook.state.focusedId);
|
||||
|
||||
if (sectionId) {
|
||||
hook.pushEvent("queue_section_cells_evaluation", {
|
||||
section_id: sectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFocusedCellEvaluation(hook) {
|
||||
if (hook.state.focusedCellId) {
|
||||
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
|
||||
hook.pushEvent("cancel_cell_evaluation", {
|
||||
cell_id: hook.state.focusedCellId,
|
||||
cell_id: hook.state.focusedId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -655,7 +689,7 @@ function showShortcuts(hook) {
|
|||
}
|
||||
|
||||
function enterInsertMode(hook) {
|
||||
if (hook.state.focusedCellId) {
|
||||
if (hook.state.focusedId) {
|
||||
setInsertMode(hook, true);
|
||||
}
|
||||
}
|
||||
|
@ -664,83 +698,88 @@ function escapeInsertMode(hook) {
|
|||
setInsertMode(hook, false);
|
||||
}
|
||||
|
||||
function moveCellFocus(hook, offset) {
|
||||
const cellId = nearbyCellId(hook.state.focusedCellId, offset);
|
||||
setFocusedCell(hook, cellId);
|
||||
function moveFocus(hook, offset) {
|
||||
const focusableId = nearbyFocusableId(hook.state.focusedId, offset);
|
||||
setFocusedEl(hook, focusableId);
|
||||
}
|
||||
|
||||
function moveFocusedCell(hook, offset) {
|
||||
if (hook.state.focusedCellId) {
|
||||
hook.pushEvent("move_cell", { cell_id: hook.state.focusedCellId, offset });
|
||||
if (hook.state.focusedId && isCell(hook.state.focusedId)) {
|
||||
hook.pushEvent("move_cell", { cell_id: hook.state.focusedId, offset });
|
||||
}
|
||||
}
|
||||
|
||||
function insertCellBelowFocused(hook, type) {
|
||||
if (hook.state.focusedCellId) {
|
||||
hook.pushEvent("insert_cell_below", {
|
||||
cell_id: hook.state.focusedCellId,
|
||||
type,
|
||||
});
|
||||
if (hook.state.focusedId) {
|
||||
insertCellBelowFocusableId(hook, hook.state.focusedId, 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);
|
||||
const focusableIds = getFocusableIds();
|
||||
if (focusableIds.length > 0) {
|
||||
insertCellBelowFocusableId(
|
||||
hook,
|
||||
focusableIds[focusableIds.length - 1],
|
||||
type
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertCellAboveFocused(hook, type) {
|
||||
if (hook.state.focusedCellId) {
|
||||
hook.pushEvent("insert_cell_above", {
|
||||
cell_id: hook.state.focusedCellId,
|
||||
type,
|
||||
});
|
||||
if (hook.state.focusedId) {
|
||||
const prevFocusableId = nearbyFocusableId(hook.state.focusedId, -1);
|
||||
insertCellBelowFocusableId(hook, prevFocusableId, 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);
|
||||
const focusableIds = getFocusableIds();
|
||||
if (focusableIds.length > 0) {
|
||||
insertCellBelowFocusableId(hook, focusableIds[0], type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insertFirstCell(hook, type) {
|
||||
const sectionIds = getSectionIds();
|
||||
|
||||
if (sectionIds.length > 0) {
|
||||
hook.pushEvent("insert_cell", {
|
||||
section_id: sectionIds[0],
|
||||
index: 0,
|
||||
type,
|
||||
});
|
||||
function insertCellBelowFocusableId(hook, focusableId, type) {
|
||||
if (isCell(focusableId)) {
|
||||
hook.pushEvent("insert_cell_below", { type, cell_id: focusableId });
|
||||
} else if (isSection(focusableId)) {
|
||||
hook.pushEvent("insert_cell_below", { type, section_id: focusableId });
|
||||
} else if (isNotebook(focusableId)) {
|
||||
const sectionIds = getSectionIds();
|
||||
if (sectionIds.length > 0) {
|
||||
hook.pushEvent("insert_cell_below", { type, section_id: sectionIds[0] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setFocusedCell(hook, cellId, scroll = true) {
|
||||
hook.state.focusedCellId = cellId;
|
||||
function setFocusedEl(
|
||||
hook,
|
||||
focusableId,
|
||||
{ scroll = true, focusElement = true } = {}
|
||||
) {
|
||||
hook.state.focusedId = focusableId;
|
||||
|
||||
if (hook.state.focusedCellId) {
|
||||
const cell = getCellById(hook.state.focusedCellId);
|
||||
hook.state.focusedCellType = cell.getAttribute("data-type");
|
||||
hook.state.focusedSectionId = getSectionIdByCellId(
|
||||
hook.state.focusedCellId
|
||||
);
|
||||
// Focus the primary cell content, this is important for screen readers
|
||||
const cellBody = cell.querySelector(`[data-element="cell-body"]`);
|
||||
cellBody.focus({ preventScroll: true });
|
||||
if (focusableId) {
|
||||
const el = getFocusableEl(focusableId);
|
||||
|
||||
if (isCell(focusableId)) {
|
||||
hook.state.focusedCellType = el.getAttribute("data-type");
|
||||
}
|
||||
|
||||
if (focusElement) {
|
||||
// Focus the primary content in the focusable element, this is important for screen readers
|
||||
const contentEl =
|
||||
el.querySelector(`[data-element="cell-body"]`) ||
|
||||
el.querySelector(`[data-element="heading"]`) ||
|
||||
el;
|
||||
contentEl.focus({ preventScroll: true });
|
||||
}
|
||||
} else {
|
||||
hook.state.focusedCellType = null;
|
||||
hook.state.focusedSectionId = null;
|
||||
}
|
||||
|
||||
globalPubSub.broadcast("cells", { type: "cell_focused", cellId, scroll });
|
||||
globalPubSub.broadcast("navigation", {
|
||||
type: "element_focused",
|
||||
focusableId: focusableId,
|
||||
scroll,
|
||||
});
|
||||
|
||||
setInsertMode(hook, false);
|
||||
}
|
||||
|
@ -754,12 +793,12 @@ function setInsertMode(hook, insertModeEnabled) {
|
|||
hook.el.removeAttribute("data-js-insert-mode");
|
||||
|
||||
sendLocationReport(hook, {
|
||||
cellId: hook.state.focusedCellId,
|
||||
focusableId: hook.state.focusedId,
|
||||
selection: null,
|
||||
});
|
||||
}
|
||||
|
||||
globalPubSub.broadcast("cells", {
|
||||
globalPubSub.broadcast("navigation", {
|
||||
type: "insert_mode_changed",
|
||||
enabled: insertModeEnabled,
|
||||
});
|
||||
|
@ -768,48 +807,41 @@ function setInsertMode(hook, insertModeEnabled) {
|
|||
// Server event handlers
|
||||
|
||||
function handleCellInserted(hook, cellId) {
|
||||
setFocusedCell(hook, cellId);
|
||||
setFocusedEl(hook, cellId);
|
||||
if (["markdown", "elixir"].includes(hook.state.focusedCellType)) {
|
||||
setInsertMode(hook, true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCellDeleted(hook, cellId, siblingCellId) {
|
||||
if (hook.state.focusedCellId === cellId) {
|
||||
setFocusedCell(hook, siblingCellId);
|
||||
if (hook.state.focusedId === cellId) {
|
||||
setFocusedEl(hook, siblingCellId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCellRestored(hook, cellId) {
|
||||
setFocusedCell(hook, cellId);
|
||||
setFocusedEl(hook, cellId);
|
||||
}
|
||||
|
||||
function handleCellMoved(hook, cellId) {
|
||||
if (hook.state.focusedCellId === cellId) {
|
||||
if (hook.state.focusedId === cellId) {
|
||||
globalPubSub.broadcast("cells", { 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);
|
||||
const headlineEl = section.querySelector(`[data-element="section-headline"]`);
|
||||
const { focusableId } = headlineEl.dataset;
|
||||
setFocusedEl(hook, focusableId);
|
||||
setInsertMode(hook, true);
|
||||
selectElementContent(document.activeElement);
|
||||
}
|
||||
|
||||
function handleSectionDeleted(hook, sectionId) {
|
||||
if (hook.state.focusedSectionId === sectionId) {
|
||||
setFocusedCell(hook, null);
|
||||
// Clear focus if the element no longer exists
|
||||
if (hook.state.focusedId && !getFocusableEl(hook.state.focusedId)) {
|
||||
setFocusedEl(hook, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -819,8 +851,8 @@ function handleSectionMoved(hook, sectionId) {
|
|||
}
|
||||
|
||||
function handleCellUpload(hook, cellId, url) {
|
||||
if (hook.state.focusedCellId !== cellId) {
|
||||
setFocusedCell(hook, cellId);
|
||||
if (hook.state.focusedId !== cellId) {
|
||||
setFocusedEl(hook, cellId);
|
||||
}
|
||||
|
||||
if (!hook.state.insertMode) {
|
||||
|
@ -840,7 +872,7 @@ function handleClientLeft(hook, clientPid) {
|
|||
if (client) {
|
||||
delete hook.state.clientsMap[clientPid];
|
||||
|
||||
broadcastLocationReport(client, { cellId: null, selection: null });
|
||||
broadcastLocationReport(client, { focusableId: null, selection: null });
|
||||
|
||||
if (client.pid === hook.state.followedClientPid) {
|
||||
hook.state.followedClientPid = null;
|
||||
|
@ -864,29 +896,19 @@ function handleLocationReport(hook, clientPid, report) {
|
|||
|
||||
if (
|
||||
client.pid === hook.state.followedClientPid &&
|
||||
report.cellId !== hook.state.focusedCellId
|
||||
report.focusableId !== hook.state.focusedId
|
||||
) {
|
||||
setFocusedCell(hook, report.cellId);
|
||||
setFocusedEl(hook, report.focusableId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Session event handlers
|
||||
|
||||
function handleSessionEvent(hook, event) {
|
||||
if (event.type === "cursor_selection_changed") {
|
||||
sendLocationReport(hook, {
|
||||
cellId: event.cellId,
|
||||
focusableId: event.focusableId,
|
||||
selection: event.selection,
|
||||
});
|
||||
}
|
||||
|
@ -896,7 +918,7 @@ function handleSessionEvent(hook, event) {
|
|||
* Broadcast new location report coming from the server to all the cells.
|
||||
*/
|
||||
function broadcastLocationReport(client, report) {
|
||||
globalPubSub.broadcast("cells", {
|
||||
globalPubSub.broadcast("navigation", {
|
||||
type: "location_report",
|
||||
client,
|
||||
report,
|
||||
|
@ -912,7 +934,7 @@ function sendLocationReport(hook, report) {
|
|||
// Only send reports if there are other people to send to
|
||||
if (numberOfClients > 1) {
|
||||
hook.pushEvent("location_report", {
|
||||
cell_id: report.cellId,
|
||||
focusable_id: report.focusableId,
|
||||
selection: encodeSelection(report.selection),
|
||||
});
|
||||
}
|
||||
|
@ -949,44 +971,51 @@ function decodeSelection(encoded) {
|
|||
|
||||
// Helpers
|
||||
|
||||
function nearbyCellId(cellId, offset) {
|
||||
const cellIds = getCellIds();
|
||||
function nearbyFocusableId(focusableId, offset) {
|
||||
const focusableIds = getFocusableIds();
|
||||
|
||||
if (cellIds.length === 0) {
|
||||
if (focusableIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const idx = cellIds.indexOf(cellId);
|
||||
const idx = focusableIds.indexOf(focusableId);
|
||||
|
||||
if (idx === -1) {
|
||||
return cellIds[0];
|
||||
return focusableIds[0];
|
||||
} else {
|
||||
const siblingIdx = clamp(idx + offset, 0, cellIds.length - 1);
|
||||
return cellIds[siblingIdx];
|
||||
const siblingIdx = clamp(idx + offset, 0, focusableIds.length - 1);
|
||||
return focusableIds[siblingIdx];
|
||||
}
|
||||
}
|
||||
|
||||
function getCellIds() {
|
||||
const cells = getCells();
|
||||
return cells.map((cell) => cell.getAttribute("data-cell-id"));
|
||||
function isCell(focusableId) {
|
||||
const el = getFocusableEl(focusableId);
|
||||
return el.dataset.element === "cell";
|
||||
}
|
||||
|
||||
function getCells() {
|
||||
return Array.from(document.querySelectorAll(`[data-element="cell"]`));
|
||||
function isSection(focusableId) {
|
||||
const el = getFocusableEl(focusableId);
|
||||
return el.dataset.element === "section-headline";
|
||||
}
|
||||
|
||||
function getCellById(cellId) {
|
||||
return document.querySelector(
|
||||
`[data-element="cell"][data-cell-id="${cellId}"]`
|
||||
);
|
||||
function isNotebook(focusableId) {
|
||||
const el = getFocusableEl(focusableId);
|
||||
return el.dataset.element === "notebook-headline";
|
||||
}
|
||||
|
||||
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 getFocusableEl(focusableId) {
|
||||
return document.querySelector(`[data-focusable-id="${focusableId}"]`);
|
||||
}
|
||||
|
||||
function getFocusableIds() {
|
||||
const elements = Array.from(document.querySelectorAll(`[data-focusable-id]`));
|
||||
return elements.map((el) => el.getAttribute("data-focusable-id"));
|
||||
}
|
||||
|
||||
function getSectionIdByFocusableId(focusableId) {
|
||||
const el = getFocusableEl(focusableId);
|
||||
const section = el.closest(`[data-element="section"]`);
|
||||
return section && section.getAttribute("data-section-id");
|
||||
}
|
||||
|
||||
function getSectionIds() {
|
||||
|
|
|
@ -34,6 +34,8 @@ defmodule LivebookWeb.Helpers do
|
|||
<div class={"relative max-h-full overflow-y-auto bg-white rounded-lg shadow-xl #{@class}"}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="0"
|
||||
autofocus
|
||||
phx-window-keydown={click_modal_close()}
|
||||
phx-click-away={click_modal_close()}
|
||||
phx-key="escape">
|
||||
|
@ -315,7 +317,7 @@ defmodule LivebookWeb.Helpers do
|
|||
<button>Open</button>
|
||||
</:toggle>
|
||||
<:content>
|
||||
<button class"menu-item">Option 1</button>
|
||||
<button class"menu-item" role="menuitem">Option 1</button>
|
||||
</:content>
|
||||
</.menu>
|
||||
"""
|
||||
|
@ -332,12 +334,14 @@ defmodule LivebookWeb.Helpers do
|
|||
<div
|
||||
phx-click={not @disabled && JS.toggle(to: "##{@id}-content")}
|
||||
phx-click-away={JS.hide(to: "##{@id}-content")}
|
||||
data-contextmenu-trigger-click={@secondary_click}>
|
||||
data-contextmenu-trigger-click={@secondary_click}
|
||||
phx-window-keydown={JS.hide(to: "##{@id}-content")}
|
||||
phx-key="escape">
|
||||
<%= render_slot(@toggle) %>
|
||||
</div>
|
||||
<div id={"#{@id}-content"} class={"hidden menu #{@position}"}>
|
||||
<menu id={"#{@id}-content"} class={"hidden menu #{@position}"} role="menu">
|
||||
<%= render_slot(@content) %>
|
||||
</div>
|
||||
</menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
|
@ -84,19 +84,14 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
<.menu id="path-selector-menu">
|
||||
<:toggle>
|
||||
<button class="icon-button" tabindex="-1">
|
||||
<.remix_icon icon="add-line" class="text-xl" />
|
||||
</button>
|
||||
</:toggle>
|
||||
<:content>
|
||||
<button class="menu-item text-gray-500" phx-click={js_show_new_dir_section()}>
|
||||
<.remix_icon icon="folder-add-fill" class="text-gray-400" />
|
||||
<span class="font-medium">New directory</span>
|
||||
</button>
|
||||
</:content>
|
||||
</.menu>
|
||||
<span class="tooltip top" data-tooltip="New directory">
|
||||
<button class="icon-button"
|
||||
tabindex="-1"
|
||||
aria-label="new directory"
|
||||
phx-click={js_show_new_dir_section()}>
|
||||
<.remix_icon icon="add-line" class="text-xl" />
|
||||
</button>
|
||||
</span>
|
||||
<%= if @inner_block do %>
|
||||
<div>
|
||||
<%= render_slot(@inner_block) %>
|
||||
|
@ -193,19 +188,22 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
~H"""
|
||||
<.menu id="file-system-menu" disabled={@file_system_select_disabled} position="left">
|
||||
<:toggle>
|
||||
<button type="button" class="button button-gray button-square-icon" disabled={@file_system_select_disabled}>
|
||||
<button type="button" class="button button-gray button-square-icon"
|
||||
aria-label="switch file system"
|
||||
disabled={@file_system_select_disabled}>
|
||||
<.file_system_icon file_system={@file.file_system} />
|
||||
</button>
|
||||
</:toggle>
|
||||
<:content>
|
||||
<%= for {file_system, index} <- @file_systems |> Enum.with_index() do %>
|
||||
<%= if file_system == @file.file_system do %>
|
||||
<button class="menu-item text-gray-900">
|
||||
<button class="menu-item text-gray-900" role="menuitem">
|
||||
<.file_system_icon file_system={file_system} />
|
||||
<span class="font-medium"><%= file_system_label(file_system) %></span>
|
||||
</button>
|
||||
<% else %>
|
||||
<button class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
phx-target={@myself}
|
||||
phx-click="set_file_system"
|
||||
phx-value-index={index}>
|
||||
|
@ -215,7 +213,8 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
<% end %>
|
||||
<% end %>
|
||||
<%= live_patch to: Routes.settings_path(@socket, :page),
|
||||
class: "menu-item text-gray-500 border-t border-gray-200" do %>
|
||||
class: "menu-item text-gray-500 border-t border-gray-200",
|
||||
role: "menuitem" do %>
|
||||
<.remix_icon icon="settings-3-line" />
|
||||
<span class="font-medium">Configure</span>
|
||||
<% end %>
|
||||
|
@ -287,6 +286,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
<:content>
|
||||
<%= if @file_info.editable do %>
|
||||
<button class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
phx-click="rename_file"
|
||||
phx-target={@myself}
|
||||
phx-value-path={@file_info.file.path}>
|
||||
|
@ -294,6 +294,7 @@ defmodule LivebookWeb.FileSelectComponent do
|
|||
<span class="font-medium">Rename</span>
|
||||
</button>
|
||||
<button class="menu-item text-red-600"
|
||||
role="menuitem"
|
||||
phx-click="delete_file"
|
||||
phx-target={@myself}
|
||||
phx-value-path={@file_info.file.path}>
|
||||
|
|
|
@ -38,6 +38,7 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
|
|||
<:content>
|
||||
<%= for order_by <- ["date", "title"] do %>
|
||||
<button class={"menu-item #{if order_by == @order_by, do: "text-gray-900", else: "text-gray-500"}"}
|
||||
role="menuitem"
|
||||
phx-click={JS.push("set_order", value: %{order_by: order_by}, target: @myself)}>
|
||||
<.remix_icon icon={order_by_icon(order_by)} />
|
||||
<span class="font-medium"><%= order_by_label(order_by) %></span>
|
||||
|
@ -85,25 +86,28 @@ defmodule LivebookWeb.HomeLive.SessionListComponent do
|
|||
</div>
|
||||
<.menu id={"session-#{session.id}-menu"}>
|
||||
<:toggle>
|
||||
<button class="icon-button">
|
||||
<button class="icon-button" aria-label="open session menu">
|
||||
<.remix_icon icon="more-2-fill" class="text-xl" />
|
||||
</button>
|
||||
</:toggle>
|
||||
<:content>
|
||||
<button class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
phx-click="fork_session"
|
||||
phx-value-id={session.id}>
|
||||
<.remix_icon icon="git-branch-line" />
|
||||
<span class="font-medium">Fork</span>
|
||||
</button>
|
||||
<a class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
href={live_dashboard_process_path(@socket, session.pid)}
|
||||
target="_blank">
|
||||
<.remix_icon icon="dashboard-2-line" />
|
||||
<span class="font-medium">See on Dashboard</span>
|
||||
</a>
|
||||
<%= live_patch to: Routes.home_path(@socket, :close_session, session.id),
|
||||
class: "menu-item text-red-600" do %>
|
||||
class: "menu-item text-red-600",
|
||||
role: "menuitem" do %>
|
||||
<.remix_icon icon="close-circle-line" />
|
||||
<span class="font-medium">Close</span>
|
||||
<% end %>
|
||||
|
|
|
@ -73,7 +73,7 @@ defmodule LivebookWeb.SessionLive do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="flex flex-grow h-full"
|
||||
id="session"
|
||||
id={"session-#{@session.id}"}
|
||||
data-element="session"
|
||||
phx-hook="Session"
|
||||
data-global-status={elem(@data_view.global_status, 0)}
|
||||
|
@ -121,46 +121,53 @@ defmodule LivebookWeb.SessionLive do
|
|||
</div>
|
||||
<div class="flex-grow overflow-y-auto scroll-smooth" data-element="notebook">
|
||||
<div class="w-full max-w-screen-lg px-16 mx-auto py-7">
|
||||
<div class="flex items-center pb-4 mb-6 space-x-4 border-b border-gray-200">
|
||||
<h1 class="flex-grow p-1 -ml-1 text-3xl font-semibold text-gray-800 border border-transparent rounded-lg hover:border-blue-200 focus:border-blue-300"
|
||||
aria-description="notebook title"
|
||||
id="notebook-name"
|
||||
data-element="notebook-name"
|
||||
contenteditable
|
||||
spellcheck="false"
|
||||
phx-blur="set_notebook_name"
|
||||
phx-hook="ContentEditable"
|
||||
data-update-attribute="phx-value-name"><%= @data_view.notebook_name %></h1>
|
||||
<div class="flex items-center pb-4 mb-6 space-x-4 border-b border-gray-200"
|
||||
data-element="notebook-headline"
|
||||
data-focusable-id="notebook"
|
||||
id="notebook"
|
||||
phx-hook="Headline"
|
||||
data-on-value-change="set_notebook_name"
|
||||
data-metadata="notebook">
|
||||
<h1 class="flex-grow p-1 -ml-1 text-3xl font-semibold text-gray-800 border border-transparent rounded-lg whitespace-pre-wrap"
|
||||
tabindex="0"
|
||||
id="notebook-heading"
|
||||
data-element="heading"
|
||||
spellcheck="false"><%= @data_view.notebook_name %></h1>
|
||||
<.menu id="session-menu">
|
||||
<:toggle>
|
||||
<button class="icon-button">
|
||||
<button class="icon-button" aria-label="open notebook menu">
|
||||
<.remix_icon icon="more-2-fill" class="text-xl" />
|
||||
</button>
|
||||
</:toggle>
|
||||
<:content>
|
||||
<%= live_patch to: Routes.session_path(@socket, :export, @session.id, "livemd"),
|
||||
class: "menu-item text-gray-500" do %>
|
||||
class: "menu-item text-gray-500",
|
||||
role: "menuitem" do %>
|
||||
<.remix_icon icon="download-2-line" />
|
||||
<span class="font-medium">Export</span>
|
||||
<% end %>
|
||||
<button class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
phx-click="erase_outputs">
|
||||
<.remix_icon icon="eraser-fill" />
|
||||
<span class="font-medium">Erase outputs</span>
|
||||
</button>
|
||||
<button class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
phx-click="fork_session">
|
||||
<.remix_icon icon="git-branch-line" />
|
||||
<span class="font-medium">Fork</span>
|
||||
</button>
|
||||
<a class="menu-item text-gray-500"
|
||||
role="menuitem"
|
||||
href={live_dashboard_process_path(@socket, @session.pid)}
|
||||
target="_blank">
|
||||
<.remix_icon icon="dashboard-2-line" />
|
||||
<span class="font-medium">See on Dashboard</span>
|
||||
</a>
|
||||
<%= live_patch to: Routes.home_path(@socket, :close_session, @session.id),
|
||||
class: "menu-item text-red-600" do %>
|
||||
class: "menu-item text-red-600",
|
||||
role: "menuitem" do %>
|
||||
<.remix_icon icon="close-circle-line" />
|
||||
<span class="font-medium">Close</span>
|
||||
<% end %>
|
||||
|
@ -560,9 +567,15 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("insert_section_into", %{"section_id" => section_id, "index" => index}, socket) do
|
||||
index = ensure_integer(index) |> max(0)
|
||||
Session.insert_section_into(socket.assigns.session.pid, section_id, index)
|
||||
def handle_event("insert_section_below", params, socket) do
|
||||
with {:ok, section, index} <-
|
||||
section_with_next_index(
|
||||
socket.private.data.notebook,
|
||||
params["section_id"],
|
||||
params["cell_id"]
|
||||
) do
|
||||
Session.insert_section_into(socket.assigns.session.pid, section.id, index)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
@ -583,28 +596,17 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
"insert_cell",
|
||||
%{"section_id" => section_id, "index" => index, "type" => type},
|
||||
socket
|
||||
) do
|
||||
index = ensure_integer(index) |> max(0)
|
||||
def handle_event("insert_cell_below", %{"type" => type} = params, socket) do
|
||||
type = String.to_atom(type)
|
||||
Session.insert_cell(socket.assigns.session.pid, section_id, index, type)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("insert_cell_below", %{"cell_id" => cell_id, "type" => type}, socket) do
|
||||
type = String.to_atom(type)
|
||||
insert_cell_next_to(socket, cell_id, type, idx_offset: 1)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("insert_cell_above", %{"cell_id" => cell_id, "type" => type}, socket) do
|
||||
type = String.to_atom(type)
|
||||
insert_cell_next_to(socket, cell_id, type, idx_offset: 0)
|
||||
with {:ok, section, index} <-
|
||||
section_with_next_index(
|
||||
socket.private.data.notebook,
|
||||
params["section_id"],
|
||||
params["cell_id"]
|
||||
) do
|
||||
Session.insert_cell(socket.assigns.session.pid, section.id, index, type)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
@ -615,14 +617,14 @@ defmodule LivebookWeb.SessionLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("set_notebook_name", %{"name" => name}, socket) do
|
||||
def handle_event("set_notebook_name", %{"value" => name}, socket) do
|
||||
name = normalize_name(name)
|
||||
Session.set_notebook_name(socket.assigns.session.pid, name)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("set_section_name", %{"section_id" => section_id, "name" => name}, socket) do
|
||||
def handle_event("set_section_name", %{"metadata" => section_id, "value" => name}, socket) do
|
||||
name = normalize_name(name)
|
||||
Session.set_section_name(socket.assigns.session.pid, section_id, name)
|
||||
|
||||
|
@ -1191,10 +1193,19 @@ defmodule LivebookWeb.SessionLive do
|
|||
String.upcase(head) <> tail
|
||||
end
|
||||
|
||||
defp insert_cell_next_to(socket, cell_id, type, idx_offset: idx_offset) do
|
||||
{:ok, cell, section} = Notebook.fetch_cell_and_section(socket.private.data.notebook, cell_id)
|
||||
index = Enum.find_index(section.cells, &(&1 == cell))
|
||||
Session.insert_cell(socket.assigns.session.pid, section.id, index + idx_offset, type)
|
||||
defp section_with_next_index(notebook, section_id, cell_id)
|
||||
|
||||
defp section_with_next_index(notebook, section_id, nil) do
|
||||
with {:ok, section} <- Notebook.fetch_section(notebook, section_id) do
|
||||
{:ok, section, 0}
|
||||
end
|
||||
end
|
||||
|
||||
defp section_with_next_index(notebook, _section_id, cell_id) do
|
||||
with {:ok, cell, section} <- Notebook.fetch_cell_and_section(notebook, cell_id) do
|
||||
index = Enum.find_index(section.cells, &(&1 == cell))
|
||||
{:ok, section, index + 1}
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_integer(n) when is_integer(n), do: n
|
||||
|
|
|
@ -9,6 +9,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
id={"cell-#{@cell_view.id}"}
|
||||
phx-hook="Cell"
|
||||
data-cell-id={@cell_view.id}
|
||||
data-focusable-id={@cell_view.id}
|
||||
data-type={@cell_view.type}
|
||||
data-session-path={Routes.session_path(@socket, :page, @session_id)}>
|
||||
<%= render_cell(assigns) %>
|
||||
|
@ -19,7 +20,10 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
defp render_cell(%{cell_view: %{type: :markdown}} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-1 flex items-center justify-end">
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2"
|
||||
role="toolbar"
|
||||
aria-label="cell actions"
|
||||
data-element="actions">
|
||||
<span class="tooltip top" data-tooltip="Edit content" data-element="enable-insert-mode-button">
|
||||
<button class="icon-button" aria-label="edit content">
|
||||
<.remix_icon icon="pencil-line" class="text-xl" />
|
||||
|
@ -28,7 +32,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
<span class="tooltip top" data-tooltip="Insert image" data-element="insert-image-button">
|
||||
<%= live_patch to: Routes.session_path(@socket, :cell_upload, @session_id, @cell_view.id),
|
||||
class: "icon-button",
|
||||
aria_label: "insert image" do %>
|
||||
aria_label: "insert image",
|
||||
role: "button" do %>
|
||||
<.remix_icon icon="image-add-line" class="text-xl" />
|
||||
<% end %>
|
||||
</span>
|
||||
|
@ -87,9 +92,13 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2"
|
||||
role="toolbar"
|
||||
aria-label="cell actions"
|
||||
data-element="actions">
|
||||
<span class="tooltip top" data-tooltip="Amplify output" data-element="amplify-outputs-button">
|
||||
<button class="icon-button" aria-label="amplify outputs">
|
||||
<button class="icon-button"
|
||||
aria-label="amplify outputs">
|
||||
<.remix_icon icon="zoom-in-line" class="text-xl" />
|
||||
</button>
|
||||
</span>
|
||||
|
@ -116,7 +125,10 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
defp render_cell(%{cell_view: %{type: :input}} = assigns) do
|
||||
~H"""
|
||||
<div class="mb-1 flex items-center justify-end">
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2" data-element="actions">
|
||||
<div class="relative z-20 flex items-center justify-end space-x-2"
|
||||
role="toolbar"
|
||||
aria-label="cell actions"
|
||||
data-element="actions">
|
||||
<.cell_settings_button cell_id={@cell_view.id} socket={@socket} session_id={@session_id} />
|
||||
<.cell_link_button cell_id={@cell_view.id} />
|
||||
<.move_cell_up_button cell_id={@cell_view.id} />
|
||||
|
@ -246,8 +258,9 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
|
||||
defp cell_body(assigns) do
|
||||
~H"""
|
||||
<!-- By setting tabindex="-1" we can programmatically focus this element -->
|
||||
<div class="flex relative" data-element="cell-body" tabindex="-1">
|
||||
<!-- By setting tabindex we can programmatically focus this element,
|
||||
also we actually want to make this element tab-focusable -->
|
||||
<div class="flex relative" data-element="cell-body" tabindex="0">
|
||||
<div class="w-1 h-full rounded-lg absolute top-0 -left-3" data-element="cell-focus-indicator">
|
||||
</div>
|
||||
<div class="w-full">
|
||||
|
@ -260,7 +273,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
defp cell_link_button(assigns) do
|
||||
~H"""
|
||||
<span class="tooltip top" data-tooltip="Link">
|
||||
<a href={"#cell-#{@cell_id}"} class="icon-button" aria-label="link to cell">
|
||||
<a href={"#cell-#{@cell_id}"} class="icon-button"
|
||||
aria-label="link to cell">
|
||||
<.remix_icon icon="link" class="text-xl" />
|
||||
</a>
|
||||
</span>
|
||||
|
@ -272,7 +286,8 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
<span class="tooltip top" data-tooltip="Cell settings">
|
||||
<%= live_patch to: Routes.session_path(@socket, :cell_settings, @session_id, @cell_id),
|
||||
class: "icon-button",
|
||||
aria_label: "cell settings" do %>
|
||||
aria_label: "cell settings",
|
||||
role: "button" do %>
|
||||
<.remix_icon icon="settings-3-line" class="text-xl" />
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
|
@ -3,30 +3,33 @@ defmodule LivebookWeb.SessionLive.InsertButtonsComponent do
|
|||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="relative top-0.5 m-0 flex justify-center" data-element="insert-buttons">
|
||||
<div class="relative top-0.5 m-0 flex justify-center"
|
||||
role="toolbar"
|
||||
aria-label="insert new"
|
||||
data-element="insert-buttons">
|
||||
<div class={"w-full absolute z-10 #{if(@persistent, do: "opacity-100", else: "opacity-0")} hover:opacity-100 focus-within:opacity-100 flex space-x-2 justify-center items-center"}>
|
||||
<button class="button button-small"
|
||||
phx-click="insert_cell"
|
||||
phx-click="insert_cell_below"
|
||||
phx-value-type="markdown"
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-index={@insert_cell_index}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>+ Markdown</button>
|
||||
<button class="button button-small"
|
||||
phx-click="insert_cell"
|
||||
phx-click="insert_cell_below"
|
||||
phx-value-type="elixir"
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-index={@insert_cell_index}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>+ Elixir</button>
|
||||
<button class="button button-small"
|
||||
phx-click="insert_cell"
|
||||
phx-click="insert_cell_below"
|
||||
phx-value-type="input"
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-index={@insert_cell_index}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>+ Input</button>
|
||||
<button class="button button-small"
|
||||
phx-click="insert_section_into"
|
||||
phx-click="insert_section_below"
|
||||
phx-value-section_id={@section_id}
|
||||
phx-value-index={@insert_cell_index}
|
||||
phx-value-cell_id={@cell_id}
|
||||
>+ Section</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -3,22 +3,22 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div data-element="section" data-section-id={@section_view.id}>
|
||||
<div class="flex space-x-4 items-center" data-element="section-headline">
|
||||
<h2 class="flex-grow text-gray-800 font-semibold text-2xl px-1 -ml-1 rounded-lg border
|
||||
border-transparent hover:border-blue-200 focus:border-blue-300"
|
||||
aria-description="section title"
|
||||
data-element="section-name"
|
||||
<section data-element="section" data-section-id={@section_view.id}>
|
||||
<div class="flex space-x-4 items-center"
|
||||
data-element="section-headline"
|
||||
id={@section_view.id}
|
||||
data-focusable-id={@section_view.id}
|
||||
phx-hook="Headline"
|
||||
data-on-value-change="set_section_name"
|
||||
data-metadata={@section_view.id}>
|
||||
<h2 class="flex-grow text-gray-800 font-semibold text-2xl px-1 -ml-1 rounded-lg border border-transparent whitespace-pre-wrap cursor-text"
|
||||
tabindex="0"
|
||||
id={@section_view.html_id}
|
||||
contenteditable
|
||||
spellcheck="false"
|
||||
phx-blur="set_section_name"
|
||||
phx-value-section_id={@section_view.id}
|
||||
phx-hook="ContentEditable"
|
||||
data-update-attribute="phx-value-name"><%= @section_view.name %></h2>
|
||||
<%# ^ Note it's important there's no space between <h2> and </h2>
|
||||
because we want the content to exactly match section name. %>
|
||||
<div class="flex space-x-2 items-center" data-element="section-actions">
|
||||
data-element="heading"
|
||||
spellcheck="false"><%= @section_view.name %></h2>
|
||||
<div class="flex space-x-2 items-center" data-element="section-actions"
|
||||
role="toolbar"
|
||||
aria-label="section actions">
|
||||
<span class="tooltip top" data-tooltip="Link">
|
||||
<a href={"##{@section_view.html_id}"} class="icon-button" aria-label="link to section">
|
||||
<.remix_icon icon="link" class="text-xl" />
|
||||
|
@ -28,8 +28,7 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
<.menu id={"section-#{@section_view.id}-branch-menu"}>
|
||||
<:toggle>
|
||||
<span class="tooltip top" data-tooltip="Branch out from">
|
||||
<button class="icon-button"
|
||||
aria-label="branch out from other section">
|
||||
<button class="icon-button" aria-label="branch out from other section">
|
||||
<.remix_icon icon="git-branch-line" class="text-xl flip-horizontally" />
|
||||
</button>
|
||||
</span>
|
||||
|
@ -74,10 +73,14 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
<.remix_icon icon="arrow-down-s-line" class="text-xl" />
|
||||
</button>
|
||||
</span>
|
||||
<span class="tooltip top" data-tooltip={if @section_view.has_children?, do: "Cannot delete this section because\nother sections branch from it", else: "Delete"}>
|
||||
<span
|
||||
{if @section_view.has_children?,
|
||||
do: [class: "tooltip left", data_tooltip: "Cannot delete this section because\nother sections branch from it"],
|
||||
else: [class: "tooltip top", data_tooltip: "Delete"]}>
|
||||
<%= live_patch to: Routes.session_path(@socket, :delete_section, @session_id, @section_view.id),
|
||||
class: "icon-button #{if @section_view.has_children?, do: "disabled"}",
|
||||
aria_label: "delete section" do %>
|
||||
aria_label: "delete section",
|
||||
role: "button" do %>
|
||||
<.remix_icon icon="delete-bin-6-line" class="text-xl" />
|
||||
<% end %>
|
||||
</span>
|
||||
|
@ -93,28 +96,26 @@ defmodule LivebookWeb.SessionLive.SectionComponent do
|
|||
<% end %>
|
||||
<div class="container">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||
id={"#{@section_view.id}:first"}
|
||||
persistent={@section_view.cell_views == []}
|
||||
section_id={@section_view.id}
|
||||
cell_id={nil} />
|
||||
<%= for {cell_view, index} <- Enum.with_index(@section_view.cell_views) do %>
|
||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||
id={"#{@section_view.id}:#{index}"}
|
||||
persistent={false}
|
||||
section_id={@section_view.id}
|
||||
insert_cell_index={index} />
|
||||
|
||||
<.live_component module={LivebookWeb.SessionLive.CellComponent}
|
||||
id={cell_view.id}
|
||||
session_id={@session_id}
|
||||
runtime={@runtime}
|
||||
cell_view={cell_view} />
|
||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||
id={"#{@section_view.id}:#{index}"}
|
||||
persistent={false}
|
||||
section_id={@section_view.id}
|
||||
cell_id={cell_view.id} />
|
||||
<% end %>
|
||||
|
||||
<.live_component module={LivebookWeb.SessionLive.InsertButtonsComponent}
|
||||
id={"#{@section_view.id}:last"}
|
||||
persistent={@section_view.cell_views == []}
|
||||
section_id={@section_view.id}
|
||||
insert_cell_index={length(@section_view.cell_views)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,9 +14,9 @@ defmodule LivebookWeb.SidebarHelpers do
|
|||
"""
|
||||
def sidebar(assigns) do
|
||||
~H"""
|
||||
<div class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
||||
<nav class="w-16 flex flex-col items-center space-y-5 px-3 py-7 bg-gray-900">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</div>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
|
||||
|
||||
assert %{cell_infos: %{^cell_id => %{evaluation_status: :evaluating}}} =
|
||||
|
@ -115,11 +115,11 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("queue_cell_evaluation", %{"cell_id" => cell_id})
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("cancel_cell_evaluation", %{"cell_id" => cell_id})
|
||||
|
||||
assert %{cell_infos: %{^cell_id => %{evaluation_status: :ready}}} =
|
||||
|
@ -133,22 +133,22 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("insert_cell_below", %{"cell_id" => cell_id, "type" => "markdown"})
|
||||
|
||||
assert %{notebook: %{sections: [%{cells: [_first_cell, %Cell.Markdown{}]}]}} =
|
||||
Session.get_data(session.pid)
|
||||
end
|
||||
|
||||
test "inserting a cell above the given cell", %{conn: conn, session: session} do
|
||||
test "inserting a cell at section start", %{conn: conn, session: session} do
|
||||
section_id = insert_section(session.pid)
|
||||
cell_id = insert_text_cell(session.pid, section_id, :elixir)
|
||||
_cell_id = insert_text_cell(session.pid, section_id, :elixir)
|
||||
|
||||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> render_hook("insert_cell_above", %{"cell_id" => cell_id, "type" => "markdown"})
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("insert_cell_below", %{"section_id" => section_id, "type" => "markdown"})
|
||||
|
||||
assert %{notebook: %{sections: [%{cells: [%Cell.Markdown{}, _first_cell]}]}} =
|
||||
Session.get_data(session.pid)
|
||||
|
@ -161,7 +161,7 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("delete_cell", %{"cell_id" => cell_id})
|
||||
|
||||
assert %{notebook: %{sections: [%{cells: []}]}} = Session.get_data(session.pid)
|
||||
|
@ -285,7 +285,7 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("intellisense_request", %{
|
||||
"cell_id" => cell_id,
|
||||
"type" => "completion",
|
||||
|
@ -306,7 +306,7 @@ defmodule LivebookWeb.SessionLiveTest do
|
|||
{:ok, view, _} = live(conn, "/sessions/#{session.id}")
|
||||
|
||||
view
|
||||
|> element("#session")
|
||||
|> element(~s{[data-element="session"]})
|
||||
|> render_hook("intellisense_request", %{
|
||||
"cell_id" => cell_id,
|
||||
"type" => "completion",
|
||||
|
|
Loading…
Add table
Reference in a new issue