diff --git a/assets/js/hooks/keyboard_control.js b/assets/js/hooks/keyboard_control.js index e796e73a4..c5f38551e 100644 --- a/assets/js/hooks/keyboard_control.js +++ b/assets/js/hooks/keyboard_control.js @@ -1,14 +1,20 @@ import { getAttributeOrThrow, parseBoolean } from "../lib/attribute"; -import { cancelEvent, isEditableElement } from "../lib/utils"; +import { cancelEvent, isEditableElement, isMacOS } from "../lib/utils"; /** * A hook for ControlComponent to handle user keyboard interactions. * * ## Configuration * - * * `data-keydown-enabled` - whether keydown events should be intercepted + * * `data-cell-id` - id of the cell in which the control is rendered * - * * `data-keyup-enabled` - whether keyup events should be intercepted + * * `data-default-handlers` - whether keyboard events should be + * intercepted and canceled, disabling session shortcuts. Must be + * one of "off", "on", or "disable_only" + * + * * `data-keydown-enabled` - whether keydown events should be listened to + * + * * `data-keyup-enabled` - whether keyup events should be listened to * * * `data-target` - the target to send live events to */ @@ -42,6 +48,8 @@ const KeyboardControl = { getProps() { return { + cellId: getAttributeOrThrow(this.el, "data-cell-id"), + defaultHandlers: getAttributeOrThrow(this.el, "data-default-handlers"), isKeydownEnabled: getAttributeOrThrow( this.el, "data-keydown-enabled", @@ -57,33 +65,58 @@ const KeyboardControl = { }, handleDocumentKeyDown(event) { - if (this.keyboardEnabled()) { + if ( + this.isKeyboardToggle(event) && + !isEditableElement(document.activeElement) + ) { cancelEvent(event); + this.keyboardEnabled() ? this.disableKeyboard() : this.enableKeyboard(); + return; } - if (this.props.isKeydownEnabled) { + if (this.keyboardEnabled()) { + if (this.props.defaultHandlers !== "on") { + cancelEvent(event); + } + if (event.repeat) { return; } - const { key } = event; - this.pushEventTo(this.props.target, "keydown", { key }); + if (this.props.isKeydownEnabled) { + const { key } = event; + this.pushEventTo(this.props.target, "keydown", { key }); + } } }, handleDocumentKeyUp(event) { if (this.keyboardEnabled()) { - cancelEvent(event); - } + if (this.props.defaultHandlers !== "on") { + cancelEvent(event); + } - if (this.props.isKeyupEnabled) { - const { key } = event; - this.pushEventTo(this.props.target, "keyup", { key }); + if (this.props.isKeyupEnabled) { + const { key } = event; + this.pushEventTo(this.props.target, "keyup", { key }); + } } }, handleDocumentFocus(event) { if (this.props.isKeydownEnabled && isEditableElement(event.target)) { + this.disableKeyboard(); + } + }, + + enableKeyboard() { + if (!this.keyboardEnabled()) { + this.pushEventTo(this.props.target, "enable_keyboard", {}); + } + }, + + disableKeyboard() { + if (this.keyboardEnabled()) { this.pushEventTo(this.props.target, "disable_keyboard", {}); } }, @@ -91,6 +124,32 @@ const KeyboardControl = { keyboardEnabled() { return this.props.isKeydownEnabled || this.props.isKeyupEnabled; }, + + isKeyboardToggle(event) { + if (event.repeat) { + return false; + } + + const { metaKey, ctrlKey, key } = event; + const cmd = isMacOS() ? metaKey : ctrlKey; + + if (cmd && key === "k" && this.isCellFocused()) { + return ( + !this.keyboardEnabled() || + ["on", "disable_only"].includes(this.props.defaultHandlers) + ); + } else { + return false; + } + }, + + isCellFocused() { + const sessionEl = this.el.closest("[data-el-session]"); + return ( + sessionEl && + sessionEl.getAttribute("data-js-focused-id") === this.props.cellId + ); + }, }; export default KeyboardControl; diff --git a/assets/js/hooks/session.js b/assets/js/hooks/session.js index 5d787aafa..e5db3d133 100644 --- a/assets/js/hooks/session.js +++ b/assets/js/hooks/session.js @@ -1033,6 +1033,12 @@ const Session = { setFocusedEl(focusableId, { scroll = true, focusElement = true } = {}) { this.focusedId = focusableId; + if (focusableId) { + this.el.setAttribute("data-js-focused-id", focusableId); + } else { + this.el.removeAttribute("data-js-focused-id"); + } + if (focusableId) { // If the element is inside collapsed section, expand that section if (!this.isSection(focusableId)) { diff --git a/lib/livebook_web/live/output.ex b/lib/livebook_web/live/output.ex index 74c3aebaa..315932c35 100644 --- a/lib/livebook_web/live/output.ex +++ b/lib/livebook_web/live/output.ex @@ -253,14 +253,16 @@ defmodule LivebookWeb.Output do id: id, input_views: input_views, session_pid: session_pid, - client_id: client_id + client_id: client_id, + cell_id: cell_id }) do live_component(Output.ControlComponent, id: id, attrs: attrs, input_views: input_views, session_pid: session_pid, - client_id: client_id + client_id: client_id, + cell_id: cell_id ) end diff --git a/lib/livebook_web/live/output/control_component.ex b/lib/livebook_web/live/output/control_component.ex index 11af96c25..e1cbec24c 100644 --- a/lib/livebook_web/live/output/control_component.ex +++ b/lib/livebook_web/live/output/control_component.ex @@ -13,6 +13,8 @@ defmodule LivebookWeb.Output.ControlComponent do class="flex" id={"#{@id}-root"} phx-hook="KeyboardControl" + data-cell-id={@cell_id} + data-default-handlers={Map.get(@attrs, :default_handlers, :off)} data-keydown-enabled={to_string(@keyboard_enabled and :keydown in @attrs.events)} data-keyup-enabled={to_string(@keyboard_enabled and :keyup in @attrs.events)} data-target={@myself} @@ -72,22 +74,19 @@ defmodule LivebookWeb.Output.ControlComponent do @impl true def handle_event("toggle_keyboard", %{}, socket) do - socket = update(socket, :keyboard_enabled, ¬/1) - maybe_report_status(socket) - {:noreply, socket} + enabled = !socket.assigns.keyboard_enabled + maybe_report_status(socket, enabled) + {:noreply, assign(socket, keyboard_enabled: enabled)} + end + + def handle_event("enable_keyboard", %{}, socket) do + maybe_report_status(socket, true) + {:noreply, assign(socket, keyboard_enabled: true)} end def handle_event("disable_keyboard", %{}, socket) do - socket = - if socket.assigns.keyboard_enabled do - socket = assign(socket, keyboard_enabled: false) - maybe_report_status(socket) - socket - else - socket - end - - {:noreply, socket} + maybe_report_status(socket, false) + {:noreply, assign(socket, keyboard_enabled: false)} end def handle_event("button_click", %{}, socket) do @@ -105,9 +104,11 @@ defmodule LivebookWeb.Output.ControlComponent do {:noreply, socket} end - defp maybe_report_status(socket) do - if :status in socket.assigns.attrs.events do - report_event(socket, %{type: :status, enabled: socket.assigns.keyboard_enabled}) + defp maybe_report_status(socket, enabled) do + %{assigns: %{attrs: attrs, keyboard_enabled: current}} = socket + + if :status in attrs.events and enabled != current do + report_event(socket, %{type: :status, enabled: enabled}) end end diff --git a/lib/livebook_web/live/session_live/shortcuts_component.ex b/lib/livebook_web/live/session_live/shortcuts_component.ex index e39133f87..c7ae6c5b2 100644 --- a/lib/livebook_web/live/session_live/shortcuts_component.ex +++ b/lib/livebook_web/live/session_live/shortcuts_component.ex @@ -107,7 +107,13 @@ defmodule LivebookWeb.SessionLive.ShortcutsComponent do %{seq: ["s", "r"], desc: "Show runtime panel"}, %{seq: ["s", "b"], desc: "Show bin"}, %{seq: ["s", "p"], desc: "Show package search"}, - %{seq: ["0", "0"], desc: "Reconnect current runtime"} + %{seq: ["0", "0"], desc: "Reconnect current runtime"}, + %{ + seq: ["ctrl", "k"], + seq_mac: ["⌘", "k"], + press_all: true, + desc: "Toggle keyboard control in cell output" + } ], universal: [ %{