Refactor JS hooks (#1055)

* Restructure hook files

* Simplify app.js

* Refactor hooks

* Implement password toggle with JS commands
This commit is contained in:
Jonatan Kłosko 2022-03-16 11:33:53 +01:00 committed by GitHub
parent 99c3eb4108
commit b3b79afed4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 2660 additions and 2731 deletions

View file

@ -13,6 +13,10 @@ solely client-side operations.
@apply hidden; @apply hidden;
} }
[phx-hook="Dropzone"][data-js-dragging] {
@apply bg-red-200 border-red-400;
}
/* === Session === */ /* === Session === */
[data-element="session"]:not([data-js-insert-mode]) [data-element="session"]:not([data-js-insert-mode])

View file

@ -1,7 +1,6 @@
import "../css/app.css"; import "../css/app.css";
import "remixicon/fonts/remixicon.css"; import "remixicon/fonts/remixicon.css";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "@fontsource/inter"; import "@fontsource/inter";
import "@fontsource/inter/500.css"; import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css"; import "@fontsource/inter/600.css";
@ -9,48 +8,13 @@ import "@fontsource/jetbrains-mono";
import "phoenix_html"; import "phoenix_html";
import { Socket } from "phoenix"; import { Socket } from "phoenix";
import topbar from "topbar";
import { LiveSocket } from "phoenix_live_view"; import { LiveSocket } from "phoenix_live_view";
import Headline from "./headline";
import Cell from "./cell"; import hooks from "./hooks";
import CellEditor from "./cell_editor"; import { morphdomOptions } from "./dom";
import Session from "./session";
import FocusOnUpdate from "./focus_on_update";
import ScrollOnUpdate from "./scroll_on_update";
import VirtualizedLines from "./virtualized_lines";
import UserForm from "./user_form";
import EditorSettings from "./editor_settings";
import Timer from "./timer";
import MarkdownRenderer from "./markdown_renderer";
import Highlight from "./highlight";
import DragAndDrop from "./drag_and_drop";
import PasswordToggle from "./password_toggle";
import KeyboardControl from "./keyboard_control";
import ConfirmModal from "./confirm_modal";
import morphdomCallbacks from "./morphdom_callbacks";
import JSView from "./js_view";
import { loadUserData } from "./lib/user"; import { loadUserData } from "./lib/user";
import { settingsStore } from "./lib/settings"; import { settingsStore } from "./lib/settings";
import { registerTopbar, registerGlobalEventHandlers } from "./events";
const hooks = {
Headline,
Cell,
CellEditor,
Session,
FocusOnUpdate,
ScrollOnUpdate,
VirtualizedLines,
UserForm,
EditorSettings,
Timer,
MarkdownRenderer,
Highlight,
DragAndDrop,
PasswordToggle,
KeyboardControl,
JSView,
ConfirmModal,
};
const csrfToken = document const csrfToken = document
.querySelector("meta[name='csrf-token']") .querySelector("meta[name='csrf-token']")
@ -65,101 +29,25 @@ const liveSocket = new LiveSocket("/live", Socket, {
}; };
}, },
hooks: hooks, hooks: hooks,
dom: morphdomCallbacks, dom: morphdomOptions,
}); });
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({ registerTopbar();
barColors: { 0: "#b2c1ff" },
shadowColor: "rgba(0, 0, 0, .3)",
});
let topBarScheduled = null; // Handle custom events dispatched with JS.dispatch/3
registerGlobalEventHandlers();
window.addEventListener("phx:page-loading-start", () => {
if (!topBarScheduled) {
topBarScheduled = setTimeout(() => topbar.show(), 200);
}
});
window.addEventListener("phx:page-loading-stop", () => {
clearTimeout(topBarScheduled);
topBarScheduled = null;
topbar.hide();
});
// connect if there are any LiveViews on the page
liveSocket.connect();
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket;
// Handling custom events dispatched with JS.dispatch/3
window.addEventListener("lb:focus", (event) => {
// The element may be about to show up via JS.show, which wraps the
// change in requestAnimationFrame, so we do the same to make sure
// the focus is applied only after we change the element visibility
requestAnimationFrame(() => {
event.target.focus();
});
});
window.addEventListener("lb:set_value", (event) => {
event.target.value = event.detail.value;
});
window.addEventListener("lb:check", (event) => {
event.target.checked = true;
});
window.addEventListener("lb:uncheck", (event) => {
event.target.checked = false;
});
window.addEventListener("lb:set_text", (event) => {
event.target.textContent = event.detail.value;
});
window.addEventListener("lb:clipcopy", (event) => {
if ("clipboard" in navigator) {
const text = event.target.textContent;
navigator.clipboard.writeText(text);
} else {
alert(
"Sorry, your browser does not support clipboard copy.\nThis generally requires a secure origin — either HTTPS or localhost."
);
}
});
// Other global handlers
window.addEventListener("contextmenu", (event) => {
const target = event.target.closest("[data-contextmenu-trigger-click]");
if (target) {
event.preventDefault();
target.dispatchEvent(new Event("click", { bubbles: true }));
}
});
window.addEventListener("lb:session_list:on_selection_change", () => {
const anySessionSelected = !!document.querySelector(
"[name='session_ids[]']:checked"
);
const disconnect = document.querySelector(
"#edit-sessions [name='disconnect']"
);
const closeAll = document.querySelector("#edit-sessions [name='close_all']");
disconnect.disabled = !anySessionSelected;
closeAll.disabled = !anySessionSelected;
});
// Global configuration
// Reflect global configuration in attributes to enable CSS rules
settingsStore.getAndSubscribe((settings) => { settingsStore.getAndSubscribe((settings) => {
document.body.setAttribute("data-editor-theme", settings.editor_theme); document.body.setAttribute("data-editor-theme", settings.editor_theme);
}); });
// Connect if there are any LiveViews on the page
liveSocket.connect();
// Expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket;

View file

@ -1,348 +0,0 @@
import { getAttributeOrDefault, getAttributeOrThrow } from "../lib/attribute";
import Markdown from "../lib/markdown";
import { globalPubSub } from "../lib/pub_sub";
import { md5Base64, smoothlyScrollToElement } from "../lib/utils";
import scrollIntoView from "scroll-into-view-if-needed";
import { isEvaluable } from "../lib/notebook";
/**
* A hook managing a single cell.
*
* Mounts and manages the collaborative editor,
* takes care of markdown rendering and focusing the editor when applicable.
*
* Configuration:
*
* * `data-cell-id` - id of the cell being edited
* * `data-type` - type of the cell
* * `data-session-path` - root path to the current session
* * `data-evaluation-digest` - digest of the last evaluated cell source
*/
const Cell = {
mounted() {
this.props = getProps(this);
this.state = {
isFocused: false,
insertMode: false,
liveEditors: {},
};
updateInsertModeAvailability(this);
// Setup action handlers
if (this.props.type === "code") {
const amplifyButton = this.el.querySelector(
`[data-element="amplify-outputs-button"]`
);
amplifyButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-amplified");
});
}
if (this.props.type === "smart") {
const toggleSourceButton = this.el.querySelector(
`[data-element="toggle-source-button"]`
);
toggleSourceButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-source-visible");
updateInsertModeAvailability(this);
maybeFocusCurrentEditor(this);
});
}
// Setup listeners
this.el.addEventListener("lb:cell:editor_created", (event) => {
const { tag, liveEditor } = event.detail;
handleCellEditorCreated(this, tag, liveEditor);
});
this.el.addEventListener("lb:cell:editor_removed", (event) => {
const { tag } = event.detail;
handleCellEditorRemoved(this, tag);
});
// We manually track hover to correctly handle absolute iframe
this.el.addEventListener("mouseenter", (event) => {
this.el.setAttribute("data-js-hover", "true");
});
this.el.addEventListener("mouseleave", (event) => {
this.el.removeAttribute("data-js-hover");
});
this._unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => {
handleNavigationEvent(this, event);
}
);
this._unsubscribeFromCellsEvents = globalPubSub.subscribe(
"cells",
(event) => {
handleCellsEvent(this, event);
}
);
},
disconnected() {
// When disconnected, this client is no longer seen by the server
// and misses all collaborative changes. On reconnection we want
// to clean up and mount a fresh hook, which we force by ensuring
// the DOM id doesn't match
this.el.removeAttribute("id");
},
destroyed() {
this._unsubscribeFromNavigationEvents();
this._unsubscribeFromCellsEvents();
},
updated() {
const prevProps = this.props;
this.props = getProps(this);
if (this.props.evaluationDigest !== prevProps.evaluationDigest) {
updateChangeIndicator(this);
}
},
};
function getProps(hook) {
return {
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
type: getAttributeOrThrow(hook.el, "data-type"),
sessionPath: getAttributeOrThrow(hook.el, "data-session-path"),
evaluationDigest: getAttributeOrDefault(
hook.el,
"data-evaluation-digest",
null
),
};
}
/**
* Handles client-side navigation event.
*/
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 === "location_report") {
handleLocationReport(hook, event.client, event.report);
}
}
/**
* 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) {
smoothlyScrollToElement(hook.el);
}
} else if (hook.state.isFocused) {
hook.state.isFocused = false;
hook.el.removeAttribute("data-js-focused");
}
}
function handleCellEditorCreated(hook, tag, liveEditor) {
hook.state.liveEditors[tag] = liveEditor;
updateInsertModeAvailability(hook);
if (liveEditor === currentEditor(hook)) {
// Once the editor is created, reflect the current insert mode state
maybeFocusCurrentEditor(hook, true);
}
liveEditor.onBlur(() => {
// Prevent from blurring unless the state changes. For example
// when we move cell using buttons the editor should keep focus
if (hook.state.isFocused && hook.state.insertMode) {
currentEditor(hook).focus();
}
});
liveEditor.onCursorSelectionChange((selection) => {
broadcastSelection(hook, selection);
});
if (tag === "primary") {
// Setup markdown rendering
if (hook.props.type === "markdown") {
const markdownContainer = hook.el.querySelector(
`[data-element="markdown-container"]`
);
const markdown = new Markdown(markdownContainer, liveEditor.getSource(), {
baseUrl: hook.props.sessionPath,
emptyText: "Empty markdown cell",
});
liveEditor.onChange((newSource) => {
markdown.setContent(newSource);
});
}
// Setup change indicator
if (isEvaluable(hook.props.type)) {
updateChangeIndicator(hook);
liveEditor.onChange((newSource) => {
updateChangeIndicator(hook);
});
hook.handleEvent(
`evaluation_finished:${hook.props.cellId}`,
({ code_error }) => {
liveEditor.setCodeErrorMarker(code_error);
}
);
}
}
}
function handleCellEditorRemoved(hook, tag) {
delete hook.state.liveEditors[tag];
}
function currentEditor(hook) {
return hook.state.liveEditors[currentEditorTag(hook)];
}
function currentEditorTag(hook) {
if (hook.props.type === "smart") {
const isSourceTab = hook.el.hasAttribute("data-js-source-visible");
return isSourceTab ? "primary" : "secondary";
}
return "primary";
}
function updateInsertModeAvailability(hook) {
hook.el.toggleAttribute("data-js-insert-mode-disabled", !currentEditor(hook));
}
function maybeFocusCurrentEditor(hook, scroll = false) {
if (hook.state.isFocused && hook.state.insertMode) {
currentEditor(hook).focus();
if (scroll) {
// If the element is being scrolled to, focus interrupts it,
// so ensure the scrolling continues.
smoothlyScrollToElement(hook.el);
}
broadcastSelection(hook);
}
}
function updateChangeIndicator(hook) {
const cellStatus = hook.el.querySelector(`[data-element="cell-status"]`);
const indicator =
cellStatus && cellStatus.querySelector(`[data-element="change-indicator"]`);
if (indicator && hook.props.evaluationDigest) {
const source = hook.state.liveEditors.primary.getSource();
const digest = md5Base64(source);
const changed = hook.props.evaluationDigest !== digest;
cellStatus.toggleAttribute("data-js-changed", changed);
}
}
function handleInsertModeChanged(hook, insertMode) {
if (hook.state.isFocused && !hook.state.insertMode && insertMode) {
hook.state.insertMode = insertMode;
if (currentEditor(hook)) {
currentEditor(hook).focus();
// The insert mode may be enabled as a result of clicking the editor,
// in which case we want to wait until editor handles the click and
// sets new cursor position. To achieve this, we simply put this task
// at the end of event loop, ensuring the editor mousedown handler is
// executed first
setTimeout(() => {
scrollIntoView(document.activeElement, {
scrollMode: "if-needed",
behavior: "smooth",
block: "center",
});
}, 0);
broadcastSelection(hook);
}
} else if (hook.state.insertMode && !insertMode) {
hook.state.insertMode = insertMode;
if (currentEditor(hook)) {
currentEditor(hook).blur();
}
}
}
function handleCellMoved(hook, cellId) {
if (hook.state.isFocused && cellId === hook.props.cellId) {
smoothlyScrollToElement(hook.el);
}
}
function handleCellUpload(hook, cellId, url) {
const liveEditor = hook.state.liveEditors.primary;
if (!liveEditor) {
return;
}
if (hook.props.cellId === cellId) {
const markdown = `![](${url})`;
liveEditor.insert(markdown);
}
}
function handleLocationReport(hook, client, report) {
Object.entries(hook.state.liveEditors).forEach(([tag, liveEditor]) => {
if (
hook.props.cellId === report.focusableId &&
report.selection &&
report.selection.tag === tag
) {
liveEditor.updateUserSelection(client, report.selection.editorSelection);
} else {
liveEditor.removeUserSelection(client);
}
});
}
function broadcastSelection(hook, editorSelection = null) {
editorSelection =
editorSelection || currentEditor(hook).editor.getSelection();
const tag = currentEditorTag(hook);
// Report new selection only if this cell is in insert mode
if (hook.state.isFocused && hook.state.insertMode) {
globalPubSub.broadcast("session", {
type: "cursor_selection_changed",
focusableId: hook.props.cellId,
selection: { tag, editorSelection },
});
}
}
export default Cell;

View file

@ -1,4 +1,4 @@
const callbacks = { export const morphdomOptions = {
onBeforeElUpdated(from, to) { onBeforeElUpdated(from, to) {
// Keep element attributes starting with data-js- // Keep element attributes starting with data-js-
// which we set on the client. // which we set on the client.
@ -29,5 +29,3 @@ const callbacks = {
} }
}, },
}; };
export default callbacks;

View file

@ -1,25 +0,0 @@
const DragAndDrop = {
mounted() {
const dropZone = this.el.querySelector("[data-dropzone]");
["dragenter", "dragover"].forEach((eventName) => {
dropZone.addEventListener(eventName, highlight, false);
});
["dragleave", "drop"].forEach((eventName) => {
dropZone.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
dropZone.classList.add("bg-red-200");
dropZone.classList.add("border-red-400");
}
function unhighlight(e) {
dropZone.classList.remove("bg-red-200");
dropZone.classList.remove("border-red-400");
}
},
};
export default DragAndDrop;

83
assets/js/events.js Normal file
View file

@ -0,0 +1,83 @@
import topbar from "topbar";
export function registerTopbar() {
topbar.config({
barColors: { 0: "#b2c1ff" },
shadowColor: "rgba(0, 0, 0, .3)",
});
let topBarScheduled = null;
window.addEventListener("phx:page-loading-start", () => {
if (!topBarScheduled) {
topBarScheduled = setTimeout(() => topbar.show(), 200);
}
});
window.addEventListener("phx:page-loading-stop", () => {
clearTimeout(topBarScheduled);
topBarScheduled = null;
topbar.hide();
});
}
export function registerGlobalEventHandlers() {
window.addEventListener("lb:focus", (event) => {
// The element may be about to show up via JS.show, which wraps the
// change in requestAnimationFrame, so we do the same to make sure
// the focus is applied only after we change the element visibility
requestAnimationFrame(() => {
event.target.focus();
});
});
window.addEventListener("lb:set_value", (event) => {
event.target.value = event.detail.value;
});
window.addEventListener("lb:check", (event) => {
event.target.checked = true;
});
window.addEventListener("lb:uncheck", (event) => {
event.target.checked = false;
});
window.addEventListener("lb:set_text", (event) => {
event.target.textContent = event.detail.value;
});
window.addEventListener("lb:clipcopy", (event) => {
if ("clipboard" in navigator) {
const text = event.target.textContent;
navigator.clipboard.writeText(text);
} else {
alert(
"Sorry, your browser does not support clipboard copy.\nThis generally requires a secure origin — either HTTPS or localhost."
);
}
});
window.addEventListener("lb:session_list:on_selection_change", () => {
const anySessionSelected = !!document.querySelector(
"[name='session_ids[]']:checked"
);
const disconnect = document.querySelector(
"#edit-sessions [name='disconnect']"
);
const closeAll = document.querySelector(
"#edit-sessions [name='close_all']"
);
disconnect.disabled = !anySessionSelected;
closeAll.disabled = !anySessionSelected;
});
window.addEventListener("contextmenu", (event) => {
const target = event.target.closest("[data-contextmenu-trigger-click]");
if (target) {
event.preventDefault();
target.dispatchEvent(new Event("click", { bubbles: true }));
}
});
}

View file

@ -1,136 +0,0 @@
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) {
const heading = getHeading(hook);
if (hook.state.isFocused && !hook.state.insertMode && insertMode) {
hook.state.insertMode = insertMode;
// While in insert mode, ignore the incoming changes
hook.el.setAttribute("phx-update", "ignore");
heading.setAttribute("contenteditable", "true");
heading.focus();
moveSelectionToEnd(heading);
} else if (hook.state.insertMode && !insertMode) {
hook.state.insertMode = insertMode;
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;

View file

@ -1,67 +0,0 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { highlight } from "../cell_editor/live_editor/monaco";
import { findChildOrThrow } from "../lib/utils";
/**
* A hook used to highlight source code in the root element.
*
* Configuration:
*
* * `data-language` - language of the source code
*
* The element should have two children:
*
* * one annotated with `data-source` attribute, it should contain
* the source code to be highlighted
*
* * one annotated with `data-target` where the highlighted code
* is rendered
*/
const Highlight = {
mounted() {
this.props = getProps(this);
this.state = {
sourceElement: null,
originalElement: null,
};
this.state.sourceElement = findChildOrThrow(this.el, "[data-source]");
this.state.targetElement = findChildOrThrow(this.el, "[data-target]");
highlightInto(
this.state.targetElement,
this.state.sourceElement,
this.props.language
).then(() => {
this.el.setAttribute("data-highlighted", "true");
});
},
updated() {
this.props = getProps(this);
highlightInto(
this.state.targetElement,
this.state.sourceElement,
this.props.language
).then(() => {
this.el.setAttribute("data-highlighted", "true");
});
},
};
function getProps(hook) {
return {
language: getAttributeOrThrow(hook.el, "data-language"),
};
}
function highlightInto(targetElement, sourceElement, language) {
const code = sourceElement.innerText;
return highlight(code, language).then((html) => {
targetElement.innerHTML = html;
});
}
export default Highlight;

350
assets/js/hooks/cell.js Normal file
View file

@ -0,0 +1,350 @@
import { getAttributeOrDefault, getAttributeOrThrow } from "../lib/attribute";
import Markdown from "../lib/markdown";
import { globalPubSub } from "../lib/pub_sub";
import { md5Base64, smoothlyScrollToElement } from "../lib/utils";
import scrollIntoView from "scroll-into-view-if-needed";
import { isEvaluable } from "../lib/notebook";
/**
* A hook managing a single cell.
*
* Manages the collaborative editor, takes care of markdown rendering
* and focusing the editor when applicable.
*
* ## Configuration
*
* * `data-cell-id` - id of the cell being edited
*
* * `data-type` - type of the cell
*
* * `data-session-path` - root path to the current session
*
* * `data-evaluation-digest` - digest of the last evaluated cell source
*/
const Cell = {
mounted() {
this.props = this.getProps();
this.isFocused = false;
this.insertMode = false;
this.liveEditors = {};
this.updateInsertModeAvailability();
// Setup action handlers
if (this.props.type === "code") {
const amplifyButton = this.el.querySelector(
`[data-element="amplify-outputs-button"]`
);
amplifyButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-amplified");
});
}
if (this.props.type === "smart") {
const toggleSourceButton = this.el.querySelector(
`[data-element="toggle-source-button"]`
);
toggleSourceButton.addEventListener("click", (event) => {
this.el.toggleAttribute("data-js-source-visible");
this.updateInsertModeAvailability();
this.maybeFocusCurrentEditor();
});
}
// Setup listeners
this.el.addEventListener("lb:cell:editor_created", (event) => {
const { tag, liveEditor } = event.detail;
this.handleCellEditorCreated(tag, liveEditor);
});
this.el.addEventListener("lb:cell:editor_removed", (event) => {
const { tag } = event.detail;
this.handleCellEditorRemoved(tag);
});
// We manually track hover to correctly handle absolute iframe
this.el.addEventListener("mouseenter", (event) => {
this.el.setAttribute("data-js-hover", "");
});
this.el.addEventListener("mouseleave", (event) => {
this.el.removeAttribute("data-js-hover");
});
this.unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => this.handleNavigationEvent(event)
);
this.unsubscribeFromCellsEvents = globalPubSub.subscribe("cells", (event) =>
this.handleCellsEvent(event)
);
},
disconnected() {
// When disconnected, this client is no longer seen by the server
// and misses all collaborative changes. On reconnection we want
// to clean up and mount a fresh hook, which we force by ensuring
// the DOM id doesn't match
this.el.removeAttribute("id");
},
destroyed() {
this.unsubscribeFromNavigationEvents();
this.unsubscribeFromCellsEvents();
},
updated() {
const prevProps = this.props;
this.props = this.getProps();
if (this.props.evaluationDigest !== prevProps.evaluationDigest) {
this.updateChangeIndicator();
}
},
getProps() {
return {
cellId: getAttributeOrThrow(this.el, "data-cell-id"),
type: getAttributeOrThrow(this.el, "data-type"),
sessionPath: getAttributeOrThrow(this.el, "data-session-path"),
evaluationDigest: getAttributeOrDefault(
this.el,
"data-evaluation-digest",
null
),
};
},
handleNavigationEvent(event) {
if (event.type === "element_focused") {
this.handleElementFocused(event.focusableId, event.scroll);
} else if (event.type === "insert_mode_changed") {
this.handleInsertModeChanged(event.enabled);
} else if (event.type === "location_report") {
this.handleLocationReport(event.client, event.report);
}
},
handleCellsEvent(event) {
if (event.type === "cell_moved") {
this.handleCellMoved(event.cellId);
} else if (event.type === "cell_upload") {
this.handleCellUpload(event.cellId, event.url);
}
},
handleElementFocused(focusableId, scroll) {
if (this.props.cellId === focusableId) {
this.isFocused = true;
this.el.setAttribute("data-js-focused", "");
if (scroll) {
smoothlyScrollToElement(this.el);
}
} else if (this.isFocused) {
this.isFocused = false;
this.el.removeAttribute("data-js-focused");
}
},
handleCellEditorCreated(tag, liveEditor) {
this.liveEditors[tag] = liveEditor;
this.updateInsertModeAvailability();
if (liveEditor === this.currentEditor()) {
// Once the editor is created, reflect the current insert mode state
this.maybeFocusCurrentEditor(true);
}
liveEditor.onBlur(() => {
// Prevent from blurring unless the state changes. For example
// when we move cell using buttons the editor should keep focus
if (this.isFocused && this.insertMode) {
this.currentEditor().focus();
}
});
liveEditor.onCursorSelectionChange((selection) => {
this.broadcastSelection(selection);
});
if (tag === "primary") {
// Setup markdown rendering
if (this.props.type === "markdown") {
const markdownContainer = this.el.querySelector(
`[data-element="markdown-container"]`
);
const markdown = new Markdown(
markdownContainer,
liveEditor.getSource(),
{
baseUrl: this.props.sessionPath,
emptyText: "Empty markdown cell",
}
);
liveEditor.onChange((newSource) => {
markdown.setContent(newSource);
});
}
// Setup change indicator
if (isEvaluable(this.props.type)) {
this.updateChangeIndicator();
liveEditor.onChange((newSource) => {
this.updateChangeIndicator();
});
this.handleEvent(
`evaluation_finished:${this.props.cellId}`,
({ code_error }) => {
liveEditor.setCodeErrorMarker(code_error);
}
);
}
}
},
handleCellEditorRemoved(tag) {
delete this.liveEditors[tag];
},
currentEditor() {
return this.liveEditors[this.currentEditorTag()];
},
currentEditorTag() {
if (this.props.type === "smart") {
const isSourceTab = this.el.hasAttribute("data-js-source-visible");
return isSourceTab ? "primary" : "secondary";
}
return "primary";
},
updateInsertModeAvailability() {
this.el.toggleAttribute(
"data-js-insert-mode-disabled",
!this.currentEditor()
);
},
maybeFocusCurrentEditor(scroll = false) {
if (this.isFocused && this.insertMode) {
this.currentEditor().focus();
if (scroll) {
// If the element is being scrolled to, focus interrupts it,
// so ensure the scrolling continues.
smoothlyScrollToElement(this.el);
}
this.broadcastSelection();
}
},
updateChangeIndicator() {
const cellStatus = this.el.querySelector(`[data-element="cell-status"]`);
const indicator =
cellStatus &&
cellStatus.querySelector(`[data-element="change-indicator"]`);
if (indicator && this.props.evaluationDigest) {
const source = this.liveEditors.primary.getSource();
const digest = md5Base64(source);
const changed = this.props.evaluationDigest !== digest;
cellStatus.toggleAttribute("data-js-changed", changed);
}
},
handleInsertModeChanged(insertMode) {
if (this.isFocused && !this.insertMode && insertMode) {
this.insertMode = insertMode;
if (this.currentEditor()) {
this.currentEditor().focus();
// The insert mode may be enabled as a result of clicking the editor,
// in which case we want to wait until editor handles the click and
// sets new cursor position. To achieve this, we simply put this task
// at the end of event loop, ensuring the editor mousedown handler is
// executed first
setTimeout(() => {
scrollIntoView(document.activeElement, {
scrollMode: "if-needed",
behavior: "smooth",
block: "center",
});
}, 0);
this.broadcastSelection();
}
} else if (this.insertMode && !insertMode) {
this.insertMode = insertMode;
if (this.currentEditor()) {
this.currentEditor().blur();
}
}
},
handleCellMoved(cellId) {
if (this.isFocused && cellId === this.props.cellId) {
smoothlyScrollToElement(this.el);
}
},
handleCellUpload(cellId, url) {
const liveEditor = this.liveEditors.primary;
if (!liveEditor) {
return;
}
if (this.props.cellId === cellId) {
const markdown = `![](${url})`;
liveEditor.insert(markdown);
}
},
handleLocationReport(client, report) {
Object.entries(this.liveEditors).forEach(([tag, liveEditor]) => {
if (
this.props.cellId === report.focusableId &&
report.selection &&
report.selection.tag === tag
) {
liveEditor.updateUserSelection(
client,
report.selection.editorSelection
);
} else {
liveEditor.removeUserSelection(client);
}
});
},
broadcastSelection(editorSelection = null) {
editorSelection =
editorSelection || this.currentEditor().editor.getSelection();
const tag = this.currentEditorTag();
// Report new selection only if this cell is in insert mode
if (this.isFocused && this.insertMode) {
globalPubSub.broadcast("session", {
type: "cursor_selection_changed",
focusableId: this.props.cellId,
selection: { tag, editorSelection },
});
}
},
};
export default Cell;

View file

@ -1,9 +1,9 @@
import LiveEditor from "./live_editor"; import LiveEditor from "./cell_editor/live_editor";
import { getAttributeOrThrow } from "../lib/attribute"; import { getAttributeOrThrow } from "../lib/attribute";
const CellEditor = { const CellEditor = {
mounted() { mounted() {
this.props = getProps(this); this.props = this.getProps();
this.handleEvent( this.handleEvent(
`cell_editor_init:${this.props.cellId}:${this.props.tag}`, `cell_editor_init:${this.props.cellId}:${this.props.tag}`,
@ -50,13 +50,13 @@ const CellEditor = {
this.liveEditor.dispose(); this.liveEditor.dispose();
} }
}, },
getProps() {
return {
cellId: getAttributeOrThrow(this.el, "data-cell-id"),
tag: getAttributeOrThrow(this.el, "data-tag"),
};
},
}; };
function getProps(hook) {
return {
cellId: getAttributeOrThrow(hook.el, "data-cell-id"),
tag: getAttributeOrThrow(hook.el, "data-tag"),
};
}
export default CellEditor; export default CellEditor;

View file

@ -3,8 +3,8 @@ import EditorClient from "./live_editor/editor_client";
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter"; import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
import HookServerAdapter from "./live_editor/hook_server_adapter"; import HookServerAdapter from "./live_editor/hook_server_adapter";
import RemoteUser from "./live_editor/remote_user"; import RemoteUser from "./live_editor/remote_user";
import { replacedSuffixLength } from "../lib/text_utils"; import { replacedSuffixLength } from "../../lib/text_utils";
import { settingsStore } from "../lib/settings"; import { settingsStore } from "../../lib/settings";
/** /**
* Mounts cell source editor with real-time collaboration mechanism. * Mounts cell source editor with real-time collaboration mechanism.
@ -33,10 +33,10 @@ class LiveEditor {
this._onCursorSelectionChange = null; this._onCursorSelectionChange = null;
this._remoteUserByClientPid = {}; this._remoteUserByClientPid = {};
this.__mountEditor(); this._mountEditor();
if (this.intellisense) { if (this.intellisense) {
this.__setupIntellisense(); this._setupIntellisense();
} }
const serverAdapter = new HookServerAdapter(hook, cellId, tag); const serverAdapter = new HookServerAdapter(hook, cellId, tag);
@ -180,7 +180,7 @@ class LiveEditor {
} }
} }
__mountEditor() { _mountEditor() {
const settings = settingsStore.get(); const settings = settingsStore.get();
this.editor = monaco.editor.create(this.container, { this.editor = monaco.editor.create(this.container, {
@ -276,7 +276,7 @@ class LiveEditor {
/** /**
* Defines cell-specific providers for various editor features. * Defines cell-specific providers for various editor features.
*/ */
__setupIntellisense() { _setupIntellisense() {
const settings = settingsStore.get(); const settings = settingsStore.get();
this.handlerByRef = {}; this.handlerByRef = {};
@ -290,11 +290,11 @@ class LiveEditor {
* * the user opens the completion list, which triggers the global * * the user opens the completion list, which triggers the global
* completion provider registered in `live_editor/monaco.js` * completion provider registered in `live_editor/monaco.js`
* *
* * the global provider delegates to the cell-specific `__getCompletionItems` * * the global provider delegates to the cell-specific `__getCompletionItems__`
* defined below. That's a little bit hacky, but this way we make * defined below. That's a little bit hacky, but this way we make
* completion cell-specific * completion cell-specific
* *
* * then `__getCompletionItems` sends a completion request to the LV process * * then `__getCompletionItems__` sends a completion request to the LV process
* and gets a unique reference, under which it keeps completion callback * and gets a unique reference, under which it keeps completion callback
* *
* * finally the hook receives the "intellisense_response" event with completion * * finally the hook receives the "intellisense_response" event with completion
@ -302,11 +302,11 @@ class LiveEditor {
* it with the response, which finally returns the completion items to the editor * it with the response, which finally returns the completion items to the editor
*/ */
this.editor.getModel().__getCompletionItems = (model, position) => { this.editor.getModel().__getCompletionItems__ = (model, position) => {
const line = model.getLineContent(position.lineNumber); const line = model.getLineContent(position.lineNumber);
const lineUntilCursor = line.slice(0, position.column - 1); const lineUntilCursor = line.slice(0, position.column - 1);
return this.__asyncIntellisenseRequest("completion", { return this._asyncIntellisenseRequest("completion", {
hint: lineUntilCursor, hint: lineUntilCursor,
editor_auto_completion: settings.editor_auto_completion, editor_auto_completion: settings.editor_auto_completion,
}) })
@ -335,11 +335,11 @@ class LiveEditor {
.catch(() => null); .catch(() => null);
}; };
this.editor.getModel().__getHover = (model, position) => { this.editor.getModel().__getHover__ = (model, position) => {
const line = model.getLineContent(position.lineNumber); const line = model.getLineContent(position.lineNumber);
const column = position.column; const column = position.column;
return this.__asyncIntellisenseRequest("details", { line, column }) return this._asyncIntellisenseRequest("details", { line, column })
.then((response) => { .then((response) => {
const contents = response.contents.map((content) => ({ const contents = response.contents.map((content) => ({
value: content, value: content,
@ -363,7 +363,7 @@ class LiveEditor {
response: null, response: null,
}; };
this.editor.getModel().__getSignatureHelp = (model, position) => { this.editor.getModel().__getSignatureHelp__ = (model, position) => {
const lines = model.getLinesContent(); const lines = model.getLinesContent();
const lineIdx = position.lineNumber - 1; const lineIdx = position.lineNumber - 1;
const prevLines = lines.slice(0, lineIdx); const prevLines = lines.slice(0, lineIdx);
@ -385,7 +385,7 @@ class LiveEditor {
}; };
} }
return this.__asyncIntellisenseRequest("signature", { return this._asyncIntellisenseRequest("signature", {
hint: codeUntilCursor, hint: codeUntilCursor,
}) })
.then((response) => { .then((response) => {
@ -400,10 +400,10 @@ class LiveEditor {
.catch(() => null); .catch(() => null);
}; };
this.editor.getModel().__getDocumentFormattingEdits = (model) => { this.editor.getModel().__getDocumentFormattingEdits__ = (model) => {
const content = model.getValue(); const content = model.getValue();
return this.__asyncIntellisenseRequest("format", { code: content }) return this._asyncIntellisenseRequest("format", { code: content })
.then((response) => { .then((response) => {
this.setCodeErrorMarker(response.code_error); this.setCodeErrorMarker(response.code_error);
@ -462,7 +462,7 @@ class LiveEditor {
* The returned promise is either resolved with a valid * The returned promise is either resolved with a valid
* response or rejected with null. * response or rejected with null.
*/ */
__asyncIntellisenseRequest(type, props) { _asyncIntellisenseRequest(type, props) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.hook.pushEvent( this.hook.pushEvent(
"intellisense_request", "intellisense_request",

View file

@ -29,17 +29,17 @@ export default class EditorClient {
this._onDelta = null; this._onDelta = null;
this.editorAdapter.onDelta((delta) => { this.editorAdapter.onDelta((delta) => {
this.__handleClientDelta(delta); this._handleClientDelta(delta);
// This delta comes from the editor, so it has already been applied. // This delta comes from the editor, so it has already been applied.
this.__emitDelta(delta); this._emitDelta(delta);
}); });
this.serverAdapter.onDelta((delta) => { this.serverAdapter.onDelta((delta) => {
this.__handleServerDelta(delta); this._handleServerDelta(delta);
}); });
this.serverAdapter.onAcknowledgement(() => { this.serverAdapter.onAcknowledgement(() => {
this.__handleServerAcknowledgement(); this._handleServerAcknowledgement();
}); });
} }
@ -53,20 +53,20 @@ export default class EditorClient {
this._onDelta = callback; this._onDelta = callback;
} }
__emitDelta(delta) { _emitDelta(delta) {
this._onDelta && this._onDelta(delta); this._onDelta && this._onDelta(delta);
} }
__handleClientDelta(delta) { _handleClientDelta(delta) {
this.state = this.state.onClientDelta(delta); this.state = this.state.onClientDelta(delta);
} }
__handleServerDelta(delta) { _handleServerDelta(delta) {
this.revision++; this.revision++;
this.state = this.state.onServerDelta(delta); this.state = this.state.onServerDelta(delta);
} }
__handleServerAcknowledgement() { _handleServerAcknowledgement() {
this.revision++; this.revision++;
this.state = this.state.onServerAcknowledgement(); this.state = this.state.onServerAcknowledgement();
} }
@ -74,7 +74,7 @@ export default class EditorClient {
applyDelta(delta) { applyDelta(delta) {
this.editorAdapter.applyDelta(delta); this.editorAdapter.applyDelta(delta);
// This delta comes from the server and we have just applied it to the editor. // This delta comes from the server and we have just applied it to the editor.
this.__emitDelta(delta); this._emitDelta(delta);
} }
sendDelta(delta) { sendDelta(delta) {

View file

@ -1,4 +1,4 @@
import Delta from "../../lib/delta"; import Delta from "../../../lib/delta";
/** /**
* Encapsulates logic related to sending/receiving messages from the server. * Encapsulates logic related to sending/receiving messages from the server.

View file

@ -41,8 +41,8 @@ document.fonts.addEventListener("loadingdone", (event) => {
monaco.languages.registerCompletionItemProvider("elixir", { monaco.languages.registerCompletionItemProvider("elixir", {
provideCompletionItems: (model, position, context, token) => { provideCompletionItems: (model, position, context, token) => {
if (model.__getCompletionItems) { if (model.__getCompletionItems__) {
return model.__getCompletionItems(model, position); return model.__getCompletionItems__(model, position);
} else { } else {
return null; return null;
} }
@ -51,8 +51,8 @@ monaco.languages.registerCompletionItemProvider("elixir", {
monaco.languages.registerHoverProvider("elixir", { monaco.languages.registerHoverProvider("elixir", {
provideHover: (model, position, token) => { provideHover: (model, position, token) => {
if (model.__getHover) { if (model.__getHover__) {
return model.__getHover(model, position); return model.__getHover__(model, position);
} else { } else {
return null; return null;
} }
@ -62,8 +62,8 @@ monaco.languages.registerHoverProvider("elixir", {
monaco.languages.registerSignatureHelpProvider("elixir", { monaco.languages.registerSignatureHelpProvider("elixir", {
signatureHelpTriggerCharacters: ["(", ","], signatureHelpTriggerCharacters: ["(", ","],
provideSignatureHelp: (model, position, token, context) => { provideSignatureHelp: (model, position, token, context) => {
if (model.__getSignatureHelp) { if (model.__getSignatureHelp__) {
return model.__getSignatureHelp(model, position); return model.__getSignatureHelp__(model, position);
} else { } else {
return null; return null;
} }
@ -72,8 +72,8 @@ monaco.languages.registerSignatureHelpProvider("elixir", {
monaco.languages.registerDocumentFormattingEditProvider("elixir", { monaco.languages.registerDocumentFormattingEditProvider("elixir", {
provideDocumentFormattingEdits: (model, options, token) => { provideDocumentFormattingEdits: (model, options, token) => {
if (model.__getDocumentFormattingEdits) { if (model.__getDocumentFormattingEdits__) {
return model.__getDocumentFormattingEdits(model); return model.__getDocumentFormattingEdits__(model);
} else { } else {
return null; return null;
} }

View file

@ -1,5 +1,5 @@
import monaco from "./monaco"; import monaco from "./monaco";
import Delta, { isDelete, isInsert, isRetain } from "../../lib/delta"; import Delta, { isDelete, isInsert, isRetain } from "../../../lib/delta";
/** /**
* Encapsulates logic related to getting/applying changes to the editor. * Encapsulates logic related to getting/applying changes to the editor.
@ -19,7 +19,7 @@ export default class MonacoEditorAdapter {
this.isLastChangeRemote = false; this.isLastChangeRemote = false;
const delta = this.__deltaFromEditorChange(event); const delta = this._deltaFromEditorChange(event);
this._onDelta && this._onDelta(delta); this._onDelta && this._onDelta(delta);
}); });
} }
@ -57,7 +57,7 @@ export default class MonacoEditorAdapter {
this.editor.getModel().popStackElement(); this.editor.getModel().popStackElement();
} }
const operations = this.__deltaToEditorOperations(delta); const operations = this._deltaToEditorOperations(delta);
this.ignoreChange = true; this.ignoreChange = true;
// Apply the operations and add them to the undo stack // Apply the operations and add them to the undo stack
this.editor.getModel().pushEditOperations(null, operations, null); this.editor.getModel().pushEditOperations(null, operations, null);
@ -70,7 +70,7 @@ export default class MonacoEditorAdapter {
this.isLastChangeRemote = true; this.isLastChangeRemote = true;
} }
__deltaFromEditorChange(event) { _deltaFromEditorChange(event) {
const deltas = event.changes.map((change) => { const deltas = event.changes.map((change) => {
const { rangeOffset, rangeLength, text } = change; const { rangeOffset, rangeLength, text } = change;
@ -94,7 +94,7 @@ export default class MonacoEditorAdapter {
return deltas.reduce((delta1, delta2) => delta1.compose(delta2)); return deltas.reduce((delta1, delta2) => delta1.compose(delta2));
} }
__deltaToEditorOperations(delta) { _deltaToEditorOperations(delta) {
const model = this.editor.getModel(); const model = this.editor.getModel();
const operations = []; const operations = [];

View file

@ -1,5 +1,5 @@
import monaco from "./monaco"; import monaco from "./monaco";
import { randomId } from "../../lib/utils"; import { randomId } from "../../../lib/utils";
/** /**
* Remote user visual indicators within the editor. * Remote user visual indicators within the editor.
@ -45,9 +45,9 @@ class CursorWidget {
this._id = randomId(); this._id = randomId();
this._editor = editor; this._editor = editor;
this._position = position; this._position = position;
this._isPositionValid = this.__checkPositionValidity(position); this._isPositionValid = this._checkPositionValidity(position);
this.__buildDomNode(hexColor, label); this._buildDomNode(hexColor, label);
this._editor.addContentWidget(this); this._editor.addContentWidget(this);
@ -75,8 +75,8 @@ class CursorWidget {
update(position) { update(position) {
this._position = position; this._position = position;
this._isPositionValid = this.__checkPositionValidity(position); this._isPositionValid = this._checkPositionValidity(position);
this.__updateDomNode(); this._updateDomNode();
this._editor.layoutContentWidget(this); this._editor.layoutContentWidget(this);
} }
@ -89,12 +89,12 @@ class CursorWidget {
this._onDidChangeModelContentDisposable.dispose(); this._onDidChangeModelContentDisposable.dispose();
} }
__checkPositionValidity(position) { _checkPositionValidity(position) {
const validPosition = this._editor.getModel().validatePosition(position); const validPosition = this._editor.getModel().validatePosition(position);
return position.equals(validPosition); return position.equals(validPosition);
} }
__buildDomNode(hexColor, label) { _buildDomNode(hexColor, label) {
const lineHeight = this._editor.getOption( const lineHeight = this._editor.getOption(
monaco.editor.EditorOption.lineHeight monaco.editor.EditorOption.lineHeight
); );
@ -117,10 +117,10 @@ class CursorWidget {
node.appendChild(labelNode); node.appendChild(labelNode);
this._domNode = node; this._domNode = node;
this.__updateDomNode(); this._updateDomNode();
} }
__updateDomNode() { _updateDomNode() {
const isFirstLine = this._position.lineNumber === 1; const isFirstLine = this._position.lineNumber === 1;
this._domNode.classList.toggle("inline", isFirstLine); this._domNode.classList.toggle("inline", isFirstLine);
} }

View file

@ -0,0 +1,24 @@
const DRAGGING_ATTR = "data-js-dragging";
/**
* A hook used to highlight drop zone when dragging a file.
*/
const Dropzone = {
mounted() {
this.el.addEventListener("dragenter", (event) => {
this.el.setAttribute(DRAGGING_ATTR, "");
});
this.el.addEventListener("dragleave", (event) => {
if (!this.el.contains(event.relatedTarget)) {
this.el.removeAttribute(DRAGGING_ATTR);
}
});
this.el.addEventListener("drop", (event) => {
this.el.removeAttribute(DRAGGING_ATTR);
});
},
};
export default Dropzone;

View file

@ -5,16 +5,16 @@ import { isEditableElement } from "../lib/utils";
*/ */
const FocusOnUpdate = { const FocusOnUpdate = {
mounted() { mounted() {
this.__focus(); this.focus();
}, },
updated() { updated() {
if (this.el !== document.activeElement) { if (this.el !== document.activeElement) {
this.__focus(); this.focus();
} }
}, },
__focus() { focus() {
if (isEditableElement(document.activeElement)) { if (isEditableElement(document.activeElement)) {
return; return;
} }

139
assets/js/hooks/headline.js Normal file
View file

@ -0,0 +1,139 @@
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 = this.getProps();
this.isFocused = false;
this.insertMode = false;
this.initializeHeadingEl();
this.unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => {
this.handleNavigationEvent(event);
}
);
},
updated() {
this.props = this.getProps();
this.initializeHeadingEl();
},
destroyed() {
this.unsubscribeFromNavigationEvents();
},
getProps() {
return {
focusableId: getAttributeOrThrow(this.el, "data-focusable-id"),
onValueChange: getAttributeOrThrow(this.el, "data-on-value-change"),
metadata: getAttributeOrThrow(this.el, "data-metadata"),
};
},
initializeHeadingEl() {
const headingEl = this.el.querySelector(`[data-element="heading"]`);
if (headingEl === this.headingEl) {
return;
}
this.headingEl = headingEl;
// Make sure only plain text is pasted
this.headingEl.addEventListener("paste", (event) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain").replace("\n", " ");
document.execCommand("insertText", false, text);
});
// Ignore enter
this.headingEl.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
}
});
this.headingEl.addEventListener("blur", (event) => {
// Wait for other handlers to complete and if still in insert
// mode force focus
setTimeout(() => {
if (this.isFocused && this.insertMode) {
this.headingEl.focus();
moveSelectionToEnd(this.headingEl);
}
}, 0);
});
},
handleNavigationEvent(event) {
if (event.type === "element_focused") {
this.handleElementFocused(event.focusableId, event.scroll);
} else if (event.type === "insert_mode_changed") {
this.handleInsertModeChanged(event.enabled);
}
},
handleElementFocused(cellId, scroll) {
if (this.props.focusableId === cellId) {
this.isFocused = true;
this.el.setAttribute("data-js-focused", "");
if (scroll) {
smoothlyScrollToElement(this.el);
}
} else if (this.isFocused) {
this.isFocused = false;
this.el.removeAttribute("data-js-focused");
}
},
handleInsertModeChanged(insertMode) {
if (this.isFocused && !this.insertMode && insertMode) {
this.insertMode = insertMode;
// While in insert mode, ignore the incoming changes
this.el.setAttribute("phx-update", "ignore");
this.headingEl.setAttribute("contenteditable", "");
this.headingEl.focus();
moveSelectionToEnd(this.headingEl);
} else if (this.insertMode && !insertMode) {
this.insertMode = insertMode;
this.headingEl.removeAttribute("contenteditable");
this.el.removeAttribute("phx-update");
this.pushEvent(this.props.onValueChange, {
value: this.headingEl.textContent.trim(),
metadata: this.props.metadata,
});
}
},
};
function moveSelectionToEnd(element) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export default Headline;

View file

@ -0,0 +1,50 @@
import { getAttributeOrThrow } from "../lib/attribute";
import { highlight } from "./cell_editor/live_editor/monaco";
import { findChildOrThrow } from "../lib/utils";
/**
* A hook used to highlight source code in the root element.
*
* ## Configuration
*
* * `data-language` - language of the source code
*
* The element should have two children:
*
* * `[data-source]` - an element containing the source code to be
* highlighted
*
* * `[data-target]` - the element to render highlighted code into
*/
const Highlight = {
mounted() {
this.props = this.getProps();
this.sourceEl = findChildOrThrow(this.el, "[data-source]");
this.targetEl = findChildOrThrow(this.el, "[data-target]");
this.updateDOM();
},
updated() {
this.props = this.getProps();
this.updateDOM();
},
getProps() {
return {
language: getAttributeOrThrow(this.el, "data-language"),
};
},
updateDOM() {
const code = this.sourceEl.innerText;
highlight(code, this.props.language).then((html) => {
this.targetEl.innerHTML = html;
this.el.setAttribute("data-highlighted", "");
});
},
};
export default Highlight;

35
assets/js/hooks/index.js Normal file
View file

@ -0,0 +1,35 @@
import Cell from "./cell";
import CellEditor from "./cell_editor";
import ConfirmModal from "./confirm_modal";
import Dropzone from "./dropzone";
import EditorSettings from "./editor_settings";
import FocusOnUpdate from "./focus_on_update";
import Headline from "./headline";
import Highlight from "./highlight";
import JSView from "./js_view";
import KeyboardControl from "./keyboard_control";
import MarkdownRenderer from "./markdown_renderer";
import ScrollOnUpdate from "./scroll_on_update";
import Session from "./session";
import Timer from "./timer";
import UserForm from "./user_form";
import VirtualizedLines from "./virtualized_lines";
export default {
Cell,
CellEditor,
ConfirmModal,
Dropzone,
EditorSettings,
FocusOnUpdate,
Headline,
Highlight,
JSView,
KeyboardControl,
MarkdownRenderer,
ScrollOnUpdate,
Session,
Timer,
UserForm,
VirtualizedLines,
};

316
assets/js/hooks/js_view.js Normal file
View file

@ -0,0 +1,316 @@
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { randomToken } from "../lib/utils";
import {
getChannel,
transportDecode,
transportEncode,
} from "./js_view/channel";
import { initializeIframeSource } from "./js_view/iframe";
/**
* A hook used to render a runtime-connected JavaScript view.
*
* JavaScript view is an abstraction for extending Livebook with
* custom capabilities. In particular, it is the primary building
* block for defining custom interactive output types, such as plots
* and maps.
*
* The JavaScript is defined by the user, so we sandbox the script
* execution inside an iframe.
*
* The hook connects to a dedicated channel, sending the token and
* view ref in an initial message. It expects `init:<ref>` message
* with `{ data }` payload, the data is then used in the initial call
* to the custom JS module.
*
* Then, a number of `event:<ref>` with `{ event, payload }` payload
* can be sent. The `event` is forwarded to the initialized component.
*
* ## Configuration
*
* * `data-ref` - a unique identifier used as messages scope
*
* * `data-assets-base-path` - the path to resolve all relative paths
* against in the iframe
*
* * `data-js-path` - a relative path for the initial view-specific
* JS module
*
* * `data-session-token` - token is sent in the "connect" message
* to the channel
*
* * `data-session-id` - the identifier of the session that this
* view belongs go
*
* * `data-iframe-local-port` - the local port where the iframe is
* served
*
*/
const JSView = {
mounted() {
this.props = this.getProps();
this.childToken = randomToken();
this.childReadyPromise = null;
this.childReady = false;
this.channel = getChannel(this.props.sessionId);
this.removeIframe = this.createIframe();
// Setup child communication
this.childReadyPromise = new Promise((resolve, reject) => {
this._handleWindowMessage = (event) => {
if (event.source === this.iframe.contentWindow) {
this.handleChildMessage(event.data, resolve);
}
};
window.addEventListener("message", this._handleWindowMessage);
});
this.loadIframe();
// Channel events
const initRef = this.channel.on(`init:${this.props.ref}`, (raw) => {
const [, payload] = transportDecode(raw);
this.handleServerInit(payload);
});
const eventRef = this.channel.on(`event:${this.props.ref}`, (raw) => {
const [[event], payload] = transportDecode(raw);
this.handleServerEvent(event, payload);
});
const errorRef = this.channel.on(
`error:${this.props.ref}`,
({ message }) => {
this.handleServerError(message);
}
);
this.unsubscribeFromChannelEvents = () => {
this.channel.off(`init:${this.props.ref}`, initRef);
this.channel.off(`event:${this.props.ref}`, eventRef);
this.channel.off(`error:${this.props.ref}`, errorRef);
};
this.channel.push("connect", {
session_token: this.props.sessionToken,
ref: this.props.ref,
});
},
updated() {
this.props = this.getProps(this);
},
destroyed() {
window.removeEventListener("message", this._handleWindowMessage);
this.removeIframe();
this.unsubscribeFromChannelEvents();
this.channel.push("disconnect", { ref: this.props.ref });
},
getProps() {
return {
ref: getAttributeOrThrow(this.el, "data-ref"),
assetsBasePath: getAttributeOrThrow(this.el, "data-assets-base-path"),
jsPath: getAttributeOrThrow(this.el, "data-js-path"),
sessionToken: getAttributeOrThrow(this.el, "data-session-token"),
sessionId: getAttributeOrThrow(this.el, "data-session-id"),
iframePort: getAttributeOrThrow(
this.el,
"data-iframe-local-port",
parseInteger
),
};
},
createIframe() {
// When cells/sections are reordered, morphdom detaches and attaches
// the relevant elements in the DOM. JS view is generally rendered
// inside cells, so when reordering happens it becomes temporarily
// detached from the DOM and attaching it back would cause the iframe
// to reload. This behaviour is expected, as discussed in (1). Reloading
// that frequently is inefficient and also clears the iframe state,
// which makes is very undesired in our case. To solve this, we insert
// the iframe higher in the DOM tree, so that it's never affected by
// reordering. Then, we insert a placeholder element in the output to
// take up the expected space and we use absolute positioning to place
// the iframe exactly over that placeholder. We set up observers to
// track the changes in placeholder's position/size and we keep the
// absolute iframe in sync.
//
// (1): https://github.com/whatwg/html/issues/5484
this.iframePlaceholder = document.createElement("div");
this.el.appendChild(this.iframePlaceholder);
this.iframe = document.createElement("iframe");
this.iframe.className = "w-full h-0 absolute z-[1]";
const notebookEl = document.querySelector(`[data-element="notebook"]`);
const notebookContentEl = notebookEl.querySelector(
`[data-element="notebook-content"]`
);
// Most placeholder position changes are accompanied by changes to the
// notebook content element height (adding cells, inserting newlines
// in the editor, etc). On the other hand, toggling the sidebar or
// resizing the window changes the width, however the notebook
// content element doesn't span full width, so this change may not
// be detected, that's why we observe the full-width parent element
const resizeObserver = new ResizeObserver((entries) => {
this.repositionIframe();
});
resizeObserver.observe(notebookContentEl);
resizeObserver.observe(notebookEl);
// On lower level cell/section reordering is applied as element
// removal followed by insert, consequently the intersection
// between the placeholder and notebook content changes (becomes
// none for a brief moment)
const intersectionObserver = new IntersectionObserver(
(entries) => {
this.repositionIframe();
},
{ root: notebookContentEl }
);
intersectionObserver.observe(this.iframePlaceholder);
// Emulate mouse enter and leave on the placeholder. Note that we
// intentionally use bubbling to notify all parents that may have
// listeners on themselves
this.iframe.addEventListener("mouseenter", (event) => {
this.iframePlaceholder.dispatchEvent(
new MouseEvent("mouseenter", { bubbles: true })
);
});
this.iframe.addEventListener("mouseleave", (event) => {
this.iframePlaceholder.dispatchEvent(
new MouseEvent("mouseleave", { bubbles: true })
);
});
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
this.iframe.remove();
};
},
repositionIframe() {
const { iframe, iframePlaceholder } = this;
const notebookEl = document.querySelector(`[data-element="notebook"]`);
if (iframePlaceholder.offsetParent === null) {
// When the placeholder is hidden, we hide the iframe as well
iframe.classList.add("hidden");
} else {
iframe.classList.remove("hidden");
const notebookBox = notebookEl.getBoundingClientRect();
const placeholderBox = iframePlaceholder.getBoundingClientRect();
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
iframe.style.top = `${top}px`;
const left =
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
iframe.style.left = `${left}px`;
iframe.style.height = `${placeholderBox.height}px`;
iframe.style.width = `${placeholderBox.width}px`;
}
},
loadIframe() {
const iframesEl = document.querySelector(
`[data-element="js-view-iframes"]`
);
initializeIframeSource(this.iframe, this.props.iframePort).then(() => {
iframesEl.appendChild(this.iframe);
});
},
handleChildMessage(message, onReady) {
if (message.type === "ready" && !this.childReady) {
const assetsBaseUrl = window.location.origin + this.props.assetsBasePath;
this.postMessage({
type: "readyReply",
token: this.childToken,
baseUrl: assetsBaseUrl,
jsPath: this.props.jsPath,
});
this.childReady = true;
onReady();
} else {
// Note: we use a random token to authorize child messages
// and do our best to make this token unavailable for the
// injected script on the child side. In the worst case scenario,
// the script manages to extract the token and can then send
// any of those messages, so we can treat this as a possible
// surface for attacks. In this case the most "critical" actions
// are shortcuts, neither of which is particularly dangerous.
if (message.token !== this.childToken) {
throw new Error("Token mismatch");
}
if (message.type === "resize") {
this.iframePlaceholder.style.height = `${message.height}px`;
this.iframe.style.height = `${message.height}px`;
} else if (message.type === "domEvent") {
// Replicate the child events on the current element,
// so that they are detected upstream in the session hook
const event = this.replicateDomEvent(message.event);
this.el.dispatchEvent(event);
} else if (message.type === "event") {
const { event, payload } = message;
const raw = transportEncode([event, this.props.ref], payload);
this.channel.push("event", raw);
}
}
},
postMessage(message) {
this.iframe.contentWindow.postMessage(message, "*");
},
replicateDomEvent(event) {
if (event.type === "focus") {
return new FocusEvent("focus");
} else if (event.type === "mousedown") {
return new MouseEvent("mousedown", { bubbles: true });
} else if (event.type === "keydown") {
return new KeyboardEvent(event.type, event.props);
}
},
handleServerInit(payload) {
this.childReadyPromise.then(() => {
this.postMessage({ type: "init", data: payload });
});
},
handleServerEvent(event, payload) {
this.childReadyPromise.then(() => {
this.postMessage({ type: "event", event, payload });
});
},
handleServerError(message) {
if (!this.errorContainer) {
this.errorContainer = document.createElement("div");
this.errorContainer.classList.add("error-box", "mb-4");
this.el.prepend(this.errorContainer);
}
this.errorContainer.textContent = message;
},
};
export default JSView;

View file

@ -0,0 +1,93 @@
import { Socket } from "phoenix";
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const socket = new Socket("/socket", { params: { _csrf_token: csrfToken } });
let channel = null;
/**
* Returns channel used for all JS views in the current session.
*/
export function getChannel(sessionId) {
if (!channel) {
socket.connect();
channel = socket.channel("js_view", { session_id: sessionId });
channel.join();
}
return channel;
}
/**
* Leaves the JS views channel tied to the current session.
*/
export function leaveChannel() {
if (channel) {
channel.leave();
channel = null;
socket.disconnect();
}
}
// Encoding/decoding of channel payloads
export function transportEncode(meta, payload) {
if (
Array.isArray(payload) &&
payload[1] &&
payload[1].constructor === ArrayBuffer
) {
const [info, buffer] = payload;
return encode([meta, info], buffer);
} else {
return { root: [meta, payload] };
}
}
export function transportDecode(raw) {
if (raw.constructor === ArrayBuffer) {
const [[meta, info], buffer] = decode(raw);
return [meta, [info, buffer]];
} else {
const {
root: [meta, payload],
} = raw;
return [meta, payload];
}
}
const HEADER_LENGTH = 4;
function encode(meta, buffer) {
const encoder = new TextEncoder();
const metaArray = encoder.encode(JSON.stringify(meta));
const raw = new ArrayBuffer(
HEADER_LENGTH + metaArray.byteLength + buffer.byteLength
);
const view = new DataView(raw);
view.setUint32(0, metaArray.byteLength);
new Uint8Array(raw, HEADER_LENGTH, metaArray.byteLength).set(metaArray);
new Uint8Array(raw, HEADER_LENGTH + metaArray.byteLength).set(
new Uint8Array(buffer)
);
return raw;
}
function decode(raw) {
const view = new DataView(raw);
const metaArrayLength = view.getUint32(0);
const metaArray = new Uint8Array(raw, HEADER_LENGTH, metaArrayLength);
const buffer = raw.slice(HEADER_LENGTH + metaArrayLength);
const decoder = new TextDecoder();
const meta = JSON.parse(decoder.decode(metaArray));
return [meta, buffer];
}

View file

@ -0,0 +1,65 @@
import { sha256Base64 } from "../../lib/utils";
// Loading iframe using `srcdoc` disables cookies and browser APIs,
// such as camera and microphone (1), the same applies to `src` with
// data URL, so we need to load the iframe through a regular request.
// Since the iframe is sandboxed we also need `allow-same-origin`.
// Additionally, we cannot load the iframe from the same origin as
// the app, because using `allow-same-origin` together with `allow-scripts`
// would be insecure (2). Consequently, we need to load the iframe
// from a different origin.
//
// When running Livebook on https:// we load the iframe from another
// https:// origin. On the other hand, when running on http:// we want
// to load the iframe from http:// as well, otherwise the browser could
// block asset requests from the https:// iframe to http:// Livebook.
// However, external http:// content is not considered a secure context (3),
// which implies no access to user media. Therefore, instead of using
// http://livebook.space we use another localhost endpoint. Note that
// this endpoint has a different port than the Livebook web app, that's
// because we need separate origins, as outlined above.
//
// To ensure integrity of the loaded content we manually verify the
// checksum against the expected value.
//
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
export function initializeIframeSource(iframe, iframePort) {
const iframeUrl = getIframeUrl(iframePort);
return verifyIframeSource(iframeUrl).then(() => {
iframe.sandbox =
"allow-scripts allow-same-origin allow-downloads allow-modals";
iframe.allow =
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
iframe.src = iframeUrl;
});
}
function getIframeUrl(iframePort) {
return window.location.protocol === "https:"
? "https://livebook.space/iframe/v2.html"
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
}
let iframeVerificationPromise = null;
function verifyIframeSource(iframeUrl) {
if (!iframeVerificationPromise) {
iframeVerificationPromise = fetch(iframeUrl)
.then((response) => response.text())
.then((html) => {
if (sha256Base64(html) !== IFRAME_SHA256) {
throw new Error(
"The loaded iframe content doesn't have the expected checksum"
);
}
});
}
return iframeVerificationPromise;
}

View file

@ -0,0 +1,96 @@
import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
import { cancelEvent, isEditableElement } from "../lib/utils";
/**
* A hook for ControlComponent to handle user keyboard interactions.
*
* ## Configuration
*
* * `data-keydown-enabled` - whether keydown events should be intercepted
*
* * `data-keyup-enabled` - whether keyup events should be intercepted
*
* * `data-target` - the target to send live events to
*/
const KeyboardControl = {
mounted() {
this.props = this.getProps();
this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this._handleDocumentKeyUp = this.handleDocumentKeyUp.bind(this);
this._handleDocumentFocus = this.handleDocumentFocus.bind(this);
// We intentionally register on window rather than document, to
// intercept events as early on as possible, even before the
// session shortcuts
window.addEventListener("keydown", this._handleDocumentKeyDown, true);
window.addEventListener("keyup", this._handleDocumentKeyUp, true);
// Note: the focus event doesn't bubble, so we register for the
// capture phase
window.addEventListener("focus", this._handleDocumentFocus, true);
},
updated() {
this.props = this.getProps();
},
destroyed() {
window.removeEventListener("keydown", this._handleDocumentKeyDown, true);
window.removeEventListener("keyup", this._handleDocumentKeyUp, true);
window.removeEventListener("focus", this._handleDocumentFocus, true);
},
getProps() {
return {
isKeydownEnabled: getAttributeOrThrow(
this.el,
"data-keydown-enabled",
parseBoolean
),
isKeyupEnabled: getAttributeOrThrow(
this.el,
"data-keyup-enabled",
parseBoolean
),
target: getAttributeOrThrow(this.el, "data-target"),
};
},
handleDocumentKeyDown(event) {
if (this.keyboardEnabled()) {
cancelEvent(event);
}
if (this.props.isKeydownEnabled) {
if (event.repeat) {
return;
}
const { key } = event;
this.pushEventTo(this.props.target, "keydown", { key });
}
},
handleDocumentKeyUp(event) {
if (this.keyboardEnabled()) {
cancelEvent(event);
}
if (this.props.isKeyupEnabled) {
const { key } = event;
this.pushEventTo(this.props.target, "keyup", { key });
}
},
handleDocumentFocus(event) {
if (this.props.isKeydownEnabled && isEditableElement(event.target)) {
this.pushEventTo(this.props.target, "disable_keyboard", {});
}
},
keyboardEnabled() {
return this.props.isKeydownEnabled || this.props.isKeyupEnabled;
},
};
export default KeyboardControl;

View file

@ -2,35 +2,32 @@ import { getAttributeOrThrow } from "../lib/attribute";
import Markdown from "../lib/markdown"; import Markdown from "../lib/markdown";
/** /**
* A hook used to render markdown content on the client. * A hook used to render Markdown content on the client.
* *
* Configuration: * ## Configuration
* *
* * `data-id` - id of the renderer, under which the content event is pushed * * `data-id` - id of the renderer, under which the content event
* is pushed
*/ */
const MarkdownRenderer = { const MarkdownRenderer = {
mounted() { mounted() {
this.props = getProps(this); this.props = this.getProps();
const markdown = new Markdown(this.el, ""); const markdown = new Markdown(this.el, "");
this.handleEvent( this.handleEvent(
`markdown-renderer:${this.props.id}:content`, `markdown_renderer:${this.props.id}:content`,
({ content }) => { ({ content }) => {
markdown.setContent(content); markdown.setContent(content);
} }
); );
}, },
updated() { getProps() {
this.props = getProps(this); return {
id: getAttributeOrThrow(this.el, "data-id"),
};
}, },
}; };
function getProps(hook) {
return {
id: getAttributeOrThrow(hook.el, "data-id"),
};
}
export default MarkdownRenderer; export default MarkdownRenderer;

View file

@ -1,17 +1,17 @@
/** /**
* A hook used to scroll to the bottom of an element * A hook used to scroll to the bottom of an element whenever it
* whenever it receives LV update. * receives LV update.
*/ */
const ScrollOnUpdate = { const ScrollOnUpdate = {
mounted() { mounted() {
this.__scroll(); this.scroll();
}, },
updated() { updated() {
this.__scroll(); this.scroll();
}, },
__scroll() { scroll() {
this.el.scrollTop = this.el.scrollHeight; this.el.scrollTop = this.el.scrollHeight;
}, },
}; };

1114
assets/js/hooks/session.js Normal file

File diff suppressed because it is too large Load diff

42
assets/js/hooks/timer.js Normal file
View file

@ -0,0 +1,42 @@
import { getAttributeOrThrow } from "../lib/attribute";
const UPDATE_INTERVAL_MS = 100;
/**
* A hook used to display a counting timer.
*
* ## Configuration
*
* * `data-start` - the timestamp to count from
*/
const Timer = {
mounted() {
this.props = this.getProps();
this.interval = setInterval(() => this.updateDOM(), UPDATE_INTERVAL_MS);
},
updated() {
this.props = this.getProps();
this.updateDOM();
},
destroyed() {
clearInterval(this.interval);
},
getProps() {
return {
start: getAttributeOrThrow(this.el, "data-start"),
};
},
updateDOM() {
const elapsedMs = Date.now() - new Date(this.props.start);
const elapsedSeconds = elapsedMs / 1_000;
this.el.innerHTML = `${elapsedSeconds.toFixed(1)}s`;
},
};
export default Timer;

View file

@ -3,9 +3,9 @@ import { storeUserData } from "../lib/user";
/** /**
* A hook for the user profile form. * A hook for the user profile form.
* *
* On submit this hook saves the new data into cookie. * On submit this hook saves the new data into cookie. This cookie
* This cookie serves as a backup and can be used to restore * serves as a backup and can be used to restore user data if the
* user data if the server is restarted. * server is restarted.
*/ */
const UserForm = { const UserForm = {
mounted() { mounted() {

View file

@ -0,0 +1,94 @@
import HyperList from "hyperlist";
import {
getAttributeOrThrow,
parseBoolean,
parseInteger,
} from "../lib/attribute";
import { findChildOrThrow, getLineHeight, isScrolledToEnd } from "../lib/utils";
/**
* A hook used to render text lines as a virtual list, so that only
* the visible lines are actually in the DOM.
*
* ## Configuration
*
* * `data-max-height` - the maximum height of the element, exceeding
* this height enables scrolling
*
* * `data-follow` - whether to automatically scroll to the bottom as
* new lines appear
*
* The element should have two children:
*
* * `[data-template]` - a hidden container containing all the line
* elements, each with a data-line attribute
*
* * `[data-content]` - the target element to render the virtualized
* lines into, it should contain the styling relevant text styles
*/
const VirtualizedLines = {
mounted() {
this.props = this.getProps();
this.lineHeight = getLineHeight(this.el);
this.templateEl = findChildOrThrow(this.el, "[data-template]");
this.contentEl = findChildOrThrow(this.el, "[data-content]");
const config = this.hyperListConfig();
this.virtualizedList = new HyperList(this.contentEl, config);
},
updated() {
this.props = this.getProps();
const scrollToEnd = this.props.follow && isScrolledToEnd(this.contentEl);
const config = this.hyperListConfig();
this.virtualizedList.refresh(this.contentEl, config);
if (scrollToEnd) {
this.contentEl.scrollTop = this.contentEl.scrollHeight;
}
},
getProps() {
return {
maxHeight: getAttributeOrThrow(this.el, "data-max-height", parseInteger),
follow: getAttributeOrThrow(this.el, "data-follow", parseBoolean),
};
},
hyperListConfig() {
const lineEls = this.templateEl.querySelectorAll("[data-line]");
const numberOfLines = lineEls.length;
const height = Math.min(
this.props.maxHeight,
this.lineHeight * numberOfLines
);
return {
height,
total: numberOfLines,
itemHeight: this.lineHeight,
generate: (index) => {
const node = lineEls[index].cloneNode(true);
node.removeAttribute("id");
return node;
},
afterRender: () => {
// The content element has a fixed height and when the horizontal
// scrollbar appears, it's treated as part of the element's content.
// To accommodate for the scrollbar we dynamically add more height
// to the element.
if (this.contentEl.scrollWidth > this.contentEl.clientWidth) {
this.contentEl.style.height = `${height + 12}px`;
} else {
this.contentEl.style.height = `${height}px`;
}
},
};
},
};
export default VirtualizedLines;

View file

@ -1,455 +0,0 @@
import { Socket } from "phoenix";
import { getAttributeOrThrow, parseInteger } from "../lib/attribute";
import { randomToken, sha256Base64 } from "../lib/utils";
/**
* A hook used to render a runtime-connected JavaScript view.
*
* JavaScript view is an abstraction for extending Livebook with
* custom capabilities. In particular, it is the primary building
* block for defining custom interactive output types, such as plots
* and maps.
*
* The JavaScript is defined by the user, so we sandbox the script
* execution inside an iframe.
*
* The hook connects to a dedicated channel, sending the token and
* view ref in an initial message. It expects `init:<ref>` message
* with `{ data }` payload, the data is then used in the initial call
* to the custom JS module.
*
* Then, a number of `event:<ref>` with `{ event, payload }` payload
* can be sent. The `event` is forwarded to the initialized component.
*
* Configuration:
*
* * `data-ref` - a unique identifier used as messages scope
*
* * `data-assets-base-path` - the path to resolve all relative paths
* against in the iframe
*
* * `data-js-path` - a relative path for the initial view-specific
* JS module
*
* * `data-session-token` - token is sent in the "connect" message
* to the channel
*
* * `data-session-id` - the identifier of the session that this
* view belongs go
*
* * `data-iframe-local-port` - the local port where the iframe is
* served
*
*/
const JSView = {
mounted() {
this.props = getProps(this);
this.state = {
childToken: randomToken(),
childReadyPromise: null,
childReady: false,
iframe: null,
channelUnsubscribe: null,
errorContainer: null,
};
const channel = getChannel(this.props.sessionId);
// When cells/sections are reordered, morphdom detaches and attaches
// the relevant elements in the DOM. JS view is generally rendered
// inside cells, so when reordering happens it becomes temporarily
// detached from the DOM and attaching it back would cause the iframe
// to reload. This behaviour is expected, as discussed in (1). Reloading
// that frequently is inefficient and also clears the iframe state,
// which makes is very undesired in our case. To solve this, we insert
// the iframe higher in the DOM tree, so that it's never affected by
// reordering. Then, we insert a placeholder element in the output to
// take up the expected space and we use absolute positioning to place
// the iframe exactly over that placeholder. We set up observers to
// track the changes in placeholder's position/size and we keep the
// absolute iframe in sync.
//
// (1): https://github.com/whatwg/html/issues/5484
const iframePlaceholder = document.createElement("div");
this.el.appendChild(iframePlaceholder);
const iframe = document.createElement("iframe");
iframe.className = "w-full h-0 absolute z-[1]";
this.state.iframe = iframe;
this.disconnectObservers = bindIframeSize(iframe, iframePlaceholder);
// Emulate mouse enter and leave on the placeholder. Note that we
// intentionally use bubbling to notify all parents that may have
// listeners on themselves
iframe.addEventListener("mouseenter", (event) => {
iframePlaceholder.dispatchEvent(
new MouseEvent("mouseenter", { bubbles: true })
);
});
iframe.addEventListener("mouseleave", (event) => {
iframePlaceholder.dispatchEvent(
new MouseEvent("mouseleave", { bubbles: true })
);
});
// Register message chandler to communicate with the iframe
function postMessage(message) {
iframe.contentWindow.postMessage(message, "*");
}
this.state.childReadyPromise = new Promise((resolve, reject) => {
this.handleWindowMessage = (event) => {
if (event.source === iframe.contentWindow) {
handleChildMessage(event.data);
}
};
window.addEventListener("message", this.handleWindowMessage);
const handleChildMessage = (message) => {
if (message.type === "ready" && !this.state.childReady) {
const assetsBaseUrl =
window.location.origin + this.props.assetsBasePath;
postMessage({
type: "readyReply",
token: this.state.childToken,
baseUrl: assetsBaseUrl,
jsPath: this.props.jsPath,
});
this.state.childReady = true;
resolve();
} else {
// Note: we use a random token to authorize child messages
// and do our best to make this token unavailable for the
// injected script on the child side. In the worst case scenario,
// the script manages to extract the token and can then send
// any of those messages, so we can treat this as a possible
// surface for attacks. In this case the most "critical" actions
// are shortcuts, neither of which is particularly dangerous.
if (message.token !== this.state.childToken) {
throw new Error("Token mismatch");
}
if (message.type === "resize") {
iframePlaceholder.style.height = `${message.height}px`;
iframe.style.height = `${message.height}px`;
} else if (message.type === "domEvent") {
// Replicate the child events on the current element,
// so that they are detected upstream in the session hook
const event = replicateDomEvent(message.event);
this.el.dispatchEvent(event);
} else if (message.type === "event") {
const { event, payload } = message;
const raw = transportEncode([event, this.props.ref], payload);
channel.push("event", raw);
}
}
};
const replicateDomEvent = (event) => {
if (event.type === "focus") {
return new FocusEvent("focus");
} else if (event.type === "mousedown") {
return new MouseEvent("mousedown", { bubbles: true });
} else if (event.type === "keydown") {
return new KeyboardEvent(event.type, event.props);
}
};
});
// Load the iframe content
const iframesEl = document.querySelector(
`[data-element="js-view-iframes"]`
);
initializeIframeSource(iframe, this.props.iframePort).then(() => {
iframesEl.appendChild(iframe);
});
// Event handlers
const initRef = channel.on(`init:${this.props.ref}`, (raw) => {
const [, payload] = transportDecode(raw);
this.state.childReadyPromise.then(() => {
postMessage({ type: "init", data: payload });
});
});
const eventRef = channel.on(`event:${this.props.ref}`, (raw) => {
const [[event], payload] = transportDecode(raw);
this.state.childReadyPromise.then(() => {
postMessage({ type: "event", event, payload });
});
});
const errorRef = channel.on(`error:${this.props.ref}`, ({ message }) => {
if (!this.state.errorContainer) {
this.state.errorContainer = document.createElement("div");
this.state.errorContainer.classList.add("error-box", "mb-4");
this.el.prepend(this.state.errorContainer);
}
this.state.errorContainer.textContent = message;
});
this.state.channelUnsubscribe = () => {
channel.off(`init:${this.props.ref}`, initRef);
channel.off(`event:${this.props.ref}`, eventRef);
channel.off(`error:${this.props.ref}`, errorRef);
};
channel.push("connect", {
session_token: this.props.sessionToken,
ref: this.props.ref,
});
},
updated() {
this.props = getProps(this);
},
destroyed() {
window.removeEventListener("message", this.handleWindowMessage);
this.disconnectObservers();
this.state.iframe.remove();
const channel = getChannel(this.props.sessionId, { create: false });
if (channel) {
this.state.channelUnsubscribe();
channel.push("disconnect", { ref: this.props.ref });
}
},
};
function getProps(hook) {
return {
ref: getAttributeOrThrow(hook.el, "data-ref"),
assetsBasePath: getAttributeOrThrow(hook.el, "data-assets-base-path"),
jsPath: getAttributeOrThrow(hook.el, "data-js-path"),
sessionToken: getAttributeOrThrow(hook.el, "data-session-token"),
sessionId: getAttributeOrThrow(hook.el, "data-session-id"),
iframePort: getAttributeOrThrow(
hook.el,
"data-iframe-local-port",
parseInteger
),
};
}
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const socket = new Socket("/socket", { params: { _csrf_token: csrfToken } });
let channel = null;
/**
* Returns channel used for all JS views in the current session.
*/
function getChannel(sessionId, { create = true } = {}) {
if (!channel && create) {
socket.connect();
channel = socket.channel("js_view", { session_id: sessionId });
channel.join();
}
return channel;
}
/**
* Leaves the JS views channel tied to the current session.
*/
export function leaveChannel() {
if (channel) {
channel.leave();
channel = null;
socket.disconnect();
}
}
/**
* Sets up observers to resize and reposition the iframe
* whenever the placeholder moves.
*/
function bindIframeSize(iframe, iframePlaceholder) {
const notebookEl = document.querySelector(`[data-element="notebook"]`);
const notebookContentEl = notebookEl.querySelector(
`[data-element="notebook-content"]`
);
function repositionIframe() {
if (iframePlaceholder.offsetParent === null) {
// When the placeholder is hidden, we hide the iframe as well
iframe.classList.add("hidden");
} else {
iframe.classList.remove("hidden");
const notebookBox = notebookEl.getBoundingClientRect();
const placeholderBox = iframePlaceholder.getBoundingClientRect();
const top = placeholderBox.top - notebookBox.top + notebookEl.scrollTop;
iframe.style.top = `${top}px`;
const left =
placeholderBox.left - notebookBox.left + notebookEl.scrollLeft;
iframe.style.left = `${left}px`;
iframe.style.height = `${placeholderBox.height}px`;
iframe.style.width = `${placeholderBox.width}px`;
}
}
// Most placeholder position changes are accompanied by changes to the
// notebook content element height (adding cells, inserting newlines
// in the editor, etc). On the other hand, toggling the sidebar or
// resizing the window changes the width, however the notebook
// content element doesn't span full width, so this change may not
// be detected, that's why we observe the full-width parent element
const resizeObserver = new ResizeObserver((entries) => repositionIframe());
resizeObserver.observe(notebookContentEl);
resizeObserver.observe(notebookEl);
// On lower level cell/section reordering is applied as element
// removal followed by insert, consequently the intersection
// between the placeholder and notebook content changes (becomes
// none for a brief moment)
const intersectionObserver = new IntersectionObserver(
(entries) => repositionIframe(),
{ root: notebookContentEl }
);
intersectionObserver.observe(iframePlaceholder);
return () => {
resizeObserver.disconnect();
intersectionObserver.disconnect();
};
}
// Loading iframe using `srcdoc` disables cookies and browser APIs,
// such as camera and microphone (1), the same applies to `src` with
// data URL, so we need to load the iframe through a regular request.
// Since the iframe is sandboxed we also need `allow-same-origin`.
// Additionally, we cannot load the iframe from the same origin as
// the app, because using `allow-same-origin` together with `allow-scripts`
// would be insecure (2). Consequently, we need to load the iframe
// from a different origin.
//
// When running Livebook on https:// we load the iframe from another
// https:// origin. On the other hand, when running on http:// we want
// to load the iframe from http:// as well, otherwise the browser could
// block asset requests from the https:// iframe to http:// Livebook.
// However, external http:// content is not considered a secure context (3),
// which implies no access to user media. Therefore, instead of using
// http://livebook.space we use another localhost endpoint. Note that
// this endpoint has a different port than the Livebook web app, that's
// because we need separate origins, as outlined above.
//
// To ensure integrity of the loaded content we manually verify the
// checksum against the expected value.
//
// (1): https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#document_source_security
// (2): https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox
// (3): https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts
const IFRAME_SHA256 = "+uJyGu0Ey7uVV7WwRwg7GyjwCkMNRBnyNc25iGFpYXc=";
function getIframeUrl(iframePort) {
return window.location.protocol === "https:"
? "https://livebook.space/iframe/v2.html"
: `http://${window.location.hostname}:${iframePort}/iframe/v2.html`;
}
function initializeIframeSource(iframe, iframePort) {
const iframeUrl = getIframeUrl(iframePort);
return verifyIframeSource(iframeUrl).then(() => {
iframe.sandbox =
"allow-scripts allow-same-origin allow-downloads allow-modals";
iframe.allow =
"accelerometer; ambient-light-sensor; camera; display-capture; encrypted-media; geolocation; gyroscope; microphone; midi; usb; xr-spatial-tracking";
iframe.src = iframeUrl;
});
}
let iframeVerificationPromise = null;
function verifyIframeSource(iframeUrl) {
if (!iframeVerificationPromise) {
iframeVerificationPromise = fetch(iframeUrl)
.then((response) => response.text())
.then((html) => {
if (sha256Base64(html) !== IFRAME_SHA256) {
throw new Error(
"The loaded iframe content doesn't have the expected checksum"
);
}
});
}
return iframeVerificationPromise;
}
// Encoding/decoding of channel payloads
function transportEncode(meta, payload) {
if (
Array.isArray(payload) &&
payload[1] &&
payload[1].constructor === ArrayBuffer
) {
const [info, buffer] = payload;
return encode([meta, info], buffer);
} else {
return { root: [meta, payload] };
}
}
function transportDecode(raw) {
if (raw.constructor === ArrayBuffer) {
const [[meta, info], buffer] = decode(raw);
return [meta, [info, buffer]];
} else {
const {
root: [meta, payload],
} = raw;
return [meta, payload];
}
}
const HEADER_LENGTH = 4;
function encode(meta, buffer) {
const encoder = new TextEncoder();
const metaArray = encoder.encode(JSON.stringify(meta));
const raw = new ArrayBuffer(
HEADER_LENGTH + metaArray.byteLength + buffer.byteLength
);
const view = new DataView(raw);
view.setUint32(0, metaArray.byteLength);
new Uint8Array(raw, HEADER_LENGTH, metaArray.byteLength).set(metaArray);
new Uint8Array(raw, HEADER_LENGTH + metaArray.byteLength).set(
new Uint8Array(buffer)
);
return raw;
}
function decode(raw) {
const view = new DataView(raw);
const metaArrayLength = view.getUint32(0);
const metaArray = new Uint8Array(raw, HEADER_LENGTH, metaArrayLength);
const buffer = raw.slice(HEADER_LENGTH + metaArrayLength);
const decoder = new TextDecoder();
const meta = JSON.parse(decoder.decode(metaArray));
return [meta, buffer];
}
export default JSView;

View file

@ -1,105 +0,0 @@
import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
import { cancelEvent, isEditableElement } from "../lib/utils";
/**
* A hook for ControlComponent to handle user keyboard interactions.
*
* Configuration:
*
* * `data-keydown-enabled` - whether keydown events should be intercepted
*
* * `data-keyup-enabled` - whether keyup events should be intercepted
*
* * `data-target` - the target to send live events to
*/
const KeyboardControl = {
mounted() {
this.props = getProps(this);
this.handleDocumentKeyDown = (event) => {
handleDocumentKeyDown(this, event);
};
// We intentionally register on window rather than document,
// to intercept clicks as early on as possible, even before
// the session shortcuts
window.addEventListener("keydown", this.handleDocumentKeyDown, true);
this.handleDocumentKeyUp = (event) => {
handleDocumentKeyUp(this, event);
};
window.addEventListener("keyup", this.handleDocumentKeyUp, true);
this.handleDocumentFocus = (event) => {
handleDocumentFocus(this, event);
};
// Note: the focus event doesn't bubble, so we register for the capture phase
window.addEventListener("focus", this.handleDocumentFocus, true);
},
updated() {
this.props = getProps(this);
},
destroyed() {
window.removeEventListener("keydown", this.handleDocumentKeyDown, true);
window.removeEventListener("keyup", this.handleDocumentKeyUp, true);
window.removeEventListener("focus", this.handleDocumentFocus, true);
},
};
function getProps(hook) {
return {
isKeydownEnabled: getAttributeOrThrow(
hook.el,
"data-keydown-enabled",
parseBoolean
),
isKeyupEnabled: getAttributeOrThrow(
hook.el,
"data-keyup-enabled",
parseBoolean
),
target: getAttributeOrThrow(hook.el, "data-target"),
};
}
function handleDocumentKeyDown(hook, event) {
if (keyboardEnabled(hook)) {
cancelEvent(event);
}
if (hook.props.isKeydownEnabled) {
if (event.repeat) {
return;
}
const key = event.key;
hook.pushEventTo(hook.props.target, "keydown", { key });
}
}
function handleDocumentKeyUp(hook, event) {
if (keyboardEnabled(hook)) {
cancelEvent(event);
}
if (hook.props.isKeyupEnabled) {
const key = event.key;
hook.pushEventTo(hook.props.target, "keyup", { key });
}
}
function handleDocumentFocus(hook, event) {
if (hook.props.isKeydownEnabled && isEditableElement(event.target)) {
hook.pushEventTo(hook.props.target, "disable_keyboard", {});
}
}
function keyboardEnabled(hook) {
return hook.props.isKeydownEnabled || hook.props.isKeyupEnabled;
}
export default KeyboardControl;

View file

@ -115,7 +115,7 @@ export default class Delta {
} }
} }
return delta.__trim(); return delta._trim();
} }
/** /**
@ -163,10 +163,10 @@ export default class Delta {
} }
} }
return delta.__trim(); return delta._trim();
} }
__trim() { _trim() {
if (this.ops.length > 0 && isRetain(this.ops[this.ops.length - 1])) { if (this.ops.length > 0 && isRetain(this.ops[this.ops.length - 1])) {
this.ops.pop(); this.ops.pop();
} }

View file

@ -15,7 +15,7 @@ import { visit } from "unist-util-visit";
import { toText } from "hast-util-to-text"; import { toText } from "hast-util-to-text";
import { removePosition } from "unist-util-remove-position"; import { removePosition } from "unist-util-remove-position";
import { highlight } from "../cell_editor/live_editor/monaco"; import { highlight } from "../hooks/cell_editor/live_editor/monaco";
import { renderMermaid } from "./markdown/mermaid"; import { renderMermaid } from "./markdown/mermaid";
import { escapeHtml } from "../lib/utils"; import { escapeHtml } from "../lib/utils";
@ -29,16 +29,16 @@ class Markdown {
this.baseUrl = baseUrl; this.baseUrl = baseUrl;
this.emptyText = emptyText; this.emptyText = emptyText;
this.__render(); this._render();
} }
setContent(content) { setContent(content) {
this.content = content; this.content = content;
this.__render(); this._render();
} }
__render() { _render() {
this.__getHtml().then((html) => { this._getHtml().then((html) => {
// Wrap the HTML in another element, so that we // Wrap the HTML in another element, so that we
// can use morphdom's childrenOnly option // can use morphdom's childrenOnly option
const wrappedHtml = `<div>${html}</div>`; const wrappedHtml = `<div>${html}</div>`;
@ -46,7 +46,7 @@ class Markdown {
}); });
} }
__getHtml() { _getHtml() {
return ( return (
unified() unified()
.use(remarkParse) .use(remarkParse)

View file

@ -58,6 +58,14 @@ export function smoothlyScrollToElement(element) {
} }
} }
export function isScrolledToEnd(element) {
// See https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
return (
Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <
1
);
}
/** /**
* Transforms a UTF8 string into base64 encoding. * Transforms a UTF8 string into base64 encoding.
*/ */

View file

@ -1,38 +0,0 @@
/**
* A hook used to toggle password's input visibility via icon button.
*/
const VISIBLE_ICON = "ri-eye-off-line";
const OBSCURED_ICON = "ri-eye-line";
const PasswordToggle = {
mounted() {
this.visible = false;
this.input = this.el.querySelector("input");
this.iconButton = this.el.querySelector("i");
this.iconButton.addEventListener("click", () => {
this.visible = !this.visible;
this._updateDOM();
});
},
updated() {
this._updateDOM();
},
_updateDOM() {
if (this.visible) {
this.input.type = "text";
this.iconButton.classList.remove(OBSCURED_ICON);
this.iconButton.classList.add(VISIBLE_ICON);
} else {
this.input.type = "password";
this.iconButton.classList.remove(VISIBLE_ICON);
this.iconButton.classList.add(OBSCURED_ICON);
}
},
};
export default PasswordToggle;

File diff suppressed because it is too large Load diff

View file

@ -1,41 +0,0 @@
import { getAttributeOrThrow } from "../lib/attribute";
const UPDATE_INTERVAL_MS = 100;
/**
* A hook used to display a timer counting from the moment
* of mounting.
*/
const Timer = {
mounted() {
this.props = getProps(this);
this.state = {
start: new Date(this.props.start),
interval: null,
};
this.state.interval = setInterval(() => {
this.__tick();
}, UPDATE_INTERVAL_MS);
},
destroyed() {
clearInterval(this.state.interval);
},
__tick() {
const elapsedMs = Date.now() - this.state.start;
const elapsedSeconds = elapsedMs / 1_000;
this.el.innerHTML = `${elapsedSeconds.toFixed(1)}s`;
},
};
function getProps(hook) {
return {
start: getAttributeOrThrow(hook.el, "data-start"),
};
}
export default Timer;

View file

@ -1,121 +0,0 @@
import HyperList from "hyperlist";
import {
getAttributeOrThrow,
parseBoolean,
parseInteger,
} from "../lib/attribute";
import { findChildOrThrow, getLineHeight } from "../lib/utils";
/**
* A hook used to render text lines as a virtual list,
* so that only the visible lines are actually in the DOM.
*
* Configuration:
*
* * `data-max-height` - the maximum height of the element, exceeding this height enables scrolling
*
* * `data-follow` - whether to automatically scroll to the bottom as new lines appear
*
* The element should have two children:
*
* * one annotated with `data-template` attribute, it should be hidden
* and contain all the line elements, each with a data-line attribute
*
* * one annotated with `data-content` where the visible elements are rendered,
* it should contain any styling relevant for the container
*
*/
const VirtualizedLines = {
mounted() {
this.props = getProps(this);
this.state = {
lineHeight: null,
templateElement: null,
contentElement: null,
virtualizedList: null,
};
this.state.lineHeight = getLineHeight(this.el);
this.state.templateElement = findChildOrThrow(this.el, "[data-template]");
this.state.contentElement = findChildOrThrow(this.el, "[data-content]");
const config = hyperListConfig(
this.state.contentElement,
this.state.templateElement,
this.props.maxHeight,
this.state.lineHeight
);
this.state.virtualizedList = new HyperList(
this.state.contentElement,
config
);
},
updated() {
this.props = getProps(this);
const config = hyperListConfig(
this.state.contentElement,
this.state.templateElement,
this.props.maxHeight,
this.state.lineHeight
);
const scrollTop = Math.round(this.state.contentElement.scrollTop);
const maxScrollTop = Math.round(
this.state.contentElement.scrollHeight -
this.state.contentElement.clientHeight
);
const isAtTheEnd = scrollTop === maxScrollTop;
this.state.virtualizedList.refresh(this.state.contentElement, config);
if (this.props.follow && isAtTheEnd) {
this.state.contentElement.scrollTop =
this.state.contentElement.scrollHeight;
}
},
};
function hyperListConfig(
contentElement,
templateElement,
maxHeight,
lineHeight
) {
const lineElements = templateElement.querySelectorAll("[data-line]");
const numberOfLines = lineElements.length;
const height = Math.min(maxHeight, lineHeight * numberOfLines);
return {
height,
total: numberOfLines,
itemHeight: lineHeight,
generate: (index) => {
const node = lineElements[index].cloneNode(true);
node.removeAttribute("id");
return node;
},
afterRender: () => {
// The content element has a fixed height and when the horizontal
// scrollbar appears, it's treated as part of the element's content.
// To accommodate for the scrollbar we dynamically add more height
// to the element.
if (contentElement.scrollWidth > contentElement.clientWidth) {
contentElement.style.height = `${height + 12}px`;
} else {
contentElement.style.height = `${height}px`;
}
},
};
}
function getProps(hook) {
return {
maxHeight: getAttributeOrThrow(hook.el, "data-max-height", parseInteger),
follow: getAttributeOrThrow(hook.el, "data-follow", parseBoolean),
};
}
export default VirtualizedLines;

View file

@ -411,30 +411,47 @@ defmodule LivebookWeb.Helpers do
end end
@doc """ @doc """
Renders a wrapper around password input Renders a wrapper around password input with an added visibility
with an added visibility toggle button. toggle button.
The toggle switches the input's type between `password` The toggle switches the input's type between `password` and `text`.
and `text`.
## Examples ## Examples
<.with_password_toggle id="input-id"> <.with_password_toggle id="secret-password-toggle">
<input type="password" ...> <input type="password" ...>
</.with_password_toggle> </.with_password_toggle>
""" """
def with_password_toggle(assigns) do def with_password_toggle(assigns) do
~H""" ~H"""
<div id={"password-toggle-#{@id}"} class="relative inline w-min" phx-hook="PasswordToggle"> <div id={@id} class="relative flex">
<!-- render password input -->
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
<button <div class="flex items-center absolute inset-y-0 right-1">
class="bg-gray-50 p-1 icon-button absolute inset-y-0 right-1" <button class="icon-button"
type="button" data-show
aria-label="toggle password visibility" type="button"
phx-change="ignore"> aria-label="show password"
<.remix_icon icon="eye-line" class="text-xl" /> phx-click={
</button> JS.remove_attribute("type", to: "##{@id} input")
|> JS.set_attribute({"type", "text"}, to: "##{@id} input")
|> JS.add_class("hidden", to: "##{@id} [data-show]")
|> JS.remove_class("hidden", to: "##{@id} [data-hide]")
}>
<.remix_icon icon="eye-line" class="text-xl" />
</button>
<button class="icon-button hidden"
data-hide
type="button"
aria-label="hide password"
phx-click={
JS.remove_attribute("type", to: "##{@id} input")
|> JS.set_attribute({"type", "password"}, to: "##{@id} input")
|> JS.remove_class("hidden", to: "##{@id} [data-show]")
|> JS.add_class("hidden", to: "##{@id} [data-hide]")
}>
<.remix_icon icon="eye-off-line" class="text-xl" />
</button>
</div>
</div> </div>
""" """
end end

View file

@ -21,11 +21,12 @@ defmodule LivebookWeb.HomeLive.ImportFileUploadComponent do
phx-change="validate" phx-change="validate"
phx-drop-target={@uploads.notebook.ref} phx-drop-target={@uploads.notebook.ref}
phx-target={@myself} phx-target={@myself}
phx-hook="DragAndDrop"
class="flex flex-col items-start" class="flex flex-col items-start"
> >
<%= live_file_input @uploads.notebook, class: "hidden", aria_labelledby: "import-from-file" %> <%= live_file_input @uploads.notebook, class: "hidden", aria_labelledby: "import-from-file" %>
<div data-dropzone class="flex flex-col justify-center items-center w-full rounded-xl border-2 border-dashed border-gray-400 h-48"> <div class="flex flex-col justify-center items-center w-full rounded-xl border-2 border-dashed border-gray-400 h-48"
phx-hook="Dropzone"
id="upload-file-dropzone">
<%= if @uploads.notebook.entries == [] do %> <%= if @uploads.notebook.entries == [] do %>
<span name="placeholder" class="font-medium text-gray-400">Drop your notebook here</span> <span name="placeholder" class="font-medium text-gray-400">Drop your notebook here</span>
<% else %> <% else %>
@ -71,6 +72,8 @@ defmodule LivebookWeb.HomeLive.ImportFileUploadComponent do
content = File.read!(path) content = File.read!(path)
send(self(), {:import_content, content, []}) send(self(), {:import_content, content, []})
{:ok, :ok}
end) end)
{:noreply, socket} {:noreply, socket}

View file

@ -6,7 +6,7 @@ defmodule LivebookWeb.Output.MarkdownComponent do
socket = assign(socket, assigns) socket = assign(socket, assigns)
{:ok, {:ok,
push_event(socket, "markdown-renderer:#{socket.assigns.id}:content", %{ push_event(socket, "markdown_renderer:#{socket.assigns.id}:content", %{
content: socket.assigns.content content: socket.assigns.content
})} })}
end end

View file

@ -1459,7 +1459,6 @@ defmodule LivebookWeb.SessionLive do
status: eval_info.status, status: eval_info.status,
evaluation_time_ms: eval_info.evaluation_time_ms, evaluation_time_ms: eval_info.evaluation_time_ms,
evaluation_start: eval_info.evaluation_start, evaluation_start: eval_info.evaluation_start,
evaluation_number: eval_info.evaluation_number,
evaluation_digest: encode_digest(eval_info.evaluation_digest), evaluation_digest: encode_digest(eval_info.evaluation_digest),
outputs_batch_number: eval_info.outputs_batch_number, outputs_batch_number: eval_info.outputs_batch_number,
# Pass input values relevant to the given cell # Pass input values relevant to the given cell

View file

@ -404,7 +404,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
~H""" ~H"""
<.status_indicator circle_class="bg-blue-500" animated_circle_class="bg-blue-400" change_indicator={true}> <.status_indicator circle_class="bg-blue-500" animated_circle_class="bg-blue-400" change_indicator={true}>
<span class="font-mono" <span class="font-mono"
id={"cell-timer-#{@cell_view.id}-evaluation-#{@cell_view.eval.evaluation_number}"} id={"cell-timer-#{@cell_view.id}"}
phx-hook="Timer" phx-hook="Timer"
phx-update="ignore" phx-update="ignore"
data-start={DateTime.to_iso8601(@cell_view.eval.evaluation_start)}> data-start={DateTime.to_iso8601(@cell_view.eval.evaluation_start)}>

View file

@ -46,13 +46,13 @@ defmodule LivebookWeb.SettingsLive.AddFileSystemComponent do
</div> </div>
<div> <div>
<div class="input-label">Access Key ID</div> <div class="input-label">Access Key ID</div>
<.with_password_toggle id="access-key"> <.with_password_toggle id="access-key-password-toggle">
<%= text_input f, :access_key_id, value: @data["access_key_id"], class: "input", type: "password" %> <%= text_input f, :access_key_id, value: @data["access_key_id"], class: "input", type: "password" %>
</.with_password_toggle> </.with_password_toggle>
</div> </div>
<div> <div>
<div class="input-label">Secret Access Key</div> <div class="input-label">Secret Access Key</div>
<.with_password_toggle id="secret-access-key"> <.with_password_toggle id="secret-access-key-password-toggle">
<%= text_input f, :secret_access_key, value: @data["secret_access_key"], class: "input", type: "password" %> <%= text_input f, :secret_access_key, value: @data["secret_access_key"], class: "input", type: "password" %>
</.with_password_toggle> </.with_password_toggle>
</div> </div>