mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-12-09 13:16:08 +08:00
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:
parent
13f9b2b509
commit
0b77fd4279
16 changed files with 330 additions and 81 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
3
assets/js/lib/utils.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function isMacOS() {
|
||||
return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
63
assets/js/session/key_buffer.js
Normal file
63
assets/js/session/key_buffer.js
Normal 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;
|
||||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
102
lib/live_book_web/live/shortcuts_component.ex
Normal file
102
lib/live_book_web/live/shortcuts_component.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue