From b3b79afed421b25230f7ed0127776dfe902e6f0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 16 Mar 2022 11:33:53 +0100 Subject: [PATCH] Refactor JS hooks (#1055) * Restructure hook files * Simplify app.js * Refactor hooks * Implement password toggle with JS commands --- assets/css/js_interop.css | 4 + assets/js/app.js | 148 +-- assets/js/cell/index.js | 348 ----- assets/js/{morphdom_callbacks.js => dom.js} | 4 +- assets/js/drag_and_drop/index.js | 25 - assets/js/events.js | 83 ++ assets/js/headline/index.js | 136 -- assets/js/highlight/index.js | 67 - assets/js/hooks/cell.js | 350 +++++ .../index.js => hooks/cell_editor.js} | 18 +- .../js/{ => hooks}/cell_editor/live_editor.js | 34 +- .../cell_editor/live_editor/editor_client.js | 18 +- .../on_type_formatting_edit_provider.js | 0 .../live_editor/hook_server_adapter.js | 2 +- .../cell_editor/live_editor/monaco.js | 16 +- .../live_editor/monaco_editor_adapter.js | 10 +- .../cell_editor/live_editor/remote_user.js | 18 +- .../cell_editor/live_editor/theme.js | 0 .../index.js => hooks/confirm_modal.js} | 0 assets/js/hooks/dropzone.js | 24 + .../index.js => hooks/editor_settings.js} | 0 .../index.js => hooks/focus_on_update.js} | 6 +- assets/js/hooks/headline.js | 139 ++ assets/js/hooks/highlight.js | 50 + assets/js/hooks/index.js | 35 + assets/js/hooks/js_view.js | 316 +++++ assets/js/hooks/js_view/channel.js | 93 ++ assets/js/hooks/js_view/iframe.js | 65 + assets/js/hooks/keyboard_control.js | 96 ++ .../index.js => hooks/markdown_renderer.js} | 23 +- .../index.js => hooks/scroll_on_update.js} | 10 +- assets/js/hooks/session.js | 1114 ++++++++++++++++ assets/js/hooks/timer.js | 42 + .../index.js => hooks/user_form.js} | 6 +- assets/js/hooks/virtualized_lines.js | 94 ++ assets/js/js_view/index.js | 455 ------- assets/js/keyboard_control/index.js | 105 -- assets/js/lib/delta.js | 6 +- assets/js/{session => lib}/key_buffer.js | 0 assets/js/lib/markdown.js | 12 +- assets/js/lib/utils.js | 8 + assets/js/password_toggle/index.js | 38 - assets/js/session/index.js | 1150 ----------------- assets/js/timer/index.js | 41 - assets/js/virtualized_lines/index.js | 121 -- lib/livebook_web/helpers.ex | 45 +- .../home_live/import_file_upload_component.ex | 7 +- .../live/output/markdown_component.ex | 2 +- lib/livebook_web/live/session_live.ex | 1 - .../live/session_live/cell_component.ex | 2 +- .../add_file_system_component.ex | 4 +- 51 files changed, 2660 insertions(+), 2731 deletions(-) delete mode 100644 assets/js/cell/index.js rename assets/js/{morphdom_callbacks.js => dom.js} (94%) delete mode 100644 assets/js/drag_and_drop/index.js create mode 100644 assets/js/events.js delete mode 100644 assets/js/headline/index.js delete mode 100644 assets/js/highlight/index.js create mode 100644 assets/js/hooks/cell.js rename assets/js/{cell_editor/index.js => hooks/cell_editor.js} (84%) rename assets/js/{ => hooks}/cell_editor/live_editor.js (94%) rename assets/js/{ => hooks}/cell_editor/live_editor/editor_client.js (95%) rename assets/js/{ => hooks}/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js (100%) rename assets/js/{ => hooks}/cell_editor/live_editor/hook_server_adapter.js (97%) rename assets/js/{ => hooks}/cell_editor/live_editor/monaco.js (89%) rename assets/js/{ => hooks}/cell_editor/live_editor/monaco_editor_adapter.js (92%) rename assets/js/{ => hooks}/cell_editor/live_editor/remote_user.js (91%) rename assets/js/{ => hooks}/cell_editor/live_editor/theme.js (100%) rename assets/js/{confirm_modal/index.js => hooks/confirm_modal.js} (100%) create mode 100644 assets/js/hooks/dropzone.js rename assets/js/{editor_settings/index.js => hooks/editor_settings.js} (100%) rename assets/js/{focus_on_update/index.js => hooks/focus_on_update.js} (89%) create mode 100644 assets/js/hooks/headline.js create mode 100644 assets/js/hooks/highlight.js create mode 100644 assets/js/hooks/index.js create mode 100644 assets/js/hooks/js_view.js create mode 100644 assets/js/hooks/js_view/channel.js create mode 100644 assets/js/hooks/js_view/iframe.js create mode 100644 assets/js/hooks/keyboard_control.js rename assets/js/{markdown_renderer/index.js => hooks/markdown_renderer.js} (56%) rename assets/js/{scroll_on_update/index.js => hooks/scroll_on_update.js} (52%) create mode 100644 assets/js/hooks/session.js create mode 100644 assets/js/hooks/timer.js rename assets/js/{user_form/index.js => hooks/user_form.js} (70%) create mode 100644 assets/js/hooks/virtualized_lines.js delete mode 100644 assets/js/js_view/index.js delete mode 100644 assets/js/keyboard_control/index.js rename assets/js/{session => lib}/key_buffer.js (100%) delete mode 100644 assets/js/password_toggle/index.js delete mode 100644 assets/js/session/index.js delete mode 100644 assets/js/timer/index.js delete mode 100644 assets/js/virtualized_lines/index.js diff --git a/assets/css/js_interop.css b/assets/css/js_interop.css index ef2ba54a7..4bbc11119 100644 --- a/assets/css/js_interop.css +++ b/assets/css/js_interop.css @@ -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]) diff --git a/assets/js/app.js b/assets/js/app.js index 16f925ed3..2b5a4df5c 100644 --- a/assets/js/app.js +++ b/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; diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js deleted file mode 100644 index c94be37b6..000000000 --- a/assets/js/cell/index.js +++ /dev/null @@ -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; diff --git a/assets/js/morphdom_callbacks.js b/assets/js/dom.js similarity index 94% rename from assets/js/morphdom_callbacks.js rename to assets/js/dom.js index ddbf76916..30cc1004c 100644 --- a/assets/js/morphdom_callbacks.js +++ b/assets/js/dom.js @@ -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; diff --git a/assets/js/drag_and_drop/index.js b/assets/js/drag_and_drop/index.js deleted file mode 100644 index 9ef03b432..000000000 --- a/assets/js/drag_and_drop/index.js +++ /dev/null @@ -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; diff --git a/assets/js/events.js b/assets/js/events.js new file mode 100644 index 000000000..274de9ce0 --- /dev/null +++ b/assets/js/events.js @@ -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 })); + } + }); +} diff --git a/assets/js/headline/index.js b/assets/js/headline/index.js deleted file mode 100644 index b7a64d503..000000000 --- a/assets/js/headline/index.js +++ /dev/null @@ -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; diff --git a/assets/js/highlight/index.js b/assets/js/highlight/index.js deleted file mode 100644 index 7392f72d1..000000000 --- a/assets/js/highlight/index.js +++ /dev/null @@ -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; diff --git a/assets/js/hooks/cell.js b/assets/js/hooks/cell.js new file mode 100644 index 000000000..53c458f90 --- /dev/null +++ b/assets/js/hooks/cell.js @@ -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; diff --git a/assets/js/cell_editor/index.js b/assets/js/hooks/cell_editor.js similarity index 84% rename from assets/js/cell_editor/index.js rename to assets/js/hooks/cell_editor.js index 0e4af0e7f..265eaadd8 100644 --- a/assets/js/cell_editor/index.js +++ b/assets/js/hooks/cell_editor.js @@ -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; diff --git a/assets/js/cell_editor/live_editor.js b/assets/js/hooks/cell_editor/live_editor.js similarity index 94% rename from assets/js/cell_editor/live_editor.js rename to assets/js/hooks/cell_editor/live_editor.js index 9f35f4569..2d8fbef0d 100644 --- a/assets/js/cell_editor/live_editor.js +++ b/assets/js/hooks/cell_editor/live_editor.js @@ -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", diff --git a/assets/js/cell_editor/live_editor/editor_client.js b/assets/js/hooks/cell_editor/live_editor/editor_client.js similarity index 95% rename from assets/js/cell_editor/live_editor/editor_client.js rename to assets/js/hooks/cell_editor/live_editor/editor_client.js index b55260c4f..6f10088a1 100644 --- a/assets/js/cell_editor/live_editor/editor_client.js +++ b/assets/js/hooks/cell_editor/live_editor/editor_client.js @@ -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) { diff --git a/assets/js/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js b/assets/js/hooks/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js similarity index 100% rename from assets/js/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js rename to assets/js/hooks/cell_editor/live_editor/elixir/on_type_formatting_edit_provider.js diff --git a/assets/js/cell_editor/live_editor/hook_server_adapter.js b/assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js similarity index 97% rename from assets/js/cell_editor/live_editor/hook_server_adapter.js rename to assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js index e94f658f8..a05e012a9 100644 --- a/assets/js/cell_editor/live_editor/hook_server_adapter.js +++ b/assets/js/hooks/cell_editor/live_editor/hook_server_adapter.js @@ -1,4 +1,4 @@ -import Delta from "../../lib/delta"; +import Delta from "../../../lib/delta"; /** * Encapsulates logic related to sending/receiving messages from the server. diff --git a/assets/js/cell_editor/live_editor/monaco.js b/assets/js/hooks/cell_editor/live_editor/monaco.js similarity index 89% rename from assets/js/cell_editor/live_editor/monaco.js rename to assets/js/hooks/cell_editor/live_editor/monaco.js index 127e8dfa0..7691a6540 100644 --- a/assets/js/cell_editor/live_editor/monaco.js +++ b/assets/js/hooks/cell_editor/live_editor/monaco.js @@ -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; } diff --git a/assets/js/cell_editor/live_editor/monaco_editor_adapter.js b/assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js similarity index 92% rename from assets/js/cell_editor/live_editor/monaco_editor_adapter.js rename to assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js index 0d73012d3..8e4474ed6 100644 --- a/assets/js/cell_editor/live_editor/monaco_editor_adapter.js +++ b/assets/js/hooks/cell_editor/live_editor/monaco_editor_adapter.js @@ -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 = []; diff --git a/assets/js/cell_editor/live_editor/remote_user.js b/assets/js/hooks/cell_editor/live_editor/remote_user.js similarity index 91% rename from assets/js/cell_editor/live_editor/remote_user.js rename to assets/js/hooks/cell_editor/live_editor/remote_user.js index 688a394f9..457c767ce 100644 --- a/assets/js/cell_editor/live_editor/remote_user.js +++ b/assets/js/hooks/cell_editor/live_editor/remote_user.js @@ -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); } diff --git a/assets/js/cell_editor/live_editor/theme.js b/assets/js/hooks/cell_editor/live_editor/theme.js similarity index 100% rename from assets/js/cell_editor/live_editor/theme.js rename to assets/js/hooks/cell_editor/live_editor/theme.js diff --git a/assets/js/confirm_modal/index.js b/assets/js/hooks/confirm_modal.js similarity index 100% rename from assets/js/confirm_modal/index.js rename to assets/js/hooks/confirm_modal.js diff --git a/assets/js/hooks/dropzone.js b/assets/js/hooks/dropzone.js new file mode 100644 index 000000000..899da9851 --- /dev/null +++ b/assets/js/hooks/dropzone.js @@ -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; diff --git a/assets/js/editor_settings/index.js b/assets/js/hooks/editor_settings.js similarity index 100% rename from assets/js/editor_settings/index.js rename to assets/js/hooks/editor_settings.js diff --git a/assets/js/focus_on_update/index.js b/assets/js/hooks/focus_on_update.js similarity index 89% rename from assets/js/focus_on_update/index.js rename to assets/js/hooks/focus_on_update.js index 61f5ce85e..027e8a648 100644 --- a/assets/js/focus_on_update/index.js +++ b/assets/js/hooks/focus_on_update.js @@ -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; } diff --git a/assets/js/hooks/headline.js b/assets/js/hooks/headline.js new file mode 100644 index 000000000..d519b7fca --- /dev/null +++ b/assets/js/hooks/headline.js @@ -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; diff --git a/assets/js/hooks/highlight.js b/assets/js/hooks/highlight.js new file mode 100644 index 000000000..96f5c5014 --- /dev/null +++ b/assets/js/hooks/highlight.js @@ -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; diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js new file mode 100644 index 000000000..2f11c5607 --- /dev/null +++ b/assets/js/hooks/index.js @@ -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, +}; diff --git a/assets/js/hooks/js_view.js b/assets/js/hooks/js_view.js new file mode 100644 index 000000000..43c73109b --- /dev/null +++ b/assets/js/hooks/js_view.js @@ -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:` message + * with `{ data }` payload, the data is then used in the initial call + * to the custom JS module. + * + * Then, a number of `event:` 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; diff --git a/assets/js/hooks/js_view/channel.js b/assets/js/hooks/js_view/channel.js new file mode 100644 index 000000000..e56599fdb --- /dev/null +++ b/assets/js/hooks/js_view/channel.js @@ -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]; +} diff --git a/assets/js/hooks/js_view/iframe.js b/assets/js/hooks/js_view/iframe.js new file mode 100644 index 000000000..dadceb76a --- /dev/null +++ b/assets/js/hooks/js_view/iframe.js @@ -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; +} diff --git a/assets/js/hooks/keyboard_control.js b/assets/js/hooks/keyboard_control.js new file mode 100644 index 000000000..e796e73a4 --- /dev/null +++ b/assets/js/hooks/keyboard_control.js @@ -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; diff --git a/assets/js/markdown_renderer/index.js b/assets/js/hooks/markdown_renderer.js similarity index 56% rename from assets/js/markdown_renderer/index.js rename to assets/js/hooks/markdown_renderer.js index 4b51a242d..3434523da 100644 --- a/assets/js/markdown_renderer/index.js +++ b/assets/js/hooks/markdown_renderer.js @@ -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; diff --git a/assets/js/scroll_on_update/index.js b/assets/js/hooks/scroll_on_update.js similarity index 52% rename from assets/js/scroll_on_update/index.js rename to assets/js/hooks/scroll_on_update.js index a5554813f..c9bea18fb 100644 --- a/assets/js/scroll_on_update/index.js +++ b/assets/js/hooks/scroll_on_update.js @@ -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; }, }; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js new file mode 100644 index 000000000..f19f28d6d --- /dev/null +++ b/assets/js/hooks/session.js @@ -0,0 +1,1114 @@ +import { + isMacOS, + isEditableElement, + clamp, + selectElementContent, + smoothlyScrollToElement, + setFavicon, + cancelEvent, + isElementInViewport, +} from "../lib/utils"; +import { getAttributeOrDefault } from "../lib/attribute"; +import KeyBuffer from "../lib/key_buffer"; +import { globalPubSub } from "../lib/pub_sub"; +import monaco from "./cell_editor/live_editor/monaco"; +import { leaveChannel } from "./js_view/channel"; +import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; + +/** + * A hook managing the whole session. + * + * Serves as a coordinator handling all the global session events. + * Note that each cell has its own hook, so that LV keeps track of + * cells being added/removed from the DOM. We do however need to + * communicate between this global hook and cells and for that we + * use a simple local pubsub that the hooks subscribe to. + * + * ## Configuration + * + * * `data-autofocus-cell-id` - id of the cell that gets initial + * focus once the notebook is loaded + * + * ## Shortcuts + * + * This hook registers session shortcut handlers, + * see `LivebookWeb.SessionLive.ShortcutsComponent` + * for the complete list of available shortcuts. + * + * ## Navigation + * + * This hook handles focusing section titles, cells and moving the + * focus around, this is done purely on the client side because it is + * event-intensive and specific to this client only. The UI changes + * are handled by setting `data-js-*` attributes and applying CSS + * accordingly (see assets/css/js_interop.css). Navigation changes + * are also broadcasted to all cell hooks via PubSub. + * + * ## Location tracking and following + * + * Location describes where the given client is within the notebook + * (in which cell, and where specifically in that cell). When multiple + * clients are connected, they report own location to each other + * whenever it changes. We then each the location to show cursor and + * selection indicators. + * + * Additionally the current user may follow another client from the + * clients list. In such case, whenever a new location comes from that + * client we move there automatically (i.e. we focus the same cells + * to effectively mimic how the followed client moves around). + * + * Initially we load basic information about connected clients using + * the `"session_init"` event and then update this information whenever + * clients join/leave/update. This way location reports include only + * client pid, as we already have the necessary hex_color/name locally. + */ +const Session = { + mounted() { + this.props = this.getProps(); + + this.focusedId = null; + this.insertMode = false; + this.keyBuffer = new KeyBuffer(); + this.clientsMap = {}; + this.lastLocationReportByClientPid = {}; + this.followedClientPid = null; + + setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus)); + + this.updateSectionListHighlight(); + + // DOM events + + this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); + this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this); + this._handleDocumentMouseDown = this.handleDocumentMouseDown.bind(this); + this._handleDocumentFocus = this.handleDocumentFocus.bind(this); + this._handleDocumentClick = this.handleDocumentClick.bind(this); + this._handleDocumentDoubleClick = this.handleDocumentDoubleClick.bind(this); + + document.addEventListener("keydown", this._handleDocumentKeyDown, true); + document.addEventListener("mousedown", this._handleDocumentMouseDown); + // Note: the focus event doesn't bubble, so we register for the capture phase + document.addEventListener("focus", this._handleDocumentFocus, true); + document.addEventListener("click", this._handleDocumentClick); + document.addEventListener("dblclick", this._handleDocumentDoubleClick); + + this.getElement("sections-list").addEventListener("click", (event) => { + this.handleSectionsListClick(event); + this.handleCellIndicatorsClick(event); + }); + + this.getElement("clients-list").addEventListener("click", (event) => + this.handleClientsListClick(event) + ); + + this.getElement("sections-list-toggle").addEventListener("click", (event) => + this.toggleSectionsList() + ); + + this.getElement("clients-list-toggle").addEventListener("click", (event) => + this.toggleClientsList() + ); + + this.getElement("runtime-info-toggle").addEventListener("click", (event) => + this.toggleRuntimeInfo() + ); + + this.getElement("notebook").addEventListener("scroll", (event) => + this.updateSectionListHighlight() + ); + + this.getElement("notebook-indicators").addEventListener("click", (event) => + this.handleCellIndicatorsClick(event) + ); + + window.addEventListener( + "phx:page-loading-stop", + () => { + this.initializeFocus(); + }, + { once: true } + ); + + // Server events + + this.handleEvent("session_init", ({ clients }) => { + clients.forEach((client) => { + this.clientsMap[client.pid] = client; + }); + }); + + this.handleEvent("cell_inserted", ({ cell_id: cellId }) => { + this.handleCellInserted(cellId); + }); + + this.handleEvent( + "cell_deleted", + ({ cell_id: cellId, sibling_cell_id: siblingCellId }) => { + this.handleCellDeleted(cellId, siblingCellId); + } + ); + + this.handleEvent("cell_restored", ({ cell_id: cellId }) => { + this.handleCellRestored(cellId); + }); + + this.handleEvent("cell_moved", ({ cell_id }) => { + this.handleCellMoved(cell_id); + }); + + this.handleEvent("section_inserted", ({ section_id }) => { + this.handleSectionInserted(section_id); + }); + + this.handleEvent("section_deleted", ({ section_id }) => { + this.handleSectionDeleted(section_id); + }); + + this.handleEvent("section_moved", ({ section_id }) => { + this.handleSectionMoved(section_id); + }); + + this.handleEvent("cell_upload", ({ cell_id, url }) => { + this.handleCellUpload(cell_id, url); + }); + + this.handleEvent("client_joined", ({ client }) => { + this.handleClientJoined(client); + }); + + this.handleEvent("client_left", ({ client_pid }) => { + this.handleClientLeft(client_pid); + }); + + this.handleEvent("clients_updated", ({ clients }) => { + this.handleClientsUpdated(clients); + }); + + this.handleEvent( + "location_report", + ({ client_pid, focusable_id, selection }) => { + const report = { + focusableId: focusable_id, + selection: this.decodeSelection(selection), + }; + + this.handleLocationReport(client_pid, report); + } + ); + + this.unsubscribeFromSessionEvents = globalPubSub.subscribe( + "session", + (event) => { + this.handleSessionEvent(event); + } + ); + }, + + updated() { + const prevProps = this.props; + this.props = this.getProps(); + + if (this.props.globalStatus !== prevProps.globalStatus) { + setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus)); + } + }, + + disconnected() { + // Reinitialize on reconnection + this.el.removeAttribute("id"); + }, + + destroyed() { + this.unsubscribeFromSessionEvents(); + + document.removeEventListener("keydown", this._handleDocumentKeyDown, true); + document.removeEventListener("mousedown", this._handleDocumentMouseDown); + document.removeEventListener("focus", this._handleDocumentFocus, true); + document.removeEventListener("click", this._handleDocumentClick); + document.removeEventListener("dblclick", this._handleDocumentDoubleClick); + + setFavicon("favicon"); + + leaveChannel(); + }, + + getProps() { + return { + autofocusCellId: getAttributeOrDefault( + this.el, + "data-autofocus-cell-id", + null + ), + globalStatus: getAttributeOrDefault(this.el, "data-global-status", null), + }; + }, + + faviconForEvaluationStatus(evaluationStatus) { + if (evaluationStatus === "evaluating") return "favicon-evaluating"; + if (evaluationStatus === "stale") return "favicon-stale"; + return "favicon"; + }, + + // DOM event handlers + + /** + * Handles session keybindings. + * + * Make sure to keep the shortcuts help modal up to date. + */ + handleDocumentKeyDown(event) { + if (event.repeat) { + return; + } + + const cmd = isMacOS() ? event.metaKey : event.ctrlKey; + const alt = event.altKey; + const shift = event.shiftKey; + const key = event.key; + const keyBuffer = this.keyBuffer; + + if (this.insertMode) { + keyBuffer.reset(); + + if (key === "Escape") { + // Ignore Escape if it's supposed to close an editor widget + if (!this.escapesMonacoWidget(event)) { + this.escapeInsertMode(); + } + } else if (cmd && shift && !alt && key === "Enter") { + cancelEvent(event); + this.queueFullCellsEvaluation(true); + } else if (cmd && !alt && key === "Enter") { + cancelEvent(event); + if (isEvaluable(this.focusedCellType())) { + this.queueFocusedCellEvaluation(); + } + } else if (cmd && key === "s") { + cancelEvent(event); + this.saveNotebook(); + } + } else { + // Ignore keystrokes on input fields + if (isEditableElement(event.target)) { + keyBuffer.reset(); + + // Use Escape for universal blur + if (key === "Escape") { + event.target.blur(); + } + + return; + } + + keyBuffer.push(event.key); + + if (cmd && key === "s") { + cancelEvent(event); + this.saveNotebook(); + } else if (keyBuffer.tryMatch(["d", "d"])) { + this.deleteFocusedCell(); + } else if (cmd && shift && !alt && key === "Enter") { + this.queueFullCellsEvaluation(true); + } else if (keyBuffer.tryMatch(["e", "a"])) { + this.queueFullCellsEvaluation(false); + } else if ( + keyBuffer.tryMatch(["e", "e"]) || + (cmd && !alt && key === "Enter") + ) { + if (isEvaluable(this.focusedCellType())) { + this.queueFocusedCellEvaluation(); + } + } else if (keyBuffer.tryMatch(["e", "s"])) { + this.queueFocusedSectionEvaluation(); + } else if (keyBuffer.tryMatch(["s", "s"])) { + this.toggleSectionsList(); + } else if (keyBuffer.tryMatch(["s", "u"])) { + this.toggleClientsList(); + } else if (keyBuffer.tryMatch(["s", "r"])) { + this.toggleRuntimeInfo(); + } else if (keyBuffer.tryMatch(["s", "b"])) { + this.showBin(); + } else if (keyBuffer.tryMatch(["e", "x"])) { + this.cancelFocusedCellEvaluation(); + } else if (keyBuffer.tryMatch(["0", "0"])) { + this.restartRuntime(); + } else if (keyBuffer.tryMatch(["Escape", "Escape"])) { + this.setFocusedEl(null); + } else if (keyBuffer.tryMatch(["?"])) { + this.showShortcuts(); + } else if ( + keyBuffer.tryMatch(["i"]) || + (event.target === document.body && this.focusedId && key === "Enter") + ) { + cancelEvent(event); + if (this.isInsertModeAvailable()) { + this.enterInsertMode(); + } + } else if (keyBuffer.tryMatch(["j"])) { + this.moveFocus(1); + } else if (keyBuffer.tryMatch(["k"])) { + this.moveFocus(-1); + } else if (keyBuffer.tryMatch(["J"])) { + this.moveFocusedCell(1); + } else if (keyBuffer.tryMatch(["K"])) { + this.moveFocusedCell(-1); + } else if (keyBuffer.tryMatch(["n"])) { + this.insertCellBelowFocused("code"); + } else if (keyBuffer.tryMatch(["N"])) { + this.insertCellAboveFocused("code"); + } else if (keyBuffer.tryMatch(["m"])) { + this.insertCellBelowFocused("markdown"); + } else if (keyBuffer.tryMatch(["M"])) { + this.insertCellAboveFocused("markdown"); + } + } + }, + + escapesMonacoWidget(event) { + // Escape pressed in an editor input + if (event.target.closest(".monaco-inputbox")) { + return true; + } + + const editor = event.target.closest(".monaco-editor.focused"); + + if (!editor) { + return false; + } + + // Completion box open + if (editor.querySelector(".editor-widget.parameter-hints-widget.visible")) { + return true; + } + + // Signature details open + if (editor.querySelector(".editor-widget.suggest-widget.visible")) { + return true; + } + + // Multi-cursor selection enabled + if (editor.querySelectorAll(".cursor").length > 1) { + return true; + } + + return false; + }, + + /** + * Focuses/blurs a cell when the user clicks somewhere. + * + * Note: we use mousedown event to more reliably focus editor + * (e.g. if the user starts selecting some text within the editor) + */ + handleDocumentMouseDown(event) { + // If the click is outside the notebook element, keep the focus as is + if (!event.target.closest(`[data-element="notebook"]`)) { + if (this.insertMode) { + this.setInsertMode(false); + } + return; + } + + // When clicking an insert button, keep focus and insert mode as is + if (event.target.closest(`[data-element="insert-buttons"] button`)) { + return; + } + + // Find the focusable element, if one was clicked + const focusableEl = event.target.closest(`[data-focusable-id]`); + const focusableId = focusableEl ? focusableEl.dataset.focusableId : null; + const insertMode = this.editableElementClicked(event, focusableEl); + + if (focusableId !== this.focusedId) { + this.setFocusedEl(focusableId, { scroll: false, focusElement: false }); + } + + // If a cell action is clicked, keep the insert mode as is + if (event.target.closest(`[data-element="actions"]`)) { + return; + } + + // Depending on whether the click targets editor or input disable/enable insert mode + if (this.insertMode !== insertMode) { + this.setInsertMode(insertMode); + } + }, + + editableElementClicked(event, focusableEl) { + if (focusableEl) { + const editableElement = event.target.closest( + `[data-element="editor-container"], [data-element="heading"]` + ); + return editableElement && focusableEl.contains(editableElement); + } + + return false; + }, + + /** + * Focuses a focusable element if the user "tab"s anywhere into it. + */ + handleDocumentFocus(event) { + const focusableEl = + event.target.closest && event.target.closest(`[data-focusable-id]`); + + if (focusableEl) { + const focusableId = focusableEl.dataset.focusableId; + + if (focusableId !== this.focusedId) { + this.setFocusedEl(focusableId, { scroll: false, focusElement: false }); + } + } + }, + + /** + * Enters insert mode when markdown edit action is clicked. + */ + handleDocumentClick(event) { + if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) { + this.setInsertMode(true); + } + }, + + /** + * Enters insert mode when a markdown cell is double-clicked. + */ + handleDocumentDoubleClick(event) { + const markdownCell = event.target.closest( + `[data-element="cell"][data-type="markdown"]` + ); + + if (markdownCell && this.focusedId && !this.insertMode) { + this.setInsertMode(true); + } + }, + + /** + * Handles section link clicks in the section list. + */ + handleSectionsListClick(event) { + const sectionButton = event.target.closest( + `[data-element="sections-list-item"]` + ); + if (sectionButton) { + const sectionId = sectionButton.getAttribute("data-section-id"); + const section = this.getSectionById(sectionId); + section.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }, + + /** + * Handles client link clicks in the clients list. + */ + handleClientsListClick(event) { + const clientListItem = event.target.closest( + `[data-element="clients-list-item"]` + ); + + if (clientListItem) { + const clientPid = clientListItem.getAttribute("data-client-pid"); + + const clientLink = event.target.closest(`[data-element="client-link"]`); + if (clientLink) { + this.handleClientLinkClick(clientPid); + } + + const clientFollowToggle = event.target.closest( + `[data-element="client-follow-toggle"]` + ); + if (clientFollowToggle) { + this.handleClientFollowToggleClick(clientPid, clientListItem); + } + } + }, + + handleClientLinkClick(clientPid) { + this.mirrorClientFocus(clientPid); + }, + + handleClientFollowToggleClick(clientPid, clientListItem) { + const followedClientListItem = this.el.querySelector( + `[data-element="clients-list-item"][data-js-followed]` + ); + + if (followedClientListItem) { + followedClientListItem.removeAttribute("data-js-followed"); + } + + if (clientPid === this.followedClientPid) { + this.followedClientPid = null; + } else { + clientListItem.setAttribute("data-js-followed", ""); + this.followedClientPid = clientPid; + this.mirrorClientFocus(clientPid); + } + }, + + mirrorClientFocus(clientPid) { + const locationReport = this.lastLocationReportByClientPid[clientPid]; + + if (locationReport && locationReport.focusableId) { + this.setFocusedEl(locationReport.focusableId); + } + }, + + /** + * Handles button clicks within cell indicators section. + */ + handleCellIndicatorsClick(event) { + const button = event.target.closest(`[data-element="focus-cell-button"]`); + if (button) { + const cellId = button.getAttribute("data-target"); + this.setFocusedEl(cellId); + } + }, + + /** + * Focuses cell or any other element based on the current + * URL and hook attributes. + */ + initializeFocus() { + const hash = window.location.hash; + + if (hash) { + const htmlId = hash.replace(/^#/, ""); + const element = document.getElementById(htmlId); + + if (element) { + const focusableEl = element.closest("[data-focusable-id]"); + + if (focusableEl) { + this.setFocusedEl(focusableEl.dataset.focusableId); + } else { + // Explicitly scroll to the target element + // after the loading finishes + element.scrollIntoView(); + } + } + } else if (this.props.autofocusCellId) { + this.setFocusedEl(this.props.autofocusCellId, { scroll: false }); + this.setInsertMode(true); + } + }, + + /** + * Handles the main notebook area being scrolled. + */ + updateSectionListHighlight() { + const currentListItem = this.el.querySelector( + `[data-element="sections-list-item"][data-js-is-viewed]` + ); + + if (currentListItem) { + currentListItem.removeAttribute("data-js-is-viewed"); + } + + // Consider a section being viewed if it is within the top 35% of the screen + const viewedSection = this.getSections() + .reverse() + .find((section) => { + const { top } = section.getBoundingClientRect(); + const scrollTop = document.documentElement.scrollTop; + return top <= scrollTop + window.innerHeight * 0.35; + }); + + if (viewedSection) { + const sectionId = viewedSection.getAttribute("data-section-id"); + const listItem = this.el.querySelector( + `[data-element="sections-list-item"][data-section-id="${sectionId}"]` + ); + listItem.setAttribute("data-js-is-viewed", ""); + } + }, + + // User action handlers (mostly keybindings) + + toggleSectionsList() { + this.toggleSidePanelContent("sections-list"); + }, + + toggleClientsList() { + this.toggleSidePanelContent("clients-list"); + }, + + toggleRuntimeInfo() { + this.toggleSidePanelContent("runtime-info"); + }, + + toggleSidePanelContent(name) { + if (this.el.getAttribute("data-js-side-panel-content") === name) { + this.el.removeAttribute("data-js-side-panel-content"); + } else { + this.el.setAttribute("data-js-side-panel-content", name); + } + }, + + showBin() { + this.pushEvent("show_bin", {}); + }, + + saveNotebook() { + this.pushEvent("save", {}); + }, + + deleteFocusedCell() { + if (this.focusedId && this.isCell(this.focusedId)) { + this.pushEvent("delete_cell", { cell_id: this.focusedId }); + } + }, + + queueFocusedCellEvaluation() { + if (this.focusedId && this.isCell(this.focusedId)) { + this.pushEvent("queue_cell_evaluation", { + cell_id: this.focusedId, + }); + } + }, + + queueFullCellsEvaluation(includeFocused) { + const forcedCellIds = + includeFocused && this.focusedId && this.isCell(this.focusedId) + ? [this.focusedId] + : []; + + this.pushEvent("queue_full_evaluation", { + forced_cell_ids: forcedCellIds, + }); + }, + + queueFocusedSectionEvaluation() { + if (this.focusedId) { + const sectionId = this.getSectionIdByFocusableId(this.focusedId); + + if (sectionId) { + this.pushEvent("queue_section_evaluation", { + section_id: sectionId, + }); + } + } + }, + + cancelFocusedCellEvaluation() { + if (this.focusedId && this.isCell(this.focusedId)) { + this.pushEvent("cancel_cell_evaluation", { + cell_id: this.focusedId, + }); + } + }, + + restartRuntime() { + this.pushEvent("restart_runtime", {}); + }, + + showShortcuts() { + this.pushEvent("show_shortcuts", {}); + }, + + isInsertModeAvailable() { + if (!this.focusedId) { + return false; + } + + const el = this.getFocusableEl(this.focusedId); + + return ( + !this.isCell(this.focusedId) || + !el.hasAttribute("data-js-insert-mode-disabled") + ); + }, + + enterInsertMode() { + if (this.focusedId) { + this.setInsertMode(true); + } + }, + + escapeInsertMode() { + this.setInsertMode(false); + }, + + moveFocus(offset) { + const focusableId = this.nearbyFocusableId(this.focusedId, offset); + this.setFocusedEl(focusableId); + }, + + moveFocusedCell(offset) { + if (this.focusedId && this.isCell(this.focusedId)) { + this.pushEvent("move_cell", { cell_id: this.focusedId, offset }); + } + }, + + insertCellBelowFocused(type) { + if (this.focusedId) { + this.insertCellBelowFocusableId(this.focusedId, type); + } else { + const focusableIds = this.getFocusableIds(); + if (focusableIds.length > 0) { + this.insertCellBelowFocusableId( + focusableIds[focusableIds.length - 1], + type + ); + } + } + }, + + insertCellAboveFocused(type) { + if (this.focusedId) { + const prevFocusableId = this.nearbyFocusableId(this.focusedId, -1); + this.insertCellBelowFocusableId(prevFocusableId, type); + } else { + const focusableIds = this.getFocusableIds(); + if (focusableIds.length > 0) { + this.insertCellBelowFocusableId(focusableIds[0], type); + } + } + }, + + insertCellBelowFocusableId(focusableId, type) { + if (this.isCell(focusableId)) { + this.pushEvent("insert_cell_below", { type, cell_id: focusableId }); + } else if (this.isSection(focusableId)) { + this.pushEvent("insert_cell_below", { type, section_id: focusableId }); + } else if (this.isNotebook(focusableId)) { + const sectionIds = this.getSectionIds(); + if (sectionIds.length > 0) { + this.pushEvent("insert_cell_below", { + type, + section_id: sectionIds[0], + }); + } + } + }, + + setFocusedEl(focusableId, { scroll = true, focusElement = true } = {}) { + this.focusedId = focusableId; + + if (focusableId) { + const el = this.getFocusableEl(focusableId); + + if (focusElement) { + // Focus the primary content in the focusable element, this is important for screen readers + const contentEl = + el.querySelector(`[data-element="cell-body"]`) || + el.querySelector(`[data-element="heading"]`) || + el; + contentEl.focus({ preventScroll: true }); + } + } + + globalPubSub.broadcast("navigation", { + type: "element_focused", + focusableId: focusableId, + scroll, + }); + + this.setInsertMode(false); + }, + + setInsertMode(insertModeEnabled) { + this.insertMode = insertModeEnabled; + + if (insertModeEnabled) { + this.el.setAttribute("data-js-insert-mode", ""); + } else { + this.el.removeAttribute("data-js-insert-mode"); + + this.sendLocationReport({ + focusableId: this.focusedId, + selection: null, + }); + } + + globalPubSub.broadcast("navigation", { + type: "insert_mode_changed", + enabled: insertModeEnabled, + }); + }, + + // Server event handlers + + handleCellInserted(cellId) { + this.setFocusedEl(cellId); + if (isDirectlyEditable(this.focusedCellType())) { + this.setInsertMode(true); + } + }, + + handleCellDeleted(cellId, siblingCellId) { + if (this.focusedId === cellId) { + this.setFocusedEl(siblingCellId); + } + }, + + handleCellRestored(cellId) { + this.setFocusedEl(cellId); + }, + + handleCellMoved(cellId) { + if (this.focusedId === cellId) { + globalPubSub.broadcast("cells", { type: "cell_moved", cellId }); + } + }, + + handleSectionInserted(sectionId) { + const section = this.getSectionById(sectionId); + const headlineEl = section.querySelector( + `[data-element="section-headline"]` + ); + const { focusableId } = headlineEl.dataset; + this.setFocusedEl(focusableId); + this.setInsertMode(true); + selectElementContent(document.activeElement); + }, + + handleSectionDeleted(sectionId) { + // Clear focus if the element no longer exists + if (this.focusedId && !this.getFocusableEl(this.focusedId)) { + this.setFocusedEl(null); + } + }, + + handleSectionMoved(sectionId) { + const section = this.getSectionById(sectionId); + smoothlyScrollToElement(section); + }, + + handleCellUpload(cellId, url) { + if (this.focusedId !== cellId) { + this.setFocusedEl(cellId); + } + + if (!this.insertMode) { + this.setInsertMode(true); + } + + globalPubSub.broadcast("cells", { type: "cell_upload", cellId, url }); + }, + + handleClientJoined(client) { + this.clientsMap[client.pid] = client; + }, + + handleClientLeft(clientPid) { + const client = this.clientsMap[clientPid]; + + if (client) { + delete this.clientsMap[clientPid]; + + this.broadcastLocationReport(client, { + focusableId: null, + selection: null, + }); + + if (client.pid === this.followedClientPid) { + this.followedClientPid = null; + } + } + }, + + handleClientsUpdated(updatedClients) { + updatedClients.forEach((client) => { + this.clientsMap[client.pid] = client; + }); + }, + + handleLocationReport(clientPid, report) { + const client = this.clientsMap[clientPid]; + + this.lastLocationReportByClientPid[clientPid] = report; + + if (client) { + this.broadcastLocationReport(client, report); + + if ( + client.pid === this.followedClientPid && + report.focusableId !== this.focusedId + ) { + this.setFocusedEl(report.focusableId); + } + } + }, + + // Session event handlers + + handleSessionEvent(event) { + if (event.type === "cursor_selection_changed") { + this.sendLocationReport({ + focusableId: event.focusableId, + selection: event.selection, + }); + } + }, + + /** + * Broadcast new location report coming from the server to all the cells. + */ + broadcastLocationReport(client, report) { + globalPubSub.broadcast("navigation", { + type: "location_report", + client, + report, + }); + }, + + /** + * Sends local location report to the server. + */ + sendLocationReport(report) { + const numberOfClients = Object.keys(this.clientsMap).length; + + // Only send reports if there are other people to send to + if (numberOfClients > 1) { + this.pushEvent("location_report", { + focusable_id: report.focusableId, + selection: this.encodeSelection(report.selection), + }); + } + }, + + encodeSelection(selection) { + if (selection === null) return null; + + const { tag, editorSelection } = selection; + + return [ + tag, + editorSelection.selectionStartLineNumber, + editorSelection.selectionStartColumn, + editorSelection.positionLineNumber, + editorSelection.positionColumn, + ]; + }, + + decodeSelection(encoded) { + if (encoded === null) return null; + + const [ + tag, + selectionStartLineNumber, + selectionStartColumn, + positionLineNumber, + positionColumn, + ] = encoded; + + const editorSelection = new monaco.Selection( + selectionStartLineNumber, + selectionStartColumn, + positionLineNumber, + positionColumn + ); + + return { tag, editorSelection }; + }, + + // Helpers + + focusedCellType() { + if (this.focusedId && this.isCell(this.focusedId)) { + const el = this.getFocusableEl(this.focusedId); + return el.getAttribute("data-type"); + } else { + return null; + } + }, + + nearbyFocusableId(focusableId, offset) { + const focusableIds = this.getFocusableIds(); + + if (focusableIds.length === 0) { + return null; + } + + const idx = focusableIds.indexOf(focusableId); + + if (idx === -1) { + const focusableElInViewport = + this.getFocusableEls().find(isElementInViewport); + + if (focusableElInViewport) { + return focusableElInViewport.getAttribute("data-focusable-id"); + } + + return focusableIds[0]; + } else { + const siblingIdx = clamp(idx + offset, 0, focusableIds.length - 1); + return focusableIds[siblingIdx]; + } + }, + + isCell(focusableId) { + const el = this.getFocusableEl(focusableId); + return el.dataset.element === "cell"; + }, + + isSection(focusableId) { + const el = this.getFocusableEl(focusableId); + return el.dataset.element === "section-headline"; + }, + + isNotebook(focusableId) { + const el = this.getFocusableEl(focusableId); + return el.dataset.element === "notebook-headline"; + }, + + getFocusableEl(focusableId) { + return this.el.querySelector(`[data-focusable-id="${focusableId}"]`); + }, + + getFocusableEls() { + return Array.from(this.el.querySelectorAll(`[data-focusable-id]`)); + }, + + getFocusableIds() { + return this.getFocusableEls().map((el) => + el.getAttribute("data-focusable-id") + ); + }, + + getSectionIdByFocusableId(focusableId) { + const el = this.getFocusableEl(focusableId); + const section = el.closest(`[data-element="section"]`); + return section && section.getAttribute("data-section-id"); + }, + + getSectionIds() { + const sections = this.getSections(); + return sections.map((section) => section.getAttribute("data-section-id")); + }, + + getSections() { + return Array.from(this.el.querySelectorAll(`[data-element="section"]`)); + }, + + getSectionById(sectionId) { + return this.el.querySelector( + `[data-element="section"][data-section-id="${sectionId}"]` + ); + }, + + getElement(name) { + return this.el.querySelector(`[data-element="${name}"]`); + }, +}; + +/** + * Data of a specific LV client. + * + * @typedef Client + * @type {Object} + * @property {String} pid + * @property {String} hex_color + * @property {String} name + */ + +/** + * A report of the current location sent by one of the other clients. + * + * @typedef LocationReport + * @type {Object} + * @property {String|null} focusableId + * @property {monaco.Selection|null} selection + */ + +export default Session; diff --git a/assets/js/hooks/timer.js b/assets/js/hooks/timer.js new file mode 100644 index 000000000..7eb573fa9 --- /dev/null +++ b/assets/js/hooks/timer.js @@ -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; diff --git a/assets/js/user_form/index.js b/assets/js/hooks/user_form.js similarity index 70% rename from assets/js/user_form/index.js rename to assets/js/hooks/user_form.js index 4046280a4..c648dae74 100644 --- a/assets/js/user_form/index.js +++ b/assets/js/hooks/user_form.js @@ -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() { diff --git a/assets/js/hooks/virtualized_lines.js b/assets/js/hooks/virtualized_lines.js new file mode 100644 index 000000000..f9c41cfc6 --- /dev/null +++ b/assets/js/hooks/virtualized_lines.js @@ -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; diff --git a/assets/js/js_view/index.js b/assets/js/js_view/index.js deleted file mode 100644 index dd0c748a8..000000000 --- a/assets/js/js_view/index.js +++ /dev/null @@ -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:` message - * with `{ data }` payload, the data is then used in the initial call - * to the custom JS module. - * - * Then, a number of `event:` 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; diff --git a/assets/js/keyboard_control/index.js b/assets/js/keyboard_control/index.js deleted file mode 100644 index 85ab45206..000000000 --- a/assets/js/keyboard_control/index.js +++ /dev/null @@ -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; diff --git a/assets/js/lib/delta.js b/assets/js/lib/delta.js index 2051e483a..edbd67ed8 100644 --- a/assets/js/lib/delta.js +++ b/assets/js/lib/delta.js @@ -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(); } diff --git a/assets/js/session/key_buffer.js b/assets/js/lib/key_buffer.js similarity index 100% rename from assets/js/session/key_buffer.js rename to assets/js/lib/key_buffer.js diff --git a/assets/js/lib/markdown.js b/assets/js/lib/markdown.js index 74a773df5..3a6da0f8d 100644 --- a/assets/js/lib/markdown.js +++ b/assets/js/lib/markdown.js @@ -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 = `
${html}
`; @@ -46,7 +46,7 @@ class Markdown { }); } - __getHtml() { + _getHtml() { return ( unified() .use(remarkParse) diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index 9ae3359f8..95ba484c2 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -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. */ diff --git a/assets/js/password_toggle/index.js b/assets/js/password_toggle/index.js deleted file mode 100644 index 225a02814..000000000 --- a/assets/js/password_toggle/index.js +++ /dev/null @@ -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; diff --git a/assets/js/session/index.js b/assets/js/session/index.js deleted file mode 100644 index b341ea889..000000000 --- a/assets/js/session/index.js +++ /dev/null @@ -1,1150 +0,0 @@ -import { - isMacOS, - isEditableElement, - clamp, - selectElementContent, - smoothlyScrollToElement, - setFavicon, - cancelEvent, - isElementInViewport, -} from "../lib/utils"; -import { getAttributeOrDefault } from "../lib/attribute"; -import KeyBuffer from "./key_buffer"; -import { globalPubSub } from "../lib/pub_sub"; -import monaco from "../cell_editor/live_editor/monaco"; -import { leaveChannel } from "../js_view"; -import { isDirectlyEditable, isEvaluable } from "../lib/notebook"; - -/** - * A hook managing the whole session. - * - * Serves as a coordinator handling all the global session events. - * Note that each cell has its own hook, so that LV keeps track - * of cells being added/removed from the DOM. We do however need - * to communicate between this global hook and cells and for - * that we use a simple local pubsub that the hooks subscribe to. - * - * Configuration: - * - * * `data-autofocus-cell-id` - id of the cell that gets initial - * focus once the notebook is loaded - * - * ## Shortcuts - * - * This hook registers session shortcut handlers, - * see `LivebookWeb.SessionLive.ShortcutsComponent` - * for the complete list of available shortcuts. - * - * ## Navigation - * - * This hook handles focusing section titles, cells and moving the focus around, - * this is done purely on the client side because it is event-intensive - * and specific to this client only. The UI changes are handled by - * setting `data-js-*` attributes and using CSS accordingly (see assets/css/js_interop.css). - * Navigation changes are also broadcasted to all cell hooks via PubSub. - * - * ## Location tracking and following - * - * Location describes where the given client is within - * the notebook (in which cell, and where specifically in that cell). - * When multiple clients are connected, they report own location to - * each other whenever it changes. We then each the location to show - * cursor and selection indicators. - * - * Additionally the current user may follow another client from the clients list. - * In such case, whenever a new location comes from that client we move there automatically - * (i.e. we focus the same cells to effectively mimic how the followed client moves around). - * - * Initially we load basic information about connected clients using `"session_init"` - * and then update this information whenever clients join/leave/update. - * This way location reports include only client pid, as we already have - * the necessary hex_color/name locally. - */ -const Session = { - mounted() { - this.props = getProps(this); - this.state = { - focusedId: null, - focusedCellType: null, - insertMode: false, - keyBuffer: new KeyBuffer(), - clientsMap: {}, - lastLocationReportByClientPid: {}, - followedClientPid: null, - }; - - // Set initial favicon based on the current status - - setFavicon(faviconForEvaluationStatus(this.props.globalStatus)); - - // DOM events - - this.handleDocumentKeyDown = (event) => { - handleDocumentKeyDown(this, event); - }; - - document.addEventListener("keydown", this.handleDocumentKeyDown, true); - - this.handleDocumentMouseDown = (event) => { - handleDocumentMouseDown(this, event); - }; - - document.addEventListener("mousedown", this.handleDocumentMouseDown); - - this.handleDocumentFocus = (event) => { - handleDocumentFocus(this, event); - }; - - // Note: the focus event doesn't bubble, so we register for the capture phase - document.addEventListener("focus", this.handleDocumentFocus, true); - - this.handleDocumentClick = (event) => { - handleDocumentClick(this, event); - }; - - document.addEventListener("click", this.handleDocumentClick); - - this.handleDocumentDoubleClick = (event) => { - handleDocumentDoubleClick(this, event); - }; - - document.addEventListener("dblclick", this.handleDocumentDoubleClick); - - getSectionsList().addEventListener("click", (event) => { - handleSectionsListClick(this, event); - handleCellIndicatorsClick(this, event); - }); - - getClientsList().addEventListener("click", (event) => { - handleClientsListClick(this, event); - }); - - getSectionsListToggle().addEventListener("click", (event) => { - toggleSectionsList(this); - }); - - getClientsListToggle().addEventListener("click", (event) => { - toggleClientsList(this); - }); - - getRuntimeInfoToggle().addEventListener("click", (event) => { - toggleRuntimeInfo(this); - }); - - getNotebook().addEventListener("scroll", (event) => { - updateSectionListHighlight(); - }); - - getCellIndicators().addEventListener("click", (event) => { - handleCellIndicatorsClick(this, event); - }); - - window.addEventListener( - "phx:page-loading-stop", - () => { - initializeFocus(this); - }, - { once: true } - ); - - // DOM setup - - updateSectionListHighlight(); - - // Server events - - this.handleEvent("session_init", ({ clients }) => { - clients.forEach((client) => { - this.state.clientsMap[client.pid] = client; - }); - }); - - this.handleEvent("cell_inserted", ({ cell_id: cellId }) => { - handleCellInserted(this, cellId); - }); - - this.handleEvent( - "cell_deleted", - ({ cell_id: cellId, sibling_cell_id: siblingCellId }) => { - handleCellDeleted(this, cellId, siblingCellId); - } - ); - - this.handleEvent("cell_restored", ({ cell_id: cellId }) => { - handleCellRestored(this, cellId); - }); - - this.handleEvent("cell_moved", ({ cell_id }) => { - handleCellMoved(this, cell_id); - }); - - this.handleEvent("section_inserted", ({ section_id }) => { - handleSectionInserted(this, section_id); - }); - - this.handleEvent("section_deleted", ({ section_id }) => { - handleSectionDeleted(this, section_id); - }); - - this.handleEvent("section_moved", ({ section_id }) => { - handleSectionMoved(this, section_id); - }); - - this.handleEvent("cell_upload", ({ cell_id, url }) => { - handleCellUpload(this, cell_id, url); - }); - - this.handleEvent("client_joined", ({ client }) => { - handleClientJoined(this, client); - }); - - this.handleEvent("client_left", ({ client_pid }) => { - handleClientLeft(this, client_pid); - }); - - this.handleEvent("clients_updated", ({ clients }) => { - handleClientsUpdated(this, clients); - }); - - this.handleEvent( - "location_report", - ({ client_pid, focusable_id, selection }) => { - const report = { - focusableId: focusable_id, - selection: decodeSelection(selection), - }; - - handleLocationReport(this, client_pid, report); - } - ); - - this._unsubscribeFromSessionEvents = globalPubSub.subscribe( - "session", - (event) => { - handleSessionEvent(this, event); - } - ); - }, - - updated() { - const prevProps = this.props; - this.props = getProps(this); - - if (this.props.globalStatus !== prevProps.globalStatus) { - setFavicon(faviconForEvaluationStatus(this.props.globalStatus)); - } - }, - - disconnected() { - // Reinitialize on reconnection - this.el.removeAttribute("id"); - }, - - destroyed() { - this._unsubscribeFromSessionEvents(); - - document.removeEventListener("keydown", this.handleDocumentKeyDown, true); - document.removeEventListener("mousedown", this.handleDocumentMouseDown); - document.removeEventListener("focus", this.handleDocumentFocus, true); - document.removeEventListener("click", this.handleDocumentClick); - document.removeEventListener("dblclick", this.handleDocumentDoubleClick); - - setFavicon("favicon"); - - leaveChannel(); - }, -}; - -function getProps(hook) { - return { - autofocusCellId: getAttributeOrDefault( - hook.el, - "data-autofocus-cell-id", - null - ), - globalStatus: getAttributeOrDefault(hook.el, "data-global-status", null), - }; -} - -function faviconForEvaluationStatus(evaluationStatus) { - if (evaluationStatus === "evaluating") return "favicon-evaluating"; - if (evaluationStatus === "stale") return "favicon-stale"; - return "favicon"; -} - -/** - * Data of a specific LV client. - * - * @typedef Client - * @type {Object} - * @property {String} pid - * @property {String} hex_color - * @property {String} name - */ - -/** - * A report of the current location sent by one of the other clients. - * - * @typedef LocationReport - * @type {Object} - * @property {String|null} focusableId - * @property {monaco.Selection|null} selection - */ - -// DOM event handlers - -/** - * Handles session keybindings. - * - * Make sure to keep the shortcuts help modal up to date. - */ -function handleDocumentKeyDown(hook, event) { - if (event.repeat) { - return; - } - - const cmd = isMacOS() ? event.metaKey : event.ctrlKey; - const alt = event.altKey; - const shift = event.shiftKey; - const key = event.key; - const keyBuffer = hook.state.keyBuffer; - - if (hook.state.insertMode) { - keyBuffer.reset(); - - if (key === "Escape") { - // Ignore Escape if it's supposed to close an editor widget - if (!escapesMonacoWidget(event)) { - escapeInsertMode(hook); - } - } else if (cmd && shift && !alt && key === "Enter") { - cancelEvent(event); - queueFullCellsEvaluation(hook, true); - } else if (cmd && !alt && key === "Enter") { - cancelEvent(event); - if (isEvaluable(hook.state.focusedCellType)) { - queueFocusedCellEvaluation(hook); - } - } else if (cmd && key === "s") { - cancelEvent(event); - saveNotebook(hook); - } - } else { - // Ignore keystrokes on input fields - if (isEditableElement(event.target)) { - keyBuffer.reset(); - - // Use Escape for universal blur - if (key === "Escape") { - event.target.blur(); - } - - return; - } - - keyBuffer.push(event.key); - - if (cmd && key === "s") { - cancelEvent(event); - saveNotebook(hook); - } else if (keyBuffer.tryMatch(["d", "d"])) { - deleteFocusedCell(hook); - } else if (cmd && shift && !alt && key === "Enter") { - queueFullCellsEvaluation(hook, true); - } else if (keyBuffer.tryMatch(["e", "a"])) { - queueFullCellsEvaluation(hook, false); - } else if ( - keyBuffer.tryMatch(["e", "e"]) || - (cmd && !alt && key === "Enter") - ) { - if (isEvaluable(hook.state.focusedCellType)) { - queueFocusedCellEvaluation(hook); - } - } else if (keyBuffer.tryMatch(["e", "s"])) { - queueFocusedSectionEvaluation(hook); - } else if (keyBuffer.tryMatch(["s", "s"])) { - toggleSectionsList(hook); - } else if (keyBuffer.tryMatch(["s", "u"])) { - toggleClientsList(hook); - } else if (keyBuffer.tryMatch(["s", "r"])) { - toggleRuntimeInfo(hook); - } else if (keyBuffer.tryMatch(["s", "b"])) { - showBin(hook); - } else if (keyBuffer.tryMatch(["e", "x"])) { - cancelFocusedCellEvaluation(hook); - } else if (keyBuffer.tryMatch(["0", "0"])) { - restartRuntime(hook); - } else if (keyBuffer.tryMatch(["Escape", "Escape"])) { - setFocusedEl(hook, null); - } else if (keyBuffer.tryMatch(["?"])) { - showShortcuts(hook); - } else if ( - keyBuffer.tryMatch(["i"]) || - (event.target === document.body && - hook.state.focusedId && - key === "Enter") - ) { - cancelEvent(event); - if (isInsertModeAvailable(hook)) { - enterInsertMode(hook); - } - } else if (keyBuffer.tryMatch(["j"])) { - moveFocus(hook, 1); - } else if (keyBuffer.tryMatch(["k"])) { - moveFocus(hook, -1); - } else if (keyBuffer.tryMatch(["J"])) { - moveFocusedCell(hook, 1); - } else if (keyBuffer.tryMatch(["K"])) { - moveFocusedCell(hook, -1); - } else if (keyBuffer.tryMatch(["n"])) { - insertCellBelowFocused(hook, "code"); - } else if (keyBuffer.tryMatch(["N"])) { - insertCellAboveFocused(hook, "code"); - } else if (keyBuffer.tryMatch(["m"])) { - insertCellBelowFocused(hook, "markdown"); - } else if (keyBuffer.tryMatch(["M"])) { - insertCellAboveFocused(hook, "markdown"); - } - } -} - -function escapesMonacoWidget(event) { - // Escape pressed in an editor input - if (event.target.closest(".monaco-inputbox")) { - return true; - } - - const editor = event.target.closest(".monaco-editor.focused"); - - if (!editor) { - return false; - } - - // Completion box open - if (editor.querySelector(".editor-widget.parameter-hints-widget.visible")) { - return true; - } - - // Signature details open - if (editor.querySelector(".editor-widget.suggest-widget.visible")) { - return true; - } - - // Multi-cursor selection enabled - if (editor.querySelectorAll(".cursor").length > 1) { - return true; - } - - return false; -} - -/** - * Focuses/blurs a cell when the user clicks somewhere. - * - * Note: we use mousedown event to more reliably focus editor - * (e.g. if the user starts selecting some text within the editor) - */ -function handleDocumentMouseDown(hook, event) { - // If the click is outside the notebook element, keep the focus as is - if (!event.target.closest(`[data-element="notebook"]`)) { - if (hook.state.insertMode) { - setInsertMode(hook, false); - } - return; - } - - // When clicking an insert button, keep focus and insert mode as is - if (event.target.closest(`[data-element="insert-buttons"] button`)) { - return; - } - - // Find the focusable element, if one was clicked - const focusableEl = event.target.closest(`[data-focusable-id]`); - const focusableId = focusableEl ? focusableEl.dataset.focusableId : null; - const insertMode = editableElementClicked(event, focusableEl); - - if (focusableId !== hook.state.focusedId) { - setFocusedEl(hook, focusableId, { scroll: false, focusElement: false }); - } - - // If a cell action is clicked, keep the insert mode as is - if (event.target.closest(`[data-element="actions"]`)) { - return; - } - - // Depending on whether the click targets editor or input disable/enable insert mode - if (hook.state.insertMode !== insertMode) { - setInsertMode(hook, insertMode); - } -} - -function editableElementClicked(event, focusableEl) { - if (focusableEl) { - const editableElement = event.target.closest( - `[data-element="editor-container"], [data-element="heading"]` - ); - return editableElement && focusableEl.contains(editableElement); - } - - return false; -} - -/** - * Focuses a focusable element if the user "tab"s anywhere into it. - */ -function handleDocumentFocus(hook, event) { - const focusableEl = - event.target.closest && event.target.closest(`[data-focusable-id]`); - - if (focusableEl) { - const focusableId = focusableEl.dataset.focusableId; - - if (focusableId !== hook.state.focusedId) { - setFocusedEl(hook, focusableId, { scroll: false, focusElement: false }); - } - } -} - -/** - * Enters insert mode when markdown edit action is clicked. - */ -function handleDocumentClick(hook, event) { - if (event.target.closest(`[data-element="enable-insert-mode-button"]`)) { - setInsertMode(hook, true); - } -} - -/** - * Enters insert mode when a markdown cell is double-clicked. - */ -function handleDocumentDoubleClick(hook, event) { - const markdownCell = event.target.closest( - `[data-element="cell"][data-type="markdown"]` - ); - - if (markdownCell && hook.state.focusedId && !hook.state.insertMode) { - setInsertMode(hook, true); - } -} - -/** - * Handles section link clicks in the section list. - */ -function handleSectionsListClick(hook, event) { - const sectionButton = event.target.closest( - `[data-element="sections-list-item"]` - ); - if (sectionButton) { - const sectionId = sectionButton.getAttribute("data-section-id"); - const section = getSectionById(sectionId); - section.scrollIntoView({ behavior: "smooth", block: "start" }); - } -} - -/** - * Handles client link clicks in the clients list. - */ -function handleClientsListClick(hook, event) { - const clientListItem = event.target.closest( - `[data-element="clients-list-item"]` - ); - - if (clientListItem) { - const clientPid = clientListItem.getAttribute("data-client-pid"); - - const clientLink = event.target.closest(`[data-element="client-link"]`); - if (clientLink) { - handleClientLinkClick(hook, clientPid); - } - - const clientFollowToggle = event.target.closest( - `[data-element="client-follow-toggle"]` - ); - if (clientFollowToggle) { - handleClientFollowToggleClick(hook, clientPid, clientListItem); - } - } -} - -function handleClientLinkClick(hook, clientPid) { - mirrorClientFocus(hook, clientPid); -} - -function handleClientFollowToggleClick(hook, clientPid, clientListItem) { - const followedClientListItem = document.querySelector( - `[data-element="clients-list-item"][data-js-followed]` - ); - - if (followedClientListItem) { - followedClientListItem.removeAttribute("data-js-followed"); - } - - if (clientPid === hook.state.followedClientPid) { - hook.state.followedClientPid = null; - } else { - clientListItem.setAttribute("data-js-followed", "true"); - hook.state.followedClientPid = clientPid; - mirrorClientFocus(hook, clientPid); - } -} - -function mirrorClientFocus(hook, clientPid) { - const locationReport = hook.state.lastLocationReportByClientPid[clientPid]; - - if (locationReport && locationReport.focusableId) { - setFocusedEl(hook, locationReport.focusableId); - } -} - -/** - * Handles button clicks within cell indicators section. - */ -function handleCellIndicatorsClick(hook, event) { - const button = event.target.closest(`[data-element="focus-cell-button"]`); - if (button) { - const cellId = button.getAttribute("data-target"); - setFocusedEl(hook, cellId); - } -} - -/** - * Focuses cell or any other element based on the current - * URL and hook attributes. - */ -function initializeFocus(hook) { - const hash = window.location.hash; - - if (hash) { - const htmlId = hash.replace(/^#/, ""); - const element = document.getElementById(htmlId); - - if (element) { - const focusableEl = element.closest("[data-focusable-id]"); - - if (focusableEl) { - setFocusedEl(hook, focusableEl.dataset.focusableId); - } else { - // Explicitly scroll to the target element - // after the loading finishes - element.scrollIntoView(); - } - } - } else if (hook.props.autofocusCellId) { - setFocusedEl(hook, hook.props.autofocusCellId, { scroll: false }); - setInsertMode(hook, true); - } -} - -/** - * Handles the main notebook area being scrolled. - */ -function updateSectionListHighlight() { - const currentListItem = document.querySelector( - `[data-element="sections-list-item"][data-js-is-viewed]` - ); - - if (currentListItem) { - currentListItem.removeAttribute("data-js-is-viewed"); - } - - // Consider a section being viewed if it is within the top 35% of the screen - const viewedSection = getSections() - .reverse() - .find((section) => { - const { top } = section.getBoundingClientRect(); - const scrollTop = document.documentElement.scrollTop; - return top <= scrollTop + window.innerHeight * 0.35; - }); - - if (viewedSection) { - const sectionId = viewedSection.getAttribute("data-section-id"); - const listItem = document.querySelector( - `[data-element="sections-list-item"][data-section-id="${sectionId}"]` - ); - listItem.setAttribute("data-js-is-viewed", "true"); - } -} - -// User action handlers (mostly keybindings) - -function toggleSectionsList(hook) { - toggleSidePanelContent(hook, "sections-list"); -} - -function toggleClientsList(hook) { - toggleSidePanelContent(hook, "clients-list"); -} - -function toggleRuntimeInfo(hook) { - toggleSidePanelContent(hook, "runtime-info"); -} - -function toggleSidePanelContent(hook, name) { - if (hook.el.getAttribute("data-js-side-panel-content") === name) { - hook.el.removeAttribute("data-js-side-panel-content"); - } else { - hook.el.setAttribute("data-js-side-panel-content", name); - } -} - -function showBin(hook) { - hook.pushEvent("show_bin", {}); -} - -function saveNotebook(hook) { - hook.pushEvent("save", {}); -} - -function deleteFocusedCell(hook) { - if (hook.state.focusedId && isCell(hook.state.focusedId)) { - hook.pushEvent("delete_cell", { cell_id: hook.state.focusedId }); - } -} - -function queueFocusedCellEvaluation(hook) { - if (hook.state.focusedId && isCell(hook.state.focusedId)) { - hook.pushEvent("queue_cell_evaluation", { - cell_id: hook.state.focusedId, - }); - } -} - -function queueFullCellsEvaluation(hook, includeFocused) { - const forcedCellIds = - includeFocused && hook.state.focusedId && isCell(hook.state.focusedId) - ? [hook.state.focusedId] - : []; - - hook.pushEvent("queue_full_evaluation", { - forced_cell_ids: forcedCellIds, - }); -} - -function queueFocusedSectionEvaluation(hook) { - if (hook.state.focusedId) { - const sectionId = getSectionIdByFocusableId(hook.state.focusedId); - - if (sectionId) { - hook.pushEvent("queue_section_evaluation", { - section_id: sectionId, - }); - } - } -} - -function cancelFocusedCellEvaluation(hook) { - if (hook.state.focusedId && isCell(hook.state.focusedId)) { - hook.pushEvent("cancel_cell_evaluation", { - cell_id: hook.state.focusedId, - }); - } -} - -function restartRuntime(hook) { - hook.pushEvent("restart_runtime", {}); -} - -function showShortcuts(hook) { - hook.pushEvent("show_shortcuts", {}); -} - -function isInsertModeAvailable(hook) { - const el = getFocusableEl(hook.state.focusedId); - - return ( - !isCell(hook.state.focusedId) || - !el.hasAttribute("data-js-insert-mode-disabled") - ); -} - -function enterInsertMode(hook) { - if (hook.state.focusedId) { - setInsertMode(hook, true); - } -} - -function escapeInsertMode(hook) { - setInsertMode(hook, false); -} - -function moveFocus(hook, offset) { - const focusableId = nearbyFocusableId(hook.state.focusedId, offset); - setFocusedEl(hook, focusableId); -} - -function moveFocusedCell(hook, offset) { - if (hook.state.focusedId && isCell(hook.state.focusedId)) { - hook.pushEvent("move_cell", { cell_id: hook.state.focusedId, offset }); - } -} - -function insertCellBelowFocused(hook, type) { - if (hook.state.focusedId) { - insertCellBelowFocusableId(hook, hook.state.focusedId, type); - } else { - const focusableIds = getFocusableIds(); - if (focusableIds.length > 0) { - insertCellBelowFocusableId( - hook, - focusableIds[focusableIds.length - 1], - type - ); - } - } -} - -function insertCellAboveFocused(hook, type) { - if (hook.state.focusedId) { - const prevFocusableId = nearbyFocusableId(hook.state.focusedId, -1); - insertCellBelowFocusableId(hook, prevFocusableId, type); - } else { - const focusableIds = getFocusableIds(); - if (focusableIds.length > 0) { - insertCellBelowFocusableId(hook, focusableIds[0], type); - } - } -} - -function insertCellBelowFocusableId(hook, focusableId, type) { - if (isCell(focusableId)) { - hook.pushEvent("insert_cell_below", { type, cell_id: focusableId }); - } else if (isSection(focusableId)) { - hook.pushEvent("insert_cell_below", { type, section_id: focusableId }); - } else if (isNotebook(focusableId)) { - const sectionIds = getSectionIds(); - if (sectionIds.length > 0) { - hook.pushEvent("insert_cell_below", { type, section_id: sectionIds[0] }); - } - } -} - -function setFocusedEl( - hook, - focusableId, - { scroll = true, focusElement = true } = {} -) { - hook.state.focusedId = focusableId; - - if (focusableId) { - const el = getFocusableEl(focusableId); - - if (isCell(focusableId)) { - hook.state.focusedCellType = el.getAttribute("data-type"); - } else { - hook.state.focusedCellType = null; - } - - if (focusElement) { - // Focus the primary content in the focusable element, this is important for screen readers - const contentEl = - el.querySelector(`[data-element="cell-body"]`) || - el.querySelector(`[data-element="heading"]`) || - el; - contentEl.focus({ preventScroll: true }); - } - } else { - hook.state.focusedCellType = null; - } - - globalPubSub.broadcast("navigation", { - type: "element_focused", - focusableId: focusableId, - scroll, - }); - - setInsertMode(hook, false); -} - -function setInsertMode(hook, insertModeEnabled) { - hook.state.insertMode = insertModeEnabled; - - if (insertModeEnabled) { - hook.el.setAttribute("data-js-insert-mode", "true"); - } else { - hook.el.removeAttribute("data-js-insert-mode"); - - sendLocationReport(hook, { - focusableId: hook.state.focusedId, - selection: null, - }); - } - - globalPubSub.broadcast("navigation", { - type: "insert_mode_changed", - enabled: insertModeEnabled, - }); -} - -// Server event handlers - -function handleCellInserted(hook, cellId) { - setFocusedEl(hook, cellId); - if (isDirectlyEditable(hook.state.focusedCellType)) { - setInsertMode(hook, true); - } -} - -function handleCellDeleted(hook, cellId, siblingCellId) { - if (hook.state.focusedId === cellId) { - setFocusedEl(hook, siblingCellId); - } -} - -function handleCellRestored(hook, cellId) { - setFocusedEl(hook, cellId); -} - -function handleCellMoved(hook, cellId) { - if (hook.state.focusedId === cellId) { - globalPubSub.broadcast("cells", { type: "cell_moved", cellId }); - } -} - -function handleSectionInserted(hook, sectionId) { - const section = getSectionById(sectionId); - const headlineEl = section.querySelector(`[data-element="section-headline"]`); - const { focusableId } = headlineEl.dataset; - setFocusedEl(hook, focusableId); - setInsertMode(hook, true); - selectElementContent(document.activeElement); -} - -function handleSectionDeleted(hook, sectionId) { - // Clear focus if the element no longer exists - if (hook.state.focusedId && !getFocusableEl(hook.state.focusedId)) { - setFocusedEl(hook, null); - } -} - -function handleSectionMoved(hook, sectionId) { - const section = getSectionById(sectionId); - smoothlyScrollToElement(section); -} - -function handleCellUpload(hook, cellId, url) { - if (hook.state.focusedId !== cellId) { - setFocusedEl(hook, cellId); - } - - if (!hook.state.insertMode) { - setInsertMode(hook, true); - } - - globalPubSub.broadcast("cells", { type: "cell_upload", cellId, url }); -} - -function handleClientJoined(hook, client) { - hook.state.clientsMap[client.pid] = client; -} - -function handleClientLeft(hook, clientPid) { - const client = hook.state.clientsMap[clientPid]; - - if (client) { - delete hook.state.clientsMap[clientPid]; - - broadcastLocationReport(client, { focusableId: null, selection: null }); - - if (client.pid === hook.state.followedClientPid) { - hook.state.followedClientPid = null; - } - } -} - -function handleClientsUpdated(hook, updatedClients) { - updatedClients.forEach((client) => { - hook.state.clientsMap[client.pid] = client; - }); -} - -function handleLocationReport(hook, clientPid, report) { - const client = hook.state.clientsMap[clientPid]; - - hook.state.lastLocationReportByClientPid[clientPid] = report; - - if (client) { - broadcastLocationReport(client, report); - - if ( - client.pid === hook.state.followedClientPid && - report.focusableId !== hook.state.focusedId - ) { - setFocusedEl(hook, report.focusableId); - } - } -} - -// Session event handlers - -function handleSessionEvent(hook, event) { - if (event.type === "cursor_selection_changed") { - sendLocationReport(hook, { - focusableId: event.focusableId, - selection: event.selection, - }); - } -} - -/** - * Broadcast new location report coming from the server to all the cells. - */ -function broadcastLocationReport(client, report) { - globalPubSub.broadcast("navigation", { - type: "location_report", - client, - report, - }); -} - -/** - * Sends local location report to the server. - */ -function sendLocationReport(hook, report) { - const numberOfClients = Object.keys(hook.state.clientsMap).length; - - // Only send reports if there are other people to send to - if (numberOfClients > 1) { - hook.pushEvent("location_report", { - focusable_id: report.focusableId, - selection: encodeSelection(report.selection), - }); - } -} - -function encodeSelection(selection) { - if (selection === null) return null; - - const { tag, editorSelection } = selection; - - return [ - tag, - editorSelection.selectionStartLineNumber, - editorSelection.selectionStartColumn, - editorSelection.positionLineNumber, - editorSelection.positionColumn, - ]; -} - -function decodeSelection(encoded) { - if (encoded === null) return null; - - const [ - tag, - selectionStartLineNumber, - selectionStartColumn, - positionLineNumber, - positionColumn, - ] = encoded; - - const editorSelection = new monaco.Selection( - selectionStartLineNumber, - selectionStartColumn, - positionLineNumber, - positionColumn - ); - - return { tag, editorSelection }; -} - -// Helpers - -function nearbyFocusableId(focusableId, offset) { - const focusableIds = getFocusableIds(); - - if (focusableIds.length === 0) { - return null; - } - - const idx = focusableIds.indexOf(focusableId); - - if (idx === -1) { - const focusableElInViewport = getFocusableEls().find(isElementInViewport); - - if (focusableElInViewport) { - return focusableElInViewport.getAttribute("data-focusable-id"); - } - - return focusableIds[0]; - } else { - const siblingIdx = clamp(idx + offset, 0, focusableIds.length - 1); - return focusableIds[siblingIdx]; - } -} - -function isCell(focusableId) { - const el = getFocusableEl(focusableId); - return el.dataset.element === "cell"; -} - -function isSection(focusableId) { - const el = getFocusableEl(focusableId); - return el.dataset.element === "section-headline"; -} - -function isNotebook(focusableId) { - const el = getFocusableEl(focusableId); - return el.dataset.element === "notebook-headline"; -} - -function getFocusableEl(focusableId) { - return document.querySelector(`[data-focusable-id="${focusableId}"]`); -} - -function getFocusableEls() { - return Array.from(document.querySelectorAll(`[data-focusable-id]`)); -} - -function getFocusableIds() { - return getFocusableEls().map((el) => el.getAttribute("data-focusable-id")); -} - -function getSectionIdByFocusableId(focusableId) { - const el = getFocusableEl(focusableId); - const section = el.closest(`[data-element="section"]`); - return section && section.getAttribute("data-section-id"); -} - -function getSectionIds() { - const sections = getSections(); - return sections.map((section) => section.getAttribute("data-section-id")); -} - -function getSections() { - return Array.from(document.querySelectorAll(`[data-element="section"]`)); -} - -function getSectionById(sectionId) { - return document.querySelector( - `[data-element="section"][data-section-id="${sectionId}"]` - ); -} - -function getSectionsList() { - return document.querySelector(`[data-element="sections-list"]`); -} - -function getClientsList() { - return document.querySelector(`[data-element="clients-list"]`); -} - -function getCellIndicators() { - return document.querySelector(`[data-element="notebook-indicators"]`); -} - -function getNotebook() { - return document.querySelector(`[data-element="notebook"]`); -} - -function getSectionsListToggle() { - return document.querySelector(`[data-element="sections-list-toggle"]`); -} - -function getClientsListToggle() { - return document.querySelector(`[data-element="clients-list-toggle"]`); -} - -function getRuntimeInfoToggle() { - return document.querySelector(`[data-element="runtime-info-toggle"]`); -} - -export default Session; diff --git a/assets/js/timer/index.js b/assets/js/timer/index.js deleted file mode 100644 index f902e1552..000000000 --- a/assets/js/timer/index.js +++ /dev/null @@ -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; diff --git a/assets/js/virtualized_lines/index.js b/assets/js/virtualized_lines/index.js deleted file mode 100644 index b06ce503c..000000000 --- a/assets/js/virtualized_lines/index.js +++ /dev/null @@ -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; diff --git a/lib/livebook_web/helpers.ex b/lib/livebook_web/helpers.ex index ef6acaeac..4cedca0ac 100644 --- a/lib/livebook_web/helpers.ex +++ b/lib/livebook_web/helpers.ex @@ -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"> """ def with_password_toggle(assigns) do ~H""" -
- +
<%= render_slot(@inner_block) %> - +
+ + +
""" end diff --git a/lib/livebook_web/live/home_live/import_file_upload_component.ex b/lib/livebook_web/live/home_live/import_file_upload_component.ex index a9a570914..fba092bb8 100644 --- a/lib/livebook_web/live/home_live/import_file_upload_component.ex +++ b/lib/livebook_web/live/home_live/import_file_upload_component.ex @@ -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" %> -
+
<%= if @uploads.notebook.entries == [] do %> Drop your notebook here <% else %> @@ -71,6 +72,8 @@ defmodule LivebookWeb.HomeLive.ImportFileUploadComponent do content = File.read!(path) send(self(), {:import_content, content, []}) + + {:ok, :ok} end) {:noreply, socket} diff --git a/lib/livebook_web/live/output/markdown_component.ex b/lib/livebook_web/live/output/markdown_component.ex index 16dafef29..70d56e2d5 100644 --- a/lib/livebook_web/live/output/markdown_component.ex +++ b/lib/livebook_web/live/output/markdown_component.ex @@ -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 diff --git a/lib/livebook_web/live/session_live.ex b/lib/livebook_web/live/session_live.ex index 69aa5d5ee..64c346194 100644 --- a/lib/livebook_web/live/session_live.ex +++ b/lib/livebook_web/live/session_live.ex @@ -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 diff --git a/lib/livebook_web/live/session_live/cell_component.ex b/lib/livebook_web/live/session_live/cell_component.ex index a7af25a95..6e4780bdb 100644 --- a/lib/livebook_web/live/session_live/cell_component.ex +++ b/lib/livebook_web/live/session_live/cell_component.ex @@ -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}> diff --git a/lib/livebook_web/live/settings_live/add_file_system_component.ex b/lib/livebook_web/live/settings_live/add_file_system_component.ex index 9378104b0..23848cf68 100644 --- a/lib/livebook_web/live/settings_live/add_file_system_component.ex +++ b/lib/livebook_web/live/settings_live/add_file_system_component.ex @@ -46,13 +46,13 @@ defmodule LivebookWeb.SettingsLive.AddFileSystemComponent do
Access Key ID
- <.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" %>
Secret Access Key
- <.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" %>