Skip to content

Commit

Permalink
Usability improvements for custom keyboard controls
Browse files Browse the repository at this point in the history
* Add `:default_handlers` option to control whether the default
Livebook shortcuts are enabled/disabled when the keyboard control is
active.
* Add `ctrl/cmd + k` session shortcut to toggle keyboard controls.
  • Loading branch information
zachallaun committed Aug 7, 2023
1 parent 5fda5b0 commit efae672
Show file tree
Hide file tree
Showing 5 changed files with 114 additions and 37 deletions.
85 changes: 73 additions & 12 deletions assets/js/hooks/keyboard_control.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { getAttributeOrThrow, parseBoolean } from "../lib/attribute";
import { cancelEvent, isEditableElement } from "../lib/utils";
import { cancelEvent, isEditableElement, isMacOS } from "../lib/utils";
import { globalPubSub } from "../lib/pub_sub";

/**
* 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
*/
const KeyboardControl = {
mounted() {
this.props = this.getProps();
this.cellFocused = false;

this._handleDocumentKeyDown = this.handleDocumentKeyDown.bind(this);
this._handleDocumentKeyUp = this.handleDocumentKeyUp.bind(this);
Expand All @@ -28,6 +36,11 @@ const KeyboardControl = {
// Note: the focus event doesn't bubble, so we register for the
// capture phase
window.addEventListener("focus", this._handleDocumentFocus, true);

this.unsubscribeFromNavigationEvents = globalPubSub.subscribe(
"navigation",
(event) => this.handleNavigationEvent(event)
);
},

updated() {
Expand All @@ -38,10 +51,13 @@ const KeyboardControl = {
window.removeEventListener("keydown", this._handleDocumentKeyDown, true);
window.removeEventListener("keyup", this._handleDocumentKeyUp, true);
window.removeEventListener("focus", this._handleDocumentFocus, true);
this.unsubscribeFromNavigationEvents();
},

getProps() {
return {
cellId: getAttributeOrThrow(this.el, "data-cell-id"),
defaultHandlers: getAttributeOrThrow(this.el, "data-default-handlers"),
isKeydownEnabled: getAttributeOrThrow(
this.el,
"data-keydown-enabled",
Expand All @@ -57,40 +73,85 @@ const KeyboardControl = {
},

handleDocumentKeyDown(event) {
if (this.keyboardEnabled()) {
if (this.isKeyboardToggle(event)) {
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 (event.repeat) {
return;
}

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();
}
},

handleNavigationEvent(event) {
if (event.type === "element_focused") {
this.cellFocused = event.focusableId === this.props.cellId;
}
},

enableKeyboard() {
if (!this.keyboardEnabled()) {
this.pushEventTo(this.props.target, "enable_keyboard", {});
}
},

disableKeyboard() {
if (this.keyboardEnabled()) {
this.pushEventTo(this.props.target, "disable_keyboard", {});
}
},

keyboardEnabled() {
return this.props.isKeydownEnabled || this.props.isKeyupEnabled;
},

isKeyboardToggle({ key, metaKey, ctrlKey }) {
const cmd = isMacOS() ? metaKey : ctrlKey;

if (cmd && key === "k" && this.cellFocused) {
return (
!this.keyboardEnabled() ||
(this.keyboardEnabled() && this.props.defaultHandlers !== "off")
);
} else {
return false;
}
},
};

export default KeyboardControl;
19 changes: 13 additions & 6 deletions assets/js/hooks/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ const Session = {
if (this.props.globalStatus !== prevProps.globalStatus) {
setFavicon(this.faviconForEvaluationStatus(this.props.globalStatus));
}

if (this.focusedId) {
this.broadcastElementFocused(this.focusedId, false);
}
},

disconnected() {
Expand Down Expand Up @@ -1056,12 +1060,7 @@ const Session = {
}
}

globalPubSub.broadcast("navigation", {
type: "element_focused",
focusableId: focusableId,
scroll,
});

this.broadcastElementFocused(focusableId, scroll);
this.setInsertMode(false);
},

Expand All @@ -1085,6 +1084,14 @@ const Session = {
});
},

broadcastElementFocused(focusableId, scroll) {
globalPubSub.broadcast("navigation", {
type: "element_focused",
focusableId: focusableId,
scroll,
});
},

handleViewsClick(event) {
const button = event.target.closest(`[data-el-view-toggle]`);

Expand Down
6 changes: 4 additions & 2 deletions lib/livebook_web/live/output.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 17 additions & 16 deletions lib/livebook_web/live/output/control_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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={to_string(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}
Expand Down Expand Up @@ -72,22 +74,19 @@ defmodule LivebookWeb.Output.ControlComponent do

@impl true
def handle_event("toggle_keyboard", %{}, socket) do
socket = update(socket, :keyboard_enabled, &not/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("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
def handle_event("enable_keyboard", %{}, socket) do
maybe_report_status(socket, true)
{:noreply, assign(socket, keyboard_enabled: true)}
end

{:noreply, socket}
def handle_event("disable_keyboard", %{}, socket) do
maybe_report_status(socket, false)
{:noreply, assign(socket, keyboard_enabled: false)}
end

def handle_event("button_click", %{}, socket) do
Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion lib/livebook_web/live/session_live/shortcuts_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 Kino keyboard control"
}
],
universal: [
%{
Expand Down

0 comments on commit efae672

Please sign in to comment.