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;
|
||||
}
|
||||
|
||||
[phx-hook="Dropzone"][data-js-dragging] {
|
||||
@apply bg-red-200 border-red-400;
|
||||
}
|
||||
|
||||
/* === Session === */
|
||||
|
||||
[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 "remixicon/fonts/remixicon.css";
|
||||
import "katex/dist/katex.min.css";
|
||||
|
||||
import "@fontsource/inter";
|
||||
import "@fontsource/inter/500.css";
|
||||
import "@fontsource/inter/600.css";
|
||||
|
@ -9,48 +8,13 @@ import "@fontsource/jetbrains-mono";
|
|||
|
||||
import "phoenix_html";
|
||||
import { Socket } from "phoenix";
|
||||
import topbar from "topbar";
|
||||
import { LiveSocket } from "phoenix_live_view";
|
||||
import Headline from "./headline";
|
||||
import Cell from "./cell";
|
||||
import CellEditor from "./cell_editor";
|
||||
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 hooks from "./hooks";
|
||||
import { morphdomOptions } from "./dom";
|
||||
import { loadUserData } from "./lib/user";
|
||||
import { settingsStore } from "./lib/settings";
|
||||
|
||||
const hooks = {
|
||||
Headline,
|
||||
Cell,
|
||||
CellEditor,
|
||||
Session,
|
||||
FocusOnUpdate,
|
||||
ScrollOnUpdate,
|
||||
VirtualizedLines,
|
||||
UserForm,
|
||||
EditorSettings,
|
||||
Timer,
|
||||
MarkdownRenderer,
|
||||
Highlight,
|
||||
DragAndDrop,
|
||||
PasswordToggle,
|
||||
KeyboardControl,
|
||||
JSView,
|
||||
ConfirmModal,
|
||||
};
|
||||
import { registerTopbar, registerGlobalEventHandlers } from "./events";
|
||||
|
||||
const csrfToken = document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
|
@ -65,101 +29,25 @@ const liveSocket = new LiveSocket("/live", Socket, {
|
|||
};
|
||||
},
|
||||
hooks: hooks,
|
||||
dom: morphdomCallbacks,
|
||||
dom: morphdomOptions,
|
||||
});
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({
|
||||
barColors: { 0: "#b2c1ff" },
|
||||
shadowColor: "rgba(0, 0, 0, .3)",
|
||||
});
|
||||
registerTopbar();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// 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
|
||||
// Handle custom events dispatched with JS.dispatch/3
|
||||
registerGlobalEventHandlers();
|
||||
|
||||
// Reflect global configuration in attributes to enable CSS rules
|
||||
settingsStore.getAndSubscribe((settings) => {
|
||||
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) {
|
||||
// Keep element attributes starting with data-js-
|
||||
// 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";
|
||||
|
||||
const CellEditor = {
|
||||
mounted() {
|
||||
this.props = getProps(this);
|
||||
this.props = this.getProps();
|
||||
|
||||
this.handleEvent(
|
||||
`cell_editor_init:${this.props.cellId}:${this.props.tag}`,
|
||||
|
@ -50,13 +50,13 @@ const CellEditor = {
|
|||
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;
|
|
@ -3,8 +3,8 @@ import EditorClient from "./live_editor/editor_client";
|
|||
import MonacoEditorAdapter from "./live_editor/monaco_editor_adapter";
|
||||
import HookServerAdapter from "./live_editor/hook_server_adapter";
|
||||
import RemoteUser from "./live_editor/remote_user";
|
||||
import { replacedSuffixLength } from "../lib/text_utils";
|
||||
import { settingsStore } from "../lib/settings";
|
||||
import { replacedSuffixLength } from "../../lib/text_utils";
|
||||
import { settingsStore } from "../../lib/settings";
|
||||
|
||||
/**
|
||||
* Mounts cell source editor with real-time collaboration mechanism.
|
||||
|
@ -33,10 +33,10 @@ class LiveEditor {
|
|||
this._onCursorSelectionChange = null;
|
||||
this._remoteUserByClientPid = {};
|
||||
|
||||
this.__mountEditor();
|
||||
this._mountEditor();
|
||||
|
||||
if (this.intellisense) {
|
||||
this.__setupIntellisense();
|
||||
this._setupIntellisense();
|
||||
}
|
||||
|
||||
const serverAdapter = new HookServerAdapter(hook, cellId, tag);
|
||||
|
@ -180,7 +180,7 @@ class LiveEditor {
|
|||
}
|
||||
}
|
||||
|
||||
__mountEditor() {
|
||||
_mountEditor() {
|
||||
const settings = settingsStore.get();
|
||||
|
||||
this.editor = monaco.editor.create(this.container, {
|
||||
|
@ -276,7 +276,7 @@ class LiveEditor {
|
|||
/**
|
||||
* Defines cell-specific providers for various editor features.
|
||||
*/
|
||||
__setupIntellisense() {
|
||||
_setupIntellisense() {
|
||||
const settings = settingsStore.get();
|
||||
|
||||
this.handlerByRef = {};
|
||||
|
@ -290,11 +290,11 @@ class LiveEditor {
|
|||
* * the user opens the completion list, which triggers the global
|
||||
* 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
|
||||
* 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
|
||||
*
|
||||
* * 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
|
||||
*/
|
||||
|
||||
this.editor.getModel().__getCompletionItems = (model, position) => {
|
||||
this.editor.getModel().__getCompletionItems__ = (model, position) => {
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const lineUntilCursor = line.slice(0, position.column - 1);
|
||||
|
||||
return this.__asyncIntellisenseRequest("completion", {
|
||||
return this._asyncIntellisenseRequest("completion", {
|
||||
hint: lineUntilCursor,
|
||||
editor_auto_completion: settings.editor_auto_completion,
|
||||
})
|
||||
|
@ -335,11 +335,11 @@ class LiveEditor {
|
|||
.catch(() => null);
|
||||
};
|
||||
|
||||
this.editor.getModel().__getHover = (model, position) => {
|
||||
this.editor.getModel().__getHover__ = (model, position) => {
|
||||
const line = model.getLineContent(position.lineNumber);
|
||||
const column = position.column;
|
||||
|
||||
return this.__asyncIntellisenseRequest("details", { line, column })
|
||||
return this._asyncIntellisenseRequest("details", { line, column })
|
||||
.then((response) => {
|
||||
const contents = response.contents.map((content) => ({
|
||||
value: content,
|
||||
|
@ -363,7 +363,7 @@ class LiveEditor {
|
|||
response: null,
|
||||
};
|
||||
|
||||
this.editor.getModel().__getSignatureHelp = (model, position) => {
|
||||
this.editor.getModel().__getSignatureHelp__ = (model, position) => {
|
||||
const lines = model.getLinesContent();
|
||||
const lineIdx = position.lineNumber - 1;
|
||||
const prevLines = lines.slice(0, lineIdx);
|
||||
|
@ -385,7 +385,7 @@ class LiveEditor {
|
|||
};
|
||||
}
|
||||
|
||||
return this.__asyncIntellisenseRequest("signature", {
|
||||
return this._asyncIntellisenseRequest("signature", {
|
||||
hint: codeUntilCursor,
|
||||
})
|
||||
.then((response) => {
|
||||
|
@ -400,10 +400,10 @@ class LiveEditor {
|
|||
.catch(() => null);
|
||||
};
|
||||
|
||||
this.editor.getModel().__getDocumentFormattingEdits = (model) => {
|
||||
this.editor.getModel().__getDocumentFormattingEdits__ = (model) => {
|
||||
const content = model.getValue();
|
||||
|
||||
return this.__asyncIntellisenseRequest("format", { code: content })
|
||||
return this._asyncIntellisenseRequest("format", { code: content })
|
||||
.then((response) => {
|
||||
this.setCodeErrorMarker(response.code_error);
|
||||
|
||||
|
@ -462,7 +462,7 @@ class LiveEditor {
|
|||
* The returned promise is either resolved with a valid
|
||||
* response or rejected with null.
|
||||
*/
|
||||
__asyncIntellisenseRequest(type, props) {
|
||||
_asyncIntellisenseRequest(type, props) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.hook.pushEvent(
|
||||
"intellisense_request",
|
|
@ -29,17 +29,17 @@ export default class EditorClient {
|
|||
this._onDelta = null;
|
||||
|
||||
this.editorAdapter.onDelta((delta) => {
|
||||
this.__handleClientDelta(delta);
|
||||
this._handleClientDelta(delta);
|
||||
// This delta comes from the editor, so it has already been applied.
|
||||
this.__emitDelta(delta);
|
||||
this._emitDelta(delta);
|
||||
});
|
||||
|
||||
this.serverAdapter.onDelta((delta) => {
|
||||
this.__handleServerDelta(delta);
|
||||
this._handleServerDelta(delta);
|
||||
});
|
||||
|
||||
this.serverAdapter.onAcknowledgement(() => {
|
||||
this.__handleServerAcknowledgement();
|
||||
this._handleServerAcknowledgement();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -53,20 +53,20 @@ export default class EditorClient {
|
|||
this._onDelta = callback;
|
||||
}
|
||||
|
||||
__emitDelta(delta) {
|
||||
_emitDelta(delta) {
|
||||
this._onDelta && this._onDelta(delta);
|
||||
}
|
||||
|
||||
__handleClientDelta(delta) {
|
||||
_handleClientDelta(delta) {
|
||||
this.state = this.state.onClientDelta(delta);
|
||||
}
|
||||
|
||||
__handleServerDelta(delta) {
|
||||
_handleServerDelta(delta) {
|
||||
this.revision++;
|
||||
this.state = this.state.onServerDelta(delta);
|
||||
}
|
||||
|
||||
__handleServerAcknowledgement() {
|
||||
_handleServerAcknowledgement() {
|
||||
this.revision++;
|
||||
this.state = this.state.onServerAcknowledgement();
|
||||
}
|
||||
|
@ -74,7 +74,7 @@ export default class EditorClient {
|
|||
applyDelta(delta) {
|
||||
this.editorAdapter.applyDelta(delta);
|
||||
// This delta comes from the server and we have just applied it to the editor.
|
||||
this.__emitDelta(delta);
|
||||
this._emitDelta(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.
|
|
@ -41,8 +41,8 @@ document.fonts.addEventListener("loadingdone", (event) => {
|
|||
|
||||
monaco.languages.registerCompletionItemProvider("elixir", {
|
||||
provideCompletionItems: (model, position, context, token) => {
|
||||
if (model.__getCompletionItems) {
|
||||
return model.__getCompletionItems(model, position);
|
||||
if (model.__getCompletionItems__) {
|
||||
return model.__getCompletionItems__(model, position);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -51,8 +51,8 @@ monaco.languages.registerCompletionItemProvider("elixir", {
|
|||
|
||||
monaco.languages.registerHoverProvider("elixir", {
|
||||
provideHover: (model, position, token) => {
|
||||
if (model.__getHover) {
|
||||
return model.__getHover(model, position);
|
||||
if (model.__getHover__) {
|
||||
return model.__getHover__(model, position);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -62,8 +62,8 @@ monaco.languages.registerHoverProvider("elixir", {
|
|||
monaco.languages.registerSignatureHelpProvider("elixir", {
|
||||
signatureHelpTriggerCharacters: ["(", ","],
|
||||
provideSignatureHelp: (model, position, token, context) => {
|
||||
if (model.__getSignatureHelp) {
|
||||
return model.__getSignatureHelp(model, position);
|
||||
if (model.__getSignatureHelp__) {
|
||||
return model.__getSignatureHelp__(model, position);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
@ -72,8 +72,8 @@ monaco.languages.registerSignatureHelpProvider("elixir", {
|
|||
|
||||
monaco.languages.registerDocumentFormattingEditProvider("elixir", {
|
||||
provideDocumentFormattingEdits: (model, options, token) => {
|
||||
if (model.__getDocumentFormattingEdits) {
|
||||
return model.__getDocumentFormattingEdits(model);
|
||||
if (model.__getDocumentFormattingEdits__) {
|
||||
return model.__getDocumentFormattingEdits__(model);
|
||||
} else {
|
||||
return null;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
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.
|
||||
|
@ -19,7 +19,7 @@ export default class MonacoEditorAdapter {
|
|||
|
||||
this.isLastChangeRemote = false;
|
||||
|
||||
const delta = this.__deltaFromEditorChange(event);
|
||||
const delta = this._deltaFromEditorChange(event);
|
||||
this._onDelta && this._onDelta(delta);
|
||||
});
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ export default class MonacoEditorAdapter {
|
|||
this.editor.getModel().popStackElement();
|
||||
}
|
||||
|
||||
const operations = this.__deltaToEditorOperations(delta);
|
||||
const operations = this._deltaToEditorOperations(delta);
|
||||
this.ignoreChange = true;
|
||||
// Apply the operations and add them to the undo stack
|
||||
this.editor.getModel().pushEditOperations(null, operations, null);
|
||||
|
@ -70,7 +70,7 @@ export default class MonacoEditorAdapter {
|
|||
this.isLastChangeRemote = true;
|
||||
}
|
||||
|
||||
__deltaFromEditorChange(event) {
|
||||
_deltaFromEditorChange(event) {
|
||||
const deltas = event.changes.map((change) => {
|
||||
const { rangeOffset, rangeLength, text } = change;
|
||||
|
||||
|
@ -94,7 +94,7 @@ export default class MonacoEditorAdapter {
|
|||
return deltas.reduce((delta1, delta2) => delta1.compose(delta2));
|
||||
}
|
||||
|
||||
__deltaToEditorOperations(delta) {
|
||||
_deltaToEditorOperations(delta) {
|
||||
const model = this.editor.getModel();
|
||||
|
||||
const operations = [];
|
|
@ -1,5 +1,5 @@
|
|||
import monaco from "./monaco";
|
||||
import { randomId } from "../../lib/utils";
|
||||
import { randomId } from "../../../lib/utils";
|
||||
|
||||
/**
|
||||
* Remote user visual indicators within the editor.
|
||||
|
@ -45,9 +45,9 @@ class CursorWidget {
|
|||
this._id = randomId();
|
||||
this._editor = editor;
|
||||
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);
|
||||
|
||||
|
@ -75,8 +75,8 @@ class CursorWidget {
|
|||
|
||||
update(position) {
|
||||
this._position = position;
|
||||
this._isPositionValid = this.__checkPositionValidity(position);
|
||||
this.__updateDomNode();
|
||||
this._isPositionValid = this._checkPositionValidity(position);
|
||||
this._updateDomNode();
|
||||
this._editor.layoutContentWidget(this);
|
||||
}
|
||||
|
||||
|
@ -89,12 +89,12 @@ class CursorWidget {
|
|||
this._onDidChangeModelContentDisposable.dispose();
|
||||
}
|
||||
|
||||
__checkPositionValidity(position) {
|
||||
_checkPositionValidity(position) {
|
||||
const validPosition = this._editor.getModel().validatePosition(position);
|
||||
return position.equals(validPosition);
|
||||
}
|
||||
|
||||
__buildDomNode(hexColor, label) {
|
||||
_buildDomNode(hexColor, label) {
|
||||
const lineHeight = this._editor.getOption(
|
||||
monaco.editor.EditorOption.lineHeight
|
||||
);
|
||||
|
@ -117,10 +117,10 @@ class CursorWidget {
|
|||
node.appendChild(labelNode);
|
||||
|
||||
this._domNode = node;
|
||||
this.__updateDomNode();
|
||||
this._updateDomNode();
|
||||
}
|
||||
|
||||
__updateDomNode() {
|
||||
_updateDomNode() {
|
||||
const isFirstLine = this._position.lineNumber === 1;
|
||||
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 = {
|
||||
mounted() {
|
||||
this.__focus();
|
||||
this.focus();
|
||||
},
|
||||
|
||||
updated() {
|
||||
if (this.el !== document.activeElement) {
|
||||
this.__focus();
|
||||
this.focus();
|
||||
}
|
||||
},
|
||||
|
||||
__focus() {
|
||||
focus() {
|
||||
if (isEditableElement(document.activeElement)) {
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
mounted() {
|
||||
this.props = getProps(this);
|
||||
this.props = this.getProps();
|
||||
|
||||
const markdown = new Markdown(this.el, "");
|
||||
|
||||
this.handleEvent(
|
||||
`markdown-renderer:${this.props.id}:content`,
|
||||
`markdown_renderer:${this.props.id}:content`,
|
||||
({ content }) => {
|
||||
markdown.setContent(content);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.props = getProps(this);
|
||||
getProps() {
|
||||
return {
|
||||
id: getAttributeOrThrow(this.el, "data-id"),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function getProps(hook) {
|
||||
return {
|
||||
id: getAttributeOrThrow(hook.el, "data-id"),
|
||||
};
|
||||
}
|
||||
|
||||
export default MarkdownRenderer;
|
|
@ -1,17 +1,17 @@
|
|||
/**
|
||||
* A hook used to scroll to the bottom of an element
|
||||
* whenever it receives LV update.
|
||||
* A hook used to scroll to the bottom of an element whenever it
|
||||
* receives LV update.
|
||||
*/
|
||||
const ScrollOnUpdate = {
|
||||
mounted() {
|
||||
this.__scroll();
|
||||
this.scroll();
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.__scroll();
|
||||
this.scroll();
|
||||
},
|
||||
|
||||
__scroll() {
|
||||
scroll() {
|
||||
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.
|
||||
*
|
||||
* On submit this hook saves the new data into cookie.
|
||||
* This cookie serves as a backup and can be used to restore
|
||||
* user data if the server is restarted.
|
||||
* On submit this hook saves the new data into cookie. This cookie
|
||||
* serves as a backup and can be used to restore user data if the
|
||||
* server is restarted.
|
||||
*/
|
||||
const UserForm = {
|
||||
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])) {
|
||||
this.ops.pop();
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ import { visit } from "unist-util-visit";
|
|||
import { toText } from "hast-util-to-text";
|
||||
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 { escapeHtml } from "../lib/utils";
|
||||
|
||||
|
@ -29,16 +29,16 @@ class Markdown {
|
|||
this.baseUrl = baseUrl;
|
||||
this.emptyText = emptyText;
|
||||
|
||||
this.__render();
|
||||
this._render();
|
||||
}
|
||||
|
||||
setContent(content) {
|
||||
this.content = content;
|
||||
this.__render();
|
||||
this._render();
|
||||
}
|
||||
|
||||
__render() {
|
||||
this.__getHtml().then((html) => {
|
||||
_render() {
|
||||
this._getHtml().then((html) => {
|
||||
// Wrap the HTML in another element, so that we
|
||||
// can use morphdom's childrenOnly option
|
||||
const wrappedHtml = `<div>${html}</div>`;
|
||||
|
@ -46,7 +46,7 @@ class Markdown {
|
|||
});
|
||||
}
|
||||
|
||||
__getHtml() {
|
||||
_getHtml() {
|
||||
return (
|
||||
unified()
|
||||
.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.
|
||||
*/
|
||||
|
|
|
@ -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
|
||||
|
||||
@doc """
|
||||
Renders a wrapper around password input
|
||||
with an added visibility toggle button.
|
||||
Renders a wrapper around password input with an added visibility
|
||||
toggle button.
|
||||
|
||||
The toggle switches the input's type between `password`
|
||||
and `text`.
|
||||
The toggle switches the input's type between `password` and `text`.
|
||||
|
||||
## Examples
|
||||
|
||||
<.with_password_toggle id="input-id">
|
||||
<.with_password_toggle id="secret-password-toggle">
|
||||
<input type="password" ...>
|
||||
</.with_password_toggle>
|
||||
"""
|
||||
def with_password_toggle(assigns) do
|
||||
~H"""
|
||||
<div id={"password-toggle-#{@id}"} class="relative inline w-min" phx-hook="PasswordToggle">
|
||||
<!-- render password input -->
|
||||
<div id={@id} class="relative flex">
|
||||
<%= render_slot(@inner_block) %>
|
||||
<button
|
||||
class="bg-gray-50 p-1 icon-button absolute inset-y-0 right-1"
|
||||
type="button"
|
||||
aria-label="toggle password visibility"
|
||||
phx-change="ignore">
|
||||
<.remix_icon icon="eye-line" class="text-xl" />
|
||||
</button>
|
||||
<div class="flex items-center absolute inset-y-0 right-1">
|
||||
<button class="icon-button"
|
||||
data-show
|
||||
type="button"
|
||||
aria-label="show password"
|
||||
phx-click={
|
||||
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>
|
||||
"""
|
||||
end
|
||||
|
|
|
@ -21,11 +21,12 @@ defmodule LivebookWeb.HomeLive.ImportFileUploadComponent do
|
|||
phx-change="validate"
|
||||
phx-drop-target={@uploads.notebook.ref}
|
||||
phx-target={@myself}
|
||||
phx-hook="DragAndDrop"
|
||||
class="flex flex-col items-start"
|
||||
>
|
||||
<%= 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 %>
|
||||
<span name="placeholder" class="font-medium text-gray-400">Drop your notebook here</span>
|
||||
<% else %>
|
||||
|
@ -71,6 +72,8 @@ defmodule LivebookWeb.HomeLive.ImportFileUploadComponent do
|
|||
content = File.read!(path)
|
||||
|
||||
send(self(), {:import_content, content, []})
|
||||
|
||||
{:ok, :ok}
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
|
|
|
@ -6,7 +6,7 @@ defmodule LivebookWeb.Output.MarkdownComponent do
|
|||
socket = assign(socket, assigns)
|
||||
|
||||
{:ok,
|
||||
push_event(socket, "markdown-renderer:#{socket.assigns.id}:content", %{
|
||||
push_event(socket, "markdown_renderer:#{socket.assigns.id}:content", %{
|
||||
content: socket.assigns.content
|
||||
})}
|
||||
end
|
||||
|
|
|
@ -1459,7 +1459,6 @@ defmodule LivebookWeb.SessionLive do
|
|||
status: eval_info.status,
|
||||
evaluation_time_ms: eval_info.evaluation_time_ms,
|
||||
evaluation_start: eval_info.evaluation_start,
|
||||
evaluation_number: eval_info.evaluation_number,
|
||||
evaluation_digest: encode_digest(eval_info.evaluation_digest),
|
||||
outputs_batch_number: eval_info.outputs_batch_number,
|
||||
# Pass input values relevant to the given cell
|
||||
|
|
|
@ -404,7 +404,7 @@ defmodule LivebookWeb.SessionLive.CellComponent do
|
|||
~H"""
|
||||
<.status_indicator circle_class="bg-blue-500" animated_circle_class="bg-blue-400" change_indicator={true}>
|
||||
<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-update="ignore"
|
||||
data-start={DateTime.to_iso8601(@cell_view.eval.evaluation_start)}>
|
||||
|
|
|
@ -46,13 +46,13 @@ defmodule LivebookWeb.SettingsLive.AddFileSystemComponent do
|
|||
</div>
|
||||
<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" %>
|
||||
</.with_password_toggle>
|
||||
</div>
|
||||
<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" %>
|
||||
</.with_password_toggle>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue