mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Refactor JS hooks (#1055)
* Restructure hook files * Simplify app.js * Refactor hooks * Implement password toggle with JS commands
This commit is contained in:
parent
99c3eb4108
commit
b3b79afed4
|
@ -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])
|
||||||
|
|
148
assets/js/app.js
148
assets/js/app.js
|
@ -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;
|
||||||
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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
83
assets/js/events.js
Normal 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 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -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;
|
|
|
@ -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
350
assets/js/hooks/cell.js
Normal 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;
|
|
@ -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;
|
|
@ -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",
|
|
@ -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) {
|
|
@ -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.
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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 = [];
|
|
@ -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);
|
||||||
}
|
}
|
24
assets/js/hooks/dropzone.js
Normal file
24
assets/js/hooks/dropzone.js
Normal 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;
|
|
@ -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
139
assets/js/hooks/headline.js
Normal 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;
|
50
assets/js/hooks/highlight.js
Normal file
50
assets/js/hooks/highlight.js
Normal 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
35
assets/js/hooks/index.js
Normal 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
316
assets/js/hooks/js_view.js
Normal 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;
|
93
assets/js/hooks/js_view/channel.js
Normal file
93
assets/js/hooks/js_view/channel.js
Normal 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];
|
||||||
|
}
|
65
assets/js/hooks/js_view/iframe.js
Normal file
65
assets/js/hooks/js_view/iframe.js
Normal 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;
|
||||||
|
}
|
96
assets/js/hooks/keyboard_control.js
Normal file
96
assets/js/hooks/keyboard_control.js
Normal 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;
|
|
@ -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;
|
|
@ -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
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
42
assets/js/hooks/timer.js
Normal 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;
|
|
@ -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() {
|
94
assets/js/hooks/virtualized_lines.js
Normal file
94
assets/js/hooks/virtualized_lines.js
Normal 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;
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)}>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue