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
This commit is contained in:
Jonatan Kłosko 2021-02-18 13:14:09 +01:00 committed by GitHub
parent 13f9b2b509
commit 0b77fd4279
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 330 additions and 81 deletions

View file

@ -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;

View file

@ -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;

3
assets/js/lib/utils.js Normal file
View file

@ -0,0 +1,3 @@
export function isMacOS() {
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
}

View file

@ -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",

View file

@ -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;

View file

@ -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.
#

View file

@ -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

View file

@ -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) %>
</div>
"""
@ -25,7 +25,7 @@ defmodule LiveBookWeb.CellComponent do
</div>
<% end %>
<div class="<%= if @expanded, do: "mb-4", else: "hidden" %>">
<div class="<%= if @focused and @insert_mode, do: "mb-4", else: "hidden" %>">
<%= render_editor(@cell, @cell_info) %>
</div>

View file

@ -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) %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
"""
end
# https://heroicons.com
defp heroicon_svg_attrs(attrs) do
heroicon_svg_attrs = [

View file

@ -19,7 +19,7 @@ defmodule LiveBookWeb.ModalComponent do
phx-page-loading></div>
<!-- Modal box -->
<div class="relative max-h-full overflow-y-auto bg-white rounded-md shadow-xl sm:max-w-2xl sm:w-full"
<div class="relative max-h-full overflow-y-auto bg-white rounded-md shadow-xl"
role="dialog"
aria-modal="true">

View file

@ -11,7 +11,7 @@ defmodule LiveBookWeb.RuntimeComponent do
@impl true
def render(assigns) do
~L"""
<div class="p-6">
<div class="p-6 sm:max-w-2xl sm:w-full">
<%= if @error_message do %>
<div class="mb-3 rounded-md px-4 py-2 bg-red-100 text-red-400 text-sm font-medium">
<%= @error_message %>

View file

@ -36,7 +36,7 @@ defmodule LiveBookWeb.SectionComponent do
cell: cell,
cell_info: @cell_infos[cell.id],
focused: @selected and cell.id == @focused_cell_id,
expanded: @selected and cell.id == @focused_cell_id and @focused_cell_expanded %>
insert_mode: @insert_mode %>
<%= live_component @socket, LiveBookWeb.InsertCellComponent,
id: "#{@section.id}:#{index + 1}",
section_id: @section.id,

View file

@ -16,13 +16,24 @@ defmodule LiveBookWeb.SessionLive do
Session.get_data(session_id)
end
{:ok, assign(socket, initial_assigns(session_id, data))}
platform = platform_from_socket(socket)
{:ok, assign(socket, initial_assigns(session_id, data, platform))}
else
{:ok, redirect(socket, to: Routes.sessions_path(socket, :page))}
end
end
defp initial_assigns(session_id, data) do
defp platform_from_socket(socket) do
with connect_info when connect_info != nil <- get_connect_info(socket),
{:ok, user_agent} <- Map.fetch(connect_info, :user_agent) do
platform_from_user_agent(user_agent)
else
_ -> nil
end
end
defp initial_assigns(session_id, data, platform) do
first_section_id =
case data.notebook.sections do
[section | _] -> section.id
@ -30,12 +41,13 @@ defmodule LiveBookWeb.SessionLive do
end
%{
platform: platform,
session_id: session_id,
data: data,
selected_section_id: first_section_id,
focused_cell_id: nil,
focused_cell_type: nil,
focused_cell_expanded: false
insert_mode: false
}
end
@ -45,15 +57,22 @@ defmodule LiveBookWeb.SessionLive do
<%= if @live_action == :runtime do %>
<%= live_modal @socket, LiveBookWeb.RuntimeComponent,
id: :runtime_modal,
action: :runtime,
return_to: Routes.session_path(@socket, :page, @session_id),
session_id: @session_id,
runtime: @data.runtime %>
<% end %>
<%= if @live_action == :shortcuts do %>
<%= live_modal @socket, LiveBookWeb.ShortcutsComponent,
id: :shortcuts_modal,
platform: @platform,
return_to: Routes.session_path(@socket, :page, @session_id) %>
<% end %>
<div class="flex flex-grow h-full"
id="session"
phx-hook="Session"
data-insert-mode="<%= @insert_mode %>"
data-focused-cell-id="<%= @focused_cell_id %>"
data-focused-cell-type="<%= @focused_cell_type %>">
<div class="flex flex-col w-1/5 bg-gray-100 border-r-2 border-gray-200">
@ -82,10 +101,13 @@ defmodule LiveBookWeb.SessionLive do
</div>
</button>
</div>
<div class="p-4">
<div class="p-4 flex space-x-2 justify-between">
<%= live_patch to: Routes.session_path(@socket, :runtime, @session_id) do %>
<%= Icons.svg(:chip, class: "h-6 w-6 text-gray-600 hover:text-current") %>
<% end %>
<%= live_patch to: Routes.session_path(@socket, :shortcuts, @session_id) do %>
<%= Icons.svg(:question_mark_circle, class: "h-6 w-6 text-gray-600 hover:text-current") %>
<% end %>
</div>
</div>
<div class="flex-grow px-6 py-8 flex overflow-y-auto">
@ -97,11 +119,18 @@ defmodule LiveBookWeb.SessionLive do
selected: section.id == @selected_section_id,
cell_infos: @data.cell_infos,
focused_cell_id: @focused_cell_id,
focused_cell_expanded: @focused_cell_expanded %>
insert_mode: @insert_mode %>
<% end %>
</div>
</div>
</div>
<%= if @insert_mode do %>
<%# Show a tiny insert indicator for clarity %>
<div class="fixed right-5 bottom-1 text-gray-500 text-semibold text-sm">
insert
</div>
<% end %>
"""
end
@ -234,9 +263,9 @@ defmodule LiveBookWeb.SessionLive do
end
end
def handle_event("toggle_cell_expanded", %{}, socket) do
def handle_event("set_insert_mode", %{"enabled" => enabled}, socket) do
if socket.assigns.focused_cell_id do
{:noreply, assign(socket, focused_cell_expanded: !socket.assigns.focused_cell_expanded)}
{:noreply, assign(socket, insert_mode: enabled)}
else
{:noreply, socket}
end
@ -256,6 +285,22 @@ defmodule LiveBookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("queue_section_cells_evaluation", %{}, socket) do
if socket.assigns.selected_section_id do
{:ok, section} =
Notebook.fetch_section(
socket.assigns.data.notebook,
socket.assigns.selected_section_id
)
for cell <- section.cells, cell.type == :elixir do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
end
end
{:noreply, socket}
end
def handle_event("queue_child_cells_evaluation", %{}, socket) do
if socket.assigns.focused_cell_id do
{:ok, cell, _section} =
@ -266,7 +311,7 @@ defmodule LiveBookWeb.SessionLive do
cells = Notebook.child_cells(socket.assigns.data.notebook, cell.id)
for cell <- [cell | cells], cell.type == :elixir do
for cell <- cells, cell.type == :elixir do
Session.queue_cell_evaluation(socket.assigns.session_id, cell.id)
end
end
@ -274,6 +319,11 @@ defmodule LiveBookWeb.SessionLive do
{:noreply, socket}
end
def handle_event("show_shortcuts", %{}, socket) do
{:noreply,
push_patch(socket, to: Routes.session_path(socket, :shortcuts, socket.assigns.session_id))}
end
@impl true
def handle_info({:operation, operation}, socket) do
case Session.Data.apply_operation(socket.assigns.data, operation) do
@ -315,7 +365,7 @@ defmodule LiveBookWeb.SessionLive do
defp after_operation(socket, _prev_socket, {:insert_cell, _, _, _, cell_id}) do
{:ok, cell, _section} = Notebook.fetch_cell_and_section(socket.assigns.data.notebook, cell_id)
focus_cell(socket, cell, expanded: true)
focus_cell(socket, cell, insert_mode: true)
end
defp after_operation(socket, prev_socket, {:delete_cell, cell_id}) do
@ -367,16 +417,16 @@ defmodule LiveBookWeb.SessionLive do
defp focus_cell(socket, cell, opts \\ [])
defp focus_cell(socket, nil = _cell, _opts) do
assign(socket, focused_cell_id: nil, focused_cell_type: nil, focused_cell_expanded: false)
assign(socket, focused_cell_id: nil, focused_cell_type: nil, insert_mode: false)
end
defp focus_cell(socket, cell, opts) do
expanded? = Keyword.get(opts, :expanded, false)
insert_mode? = Keyword.get(opts, :insert_mode, false)
assign(socket,
focused_cell_id: cell.id,
focused_cell_type: cell.type,
focused_cell_expanded: expanded?
insert_mode: insert_mode?
)
end

View file

@ -0,0 +1,102 @@
defmodule LiveBookWeb.ShortcutsComponent do
use LiveBookWeb, :live_component
@shortcuts %{
insert_mode: [
%{seq: "esc", desc: "Switch back to navigation mode"},
%{seq: "ctrl + enter", desc: "Evaluate cell and stay in insert mode"}
],
navigation_mode: [
%{seq: "?", desc: "Open this help modal"},
%{seq: "j", desc: "Focus next cell"},
%{seq: "k", desc: "Focus previous cell"},
%{seq: "i", desc: "Switch to insert mode"},
%{seq: "n", desc: "Insert Elixir cell below"},
%{seq: "m", desc: "Insert Markdown cell below"},
%{seq: "N", desc: "Insert Elixir cell above"},
%{seq: "M", desc: "Insert Markdown cell above"},
%{seq: "dd", desc: "Delete cell"},
%{seq: "ee", desc: "Evaluate cell"},
%{seq: "es", desc: "Evaluate section"},
%{seq: "ej", desc: "Evaluate cells below"}
]
}
@impl true
def mount(socket) do
{:ok, assign(socket, shortcuts: @shortcuts)}
end
@impl true
def render(assigns) do
~L"""
<div class="p-6 sm:max-w-4xl sm:w-full flex flex-col space-y-3">
<h3 class="text-lg font-medium text-gray-900">
Keyboard shortcuts
</h3>
<p class="text-gray-500">
LiveBook highly embraces keyboard navigation to improve your productivity.
It operates in one of two modes similarly to the Vim text editor.
In <span class="font-semibold">navigation mode</span> you move around
the notebook and execute commands, whereas in the <span class="font-semibold">insert mode</span>
you have editor focus and directly modify the given cell content.
</p>
<%= render_shortcuts_section("Navigation mode", @shortcuts.navigation_mode, @platform) %>
<%= render_shortcuts_section("Insert mode", @shortcuts.insert_mode, @platform) %>
</div>
"""
end
defp render_shortcuts_section(title, shortcuts, platform) do
{left, right} = split_in_half(shortcuts)
assigns = %{title: title, left: left, right: right, platform: platform}
~L"""
<h3 class="text-lg font-medium text-gray-900">
<%= @title %>
</h3>
<div class="mt-2 flex">
<div class="w-1/2">
<%= render_shortcuts_section_table(@left, @platform) %>
</div>
<div class="w-1/2">
<%= render_shortcuts_section_table(@right, @platform) %>
</div>
</div>
"""
end
defp render_shortcuts_section_table(shortcuts, platform) do
assigns = %{shortcuts: shortcuts, platform: platform}
~L"""
<table>
<tbody>
<%= for shortcut <- @shortcuts do %>
<tr>
<td class="py-1 pr-4">
<span class="bg-editor text-editor py-0.5 px-2 rounded-md inline-flex items-center">
<%= if(@platform == :mac, do: seq_for_mac(shortcut.seq), else: shortcut.seq) %>
</span>
</td>
<td>
<%= shortcut.desc %>
</td>
</tr>
<% end %>
</tbody>
</table>
"""
end
defp seq_for_mac(seq) do
seq
|> String.replace("ctrl", "cmd")
|> String.replace("alt", "option")
end
defp split_in_half(list) do
half_idx = list |> length() |> Kernel.+(1) |> div(2)
Enum.split(list, half_idx)
end
end

View file

@ -21,5 +21,6 @@ defmodule LiveBookWeb.Router do
live "/sessions", SessionsLive, :page
live "/sessions/:id", SessionLive, :page
live "/sessions/:id/runtime", SessionLive, :runtime
live "/sessions/:id/shortcuts", SessionLive, :shortcuts
end
end

View file

@ -26,7 +26,7 @@ defmodule LiveBook.Runtime.StandaloneTest do
end
test "loads necessary modules and starts manager process" do
assert {:ok, %{node: node} = runtime} = Runtime.Standalone.init(self())
assert {:ok, %{node: node}} = Runtime.Standalone.init(self())
assert evaluator_module_loaded?(node)
assert manager_started?(node)