From 0b77fd42790fbd25c8aac94e2f71e8e1080d8d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 18 Feb 2021 13:14:09 +0100 Subject: [PATCH] Add keyboard shortcuts help modal (#41) * Update keybindings and add help modal * Add more evaluation shortcuts * Add shortcut to the help modal * Show appropriate shortcuts depending on the user system * Handle missing user-agent header * Conditionally render shortcut based on user agent * Implement vim-style navigation * Remove warning * Determine platform based on socket on mount * Improve shortcuts list UI --- assets/css/utilities.css | 4 + assets/js/cell/index.js | 16 +-- assets/js/lib/utils.js | 3 + assets/js/session/index.js | 104 +++++++++--------- assets/js/session/key_buffer.js | 63 +++++++++++ lib/live_book_web/endpoint.ex | 3 +- lib/live_book_web/helpers.ex | 17 +++ lib/live_book_web/live/cell_component.ex | 4 +- lib/live_book_web/live/icons.ex | 10 ++ lib/live_book_web/live/modal_component.ex | 2 +- lib/live_book_web/live/runtime_component.ex | 2 +- lib/live_book_web/live/section_component.ex | 2 +- lib/live_book_web/live/session_live.ex | 76 ++++++++++--- lib/live_book_web/live/shortcuts_component.ex | 102 +++++++++++++++++ lib/live_book_web/router.ex | 1 + test/live_book/runtime/standalone_test.exs | 2 +- 16 files changed, 330 insertions(+), 81 deletions(-) create mode 100644 assets/js/lib/utils.js create mode 100644 assets/js/session/key_buffer.js create mode 100644 lib/live_book_web/live/shortcuts_component.ex diff --git a/assets/css/utilities.css b/assets/css/utilities.css index 2508024da..c80371ffe 100644 --- a/assets/css/utilities.css +++ b/assets/css/utilities.css @@ -4,6 +4,10 @@ background-color: #282c34; } +.text-editor { + color: #abb2bf; +} + .font-editor { font-family: "Droid Sans Mono", monospace, monospace, "Droid Sans Fallback"; font-size: 14px; diff --git a/assets/js/cell/index.js b/assets/js/cell/index.js index 2e8acf4eb..e4a5d0e72 100644 --- a/assets/js/cell/index.js +++ b/assets/js/cell/index.js @@ -1,8 +1,4 @@ -import { - getAttributeOrThrow, - parseBoolean, - parseInteger, -} from "../lib/attribute"; +import { getAttributeOrThrow, parseBoolean } from "../lib/attribute"; import LiveEditor from "./live_editor"; import Markdown from "./markdown"; @@ -17,7 +13,7 @@ import Markdown from "./markdown"; * * `data-cell-id` - id of the cell being edited * * `data-type` - editor type (i.e. language), either "markdown" or "elixir" is expected * * `data-focused` - whether the cell is currently focused - * * `data-expanded` - whether the cell is currently expanded (relevant for markdown cells) + * * `data-insert-mode` - whether insert mode is currently enabled */ const Cell = { mounted() { @@ -93,7 +89,7 @@ function getProps(hook) { cellId: getAttributeOrThrow(hook.el, "data-cell-id"), type: getAttributeOrThrow(hook.el, "data-type"), isFocused: getAttributeOrThrow(hook.el, "data-focused", parseBoolean), - isExpanded: getAttributeOrThrow(hook.el, "data-expanded", parseBoolean), + insertMode: getAttributeOrThrow(hook.el, "data-insert-mode", parseBoolean), }; } @@ -101,11 +97,7 @@ function getProps(hook) { * Checks if the cell editor is active and should have focus. */ function isActive(props) { - if (props.type === "markdown") { - return props.isFocused && props.isExpanded; - } else { - return props.isFocused; - } + return props.isFocused && props.insertMode; } export default Cell; diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js new file mode 100644 index 000000000..d16993a05 --- /dev/null +++ b/assets/js/lib/utils.js @@ -0,0 +1,3 @@ +export function isMacOS() { + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); +} diff --git a/assets/js/session/index.js b/assets/js/session/index.js index 94179d9c6..b8ee1da45 100644 --- a/assets/js/session/index.js +++ b/assets/js/session/index.js @@ -1,4 +1,6 @@ -import { getAttributeOrThrow } from "../lib/attribute"; +import { getAttributeOrThrow, parseBoolean } from "../lib/attribute"; +import { isMacOS } from "../lib/utils"; +import KeyBuffer from "./key_buffer"; /** * A hook managing the whole session. @@ -14,66 +16,62 @@ const Session = { this.props = getProps(this); // Keybindings - this.handleDocumentKeydown = (event) => { - const key = event.key.toLowerCase(); - const shift = event.shiftKey; - const alt = event.altKey; - const ctrl = event.ctrlKey; + // Note: make sure to keep the shortcuts help modal up to date. + const keyBuffer = new KeyBuffer(); + + this.handleDocumentKeydown = (event) => { if (event.repeat) { return; } - if (shift && key === "enter") { - cancelEvent(event); + const cmd = isMacOS() ? event.metaKey : event.ctrlKey; + const key = event.key; - if (this.props.focusedCellType === "elixir") { + if (this.props.insertMode) { + keyBuffer.reset(); + + if (key === "Escape") { + this.pushEvent("set_insert_mode", { enabled: false }); + } else if ( + this.props.focusedCellType === "elixir" && + cmd && + key === "Enter" + ) { + cancelEvent(event); this.pushEvent("queue_focused_cell_evaluation"); } + } else { + keyBuffer.push(event.key); - this.pushEvent("move_cell_focus", { offset: 1 }); - } else if (alt && ctrl && key === "enter") { - cancelEvent(event); - - this.pushEvent("queue_child_cells_evaluation", {}); - } else if (ctrl && key === "enter") { - cancelEvent(event); - - if (this.props.focusedCellType === "elixir") { - this.pushEvent("queue_focused_cell_evaluation"); - } - - if (this.props.focusedCellType === "markdown") { - this.pushEvent("toggle_cell_expanded"); - } - } else if (alt && key === "j") { - cancelEvent(event); - - this.pushEvent("move_cell_focus", { offset: 1 }); - } else if (alt && key === "k") { - cancelEvent(event); - - this.pushEvent("move_cell_focus", { offset: -1 }); - } else if (alt && key === "n") { - cancelEvent(event); - - if (shift) { - this.pushEvent("insert_cell_above_focused", { type: "elixir" }); - } else { + if (keyBuffer.tryMatch(["d", "d"])) { + this.pushEvent("delete_focused_cell", {}); + } else if ( + this.props.focusedCellType === "elixir" && + keyBuffer.tryMatch(["e", "e"]) + ) { + this.pushEvent("queue_focused_cell_evaluation", {}); + } else if (keyBuffer.tryMatch(["e", "s"])) { + this.pushEvent("queue_section_cells_evaluation", {}); + } else if (keyBuffer.tryMatch(["e", "j"])) { + this.pushEvent("queue_child_cells_evaluation", {}); + } else if (keyBuffer.tryMatch(["?"])) { + this.pushEvent("show_shortcuts", {}); + } else if (key === "i") { + this.pushEvent("set_insert_mode", { enabled: true }); + } else if (key === "j") { + this.pushEvent("move_cell_focus", { offset: 1 }); + } else if (key === "k") { + this.pushEvent("move_cell_focus", { offset: -1 }); + } else if (key === "n") { this.pushEvent("insert_cell_below_focused", { type: "elixir" }); - } - } else if (alt && key === "m") { - cancelEvent(event); - - if (shift) { - this.pushEvent("insert_cell_above_focused", { type: "markdown" }); - } else { + } else if (key === "N") { + this.pushEvent("insert_cell_above_focused", { type: "elixir" }); + } else if (key === "m") { this.pushEvent("insert_cell_below_focused", { type: "markdown" }); + } else if (key === "M") { + this.pushEvent("insert_cell_above_focused", { type: "markdown" }); } - } else if (alt && key === "w") { - cancelEvent(event); - - this.pushEvent("delete_focused_cell", {}); } }; @@ -87,6 +85,13 @@ const Session = { if (cellId !== this.props.focusedCellId) { this.pushEvent("focus_cell", { cell_id: cellId }); } + + // Depending if the click targets editor or not disable/enable insert mode. + if (cell) { + const editorContainer = cell.querySelector("[data-editor-container]"); + const editorClicked = editorContainer.contains(event.target); + this.pushEvent("set_insert_mode", { enabled: editorClicked }); + } }; document.addEventListener("click", this.handleDocumentClick); @@ -104,6 +109,7 @@ const Session = { function getProps(hook) { return { + insertMode: getAttributeOrThrow(hook.el, "data-insert-mode", parseBoolean), focusedCellId: getAttributeOrThrow( hook.el, "data-focused-cell-id", diff --git a/assets/js/session/key_buffer.js b/assets/js/session/key_buffer.js new file mode 100644 index 000000000..95ccad924 --- /dev/null +++ b/assets/js/session/key_buffer.js @@ -0,0 +1,63 @@ +/** + * Allows for recording a sequence of keys pressed + * and matching against that sequence. + */ +class KeyBuffer { + /** + * @param {Number} resetTimeout The number of milliseconds to wait after new key is pushed before the buffer is cleared. + */ + constructor(resetTimeout = 2000) { + this.resetTimeout = resetTimeout; + this.buffer = []; + this.resetTimeoutId = null; + } + + /** + * Adds a new key to the buffer and renews the reset timeout. + */ + push(key) { + this.buffer.push(key); + + if (this.resetTimeoutId) { + clearTimeout(this.resetTimeoutId); + } + + this.resetTimeoutId = setTimeout(() => { + this.reset(); + }, this.resetTimeout); + } + + /** + * Immediately clears the buffer. + */ + reset() { + if (this.resetTimeout) { + clearTimeout(this.resetTimeout); + } + + this.clearTimeout = null; + this.buffer = []; + } + + /** + * Checks if the given sequence of keys matches the end of buffer. + * + * If the match succeeds, the buffer is reset. + */ + tryMatch(keys) { + if (keys.length > this.buffer.length) { + return false; + } + + const bufferTail = this.buffer.slice(-keys.length); + const matches = keys.every((key, index) => key === bufferTail[index]); + + if (matches) { + this.reset(); + } + + return matches; + } +} + +export default KeyBuffer; diff --git a/lib/live_book_web/endpoint.ex b/lib/live_book_web/endpoint.ex index 8c42bb736..373aae9f5 100644 --- a/lib/live_book_web/endpoint.ex +++ b/lib/live_book_web/endpoint.ex @@ -10,7 +10,8 @@ defmodule LiveBookWeb.Endpoint do signing_salt: "SqUy8vWM" ] - socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [:user_agent, session: @session_options]] # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/live_book_web/helpers.ex b/lib/live_book_web/helpers.ex index 5fbd6ab6a..b5bd53d97 100644 --- a/lib/live_book_web/helpers.ex +++ b/lib/live_book_web/helpers.ex @@ -12,4 +12,21 @@ defmodule LiveBookWeb.Helpers do modal_opts = [id: :modal, return_to: path, component: component, opts: opts] live_component(socket, LiveBookWeb.ModalComponent, modal_opts) end + + @doc """ + Determines user platform based on the given *User-Agent* header. + """ + @spec platform_from_user_agent(Sting.t()) :: :linux | :mac | :windows | :other + def platform_from_user_agent(user_agent) when is_binary(user_agent) do + cond do + linux?(user_agent) -> :linux + mac?(user_agent) -> :mac + windows?(user_agent) -> :windows + true -> :other + end + end + + defp linux?(user_agent), do: String.match?(user_agent, ~r/Linux/) + defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/) + defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/) end diff --git a/lib/live_book_web/live/cell_component.ex b/lib/live_book_web/live/cell_component.ex index 5f3e50e1b..25efa8d17 100644 --- a/lib/live_book_web/live/cell_component.ex +++ b/lib/live_book_web/live/cell_component.ex @@ -9,7 +9,7 @@ defmodule LiveBookWeb.CellComponent do data-cell-id="<%= @cell.id %>" data-type="<%= @cell.type %>" data-focused="<%= @focused %>" - data-expanded="<%= @expanded %>"> + data-insert-mode="<%= @insert_mode %>"> <%= render_cell_content(assigns) %> """ @@ -25,7 +25,7 @@ defmodule LiveBookWeb.CellComponent do <% end %> -
"> +
"> <%= render_editor(@cell, @cell_info) %>
diff --git a/lib/live_book_web/live/icons.ex b/lib/live_book_web/live/icons.ex index a0fee0c1d..7a3321fe5 100644 --- a/lib/live_book_web/live/icons.ex +++ b/lib/live_book_web/live/icons.ex @@ -78,6 +78,16 @@ defmodule LiveBookWeb.Icons do """ end + def svg(:question_mark_circle, attrs) do + assigns = %{attrs: heroicon_svg_attrs(attrs)} + + ~L""" + <%= tag(:svg, @attrs) %> + + + """ + end + # https://heroicons.com defp heroicon_svg_attrs(attrs) do heroicon_svg_attrs = [ diff --git a/lib/live_book_web/live/modal_component.ex b/lib/live_book_web/live/modal_component.ex index 7afd8bf0b..9b7d56ac2 100644 --- a/lib/live_book_web/live/modal_component.ex +++ b/lib/live_book_web/live/modal_component.ex @@ -19,7 +19,7 @@ defmodule LiveBookWeb.ModalComponent do phx-page-loading>
-