Skip to content

Instantly share code, notes, and snippets.

@benvp
Created November 16, 2022 12:56
Show Gist options
  • Save benvp/15fd4545e9399d725fd91a65808a8dc9 to your computer and use it in GitHub Desktop.
Save benvp/15fd4545e9399d725fd91a65808a8dc9 to your computer and use it in GitHub Desktop.
LiveView Shortcuts
<main id="main" phx-hook="GlobalShortcuts">
<!-- content -->
</main>
import { Hook } from '~/types/phoenix_live_view';
import { KeyBuffer } from '~lib/KeyBuffer';
import { isMacOS } from '~lib/utils';
import { maybeNavigate } from './helpers';
import { ShortcutState, state } from './state';
type GlobalShortcuts = {
keyBuffer: KeyBuffer;
unregisterEventHandlers: () => void;
registerGlobalShortcuts: () => void;
unregisterGlobalShortcuts: () => void;
unregisterStateHandlers: () => void;
};
export type GlobalShortcutsHook = Hook<GlobalShortcuts>;
export const GlobalShortcuts: GlobalShortcutsHook = {
mounted() {
this.keyBuffer = new KeyBuffer();
this.unregisterEventHandlers = registerEventHandlers(this.keyBuffer, state);
this.unregisterStateHandlers = registerStateHandlers(state);
this.registerGlobalShortcuts();
},
destroyed() {
this.unregisterGlobalShortcuts();
this.unregisterEventHandlers?.();
this.unregisterStateHandlers?.();
},
registerGlobalShortcuts() {
state.register('g c', () => maybeNavigate('/subscribers', this.liveSocket));
state.register('g t', () => maybeNavigate('/temp', this.liveSocket));
},
unregisterGlobalShortcuts() {
state.unregister('g c');
state.unregister('g t');
},
};
function registerEventHandlers(keyBuffer: KeyBuffer, state: ShortcutState) {
const handleDocumentKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
const modifiers: Record<string, boolean> = {
cmd: isMacOS() ? event.metaKey : event.ctrlKey,
alt: event.altKey,
shift: event.shiftKey,
meta: event.metaKey,
};
keyBuffer.push(event.key.toLowerCase());
// call tryMatch for every shortcut in the registry
// if a match is found, call the handler
// if no match is found, do nothing
for (const [keyString, shortcut] of state.shortcuts) {
if (!shortcut.enabled) {
continue;
}
const isCombined = /^(\S+\+\S+)+$/.test(keyString);
if (isCombined) {
const keys = keyString.split('+');
const isMatch = keys.every(key =>
typeof modifiers[key] !== 'undefined' ? modifiers[key] : key === event.key.toLowerCase(),
);
if (isMatch) {
shortcut.handler(shortcut);
break;
}
}
const seq = keyString.split(' ');
if (keyBuffer.tryMatch(seq)) {
shortcut.handler(shortcut);
break;
}
}
};
document.addEventListener('keydown', handleDocumentKeyDown, true);
return () => document.removeEventListener('keydown', handleDocumentKeyDown, true);
}
interface ShortcutEnableEvent extends Event {
readonly detail: {
exclude: string[] | null;
only: string[] | null;
};
target: HTMLElement;
}
function registerStateHandlers(state: ShortcutState) {
const enable = () => state.enableShortcuts();
const disable = (e: Event) => {
const { detail } = e as ShortcutEnableEvent;
if (detail.only) {
state.shortcuts.forEach(
(_s, seq) => detail.only?.includes(seq) && state.disableShortcut(seq),
);
}
if (detail.exclude) {
state.shortcuts.forEach(
(_s, seq) => detail.exclude?.includes(seq) || state.disableShortcut(seq),
);
}
};
window.addEventListener('sc:enable', enable);
window.addEventListener('sc:disable', disable);
return () => {
document.removeEventListener('sc:enable', enable);
document.removeEventListener('sc:disable', disable);
};
}
import { triggerSubmit } from './helpers';
export type CustomHandler = (...args: any[]) => void;
export function createHandlers() {
return new Map<string, CustomHandler>([['submit', submit]]);
}
function submit(selector: string) {
const el = document.querySelector<HTMLFormElement>(selector);
if (el) {
triggerSubmit(el);
}
}
import { expandPath } from '~lib/utils';
/**
* Triggers a redirect if the target is a different page.
*/
export function maybeNavigate(path: string, liveSocket: any) {
const expanded = expandPath(path);
if (expanded === window.location.href) {
return;
}
liveSocket.historyRedirect(expandPath(path), 'push');
}
export function triggerSubmit(el: HTMLFormElement) {
if (el.checkValidity?.()) {
el.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
} else {
el.reportValidity?.();
}
}
/**
* Allows for recording a sequence of keys pressed
* and matching against that sequence.
*
* Taken from the livebook repo and adapted a little bit.
*/
export class KeyBuffer {
private resetTimeout: number;
private buffer: string[] = [];
private resetTimeoutId: number | null = null;
/**
* @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;
}
/**
* Adds a new key to the buffer and renews the reset timeout.
*/
push(key: string) {
this.buffer.push(key);
if (this.resetTimeoutId) {
clearTimeout(this.resetTimeoutId);
}
this.resetTimeoutId = setTimeout(() => {
this.reset();
}, this.resetTimeout);
}
/**
* Immediately clears the buffer.
*/
reset() {
if (this.resetTimeoutId) {
clearTimeout(this.resetTimeoutId);
}
this.resetTimeoutId = 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: string[]) {
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;
}
}
defmodule PigeonWeb.Components.Shortcut do
use Phoenix.Component
alias Phoenix.LiveView.JS
attr :id, :string, required: true
attr :seq, :string, required: true
attr :handler, :any,
default: %JS{},
doc: "Phoenix.LiveView.JS struct or a string which will push an event to the server."
attr :as, :string, default: "div"
attr :rest, :global
slot :inner_block, required: true
slot :tooltip do
attr :platform, :string, required: true
attr :placement, :string,
values:
~w(top top-start top-end right right-start-right-end bottom bottom-start bottom-end left left-start left-end)
end
def shortcut(assigns) do
~H"""
<.dynamic_tag
id={"sc-#{@id}"}
name={@as}
phx-hook="Shortcut"
data-seq={@seq}
data-handler={maybe_create_event(@handler)}
{@rest}
>
<div id={"sc-#{@id}-content"}><%= render_slot(@inner_block) %></div>
<div
id={"sc-#{@id}-tooltip"}
role="tooltip"
class="hidden absolute z-20 top-0 left-0 px-2 py-1 bg-slate-50 border border-slate-100 rounded"
data-show={show_tooltip("sc-#{@id}-tooltip")}
data-hide={hide_tooltip("sc-#{@id}-tooltip")}
data-placement={Map.get(hd(@tooltip), :placement)}
>
<div class="flex items-center space-x-2">
<div class="text-sm">
<%= render_slot(@tooltip) %>
</div>
<.shortcut_label seq={@seq} platform={Map.get(hd(@tooltip), :platform)} />
</div>
</div>
</.dynamic_tag>
"""
end
attr :seq, :string, required: true
attr :platform, :atom, default: :other, values: [:mac, :linux, :windows, :other]
defp shortcut_label(assigns) do
combined = String.contains?(assigns.seq, "+")
keys = if combined, do: String.split(assigns.seq, "+"), else: String.split(assigns.seq, " ")
assigns =
assigns
|> Map.put(:combined, combined)
|> Map.put(:keys, keys)
~H"""
<div class="flex space-x-1 items-center">
<%= if @combined do %>
<kbd
:for={key <- @keys}
class="inline-block font-sans bg-slate-200 border border-slate-300 rounded px-1 py-0.5 leading-none text-xs"
>
<%= symbol_for_key(key, @platform) %>
</kbd>
<% else %>
<.intersperse :let={key} enum={@keys}>
<kbd class="inline-block font-sans bg-slate-200 border border-slate-300 rounded px-1 py-0.5 leading-none text-xs">
<%= symbol_for_key(key, @platform) %>
</kbd>
<:separator>
<span class="text-xs">then</span>
</:separator>
</.intersperse>
<% end %>
</div>
"""
end
defp symbol_for_key(key, platform) do
case key do
" " -> "Space"
"cmd" -> if platform == :mac, do: "⌘", else: "Ctrl"
"shift" -> "⇧"
"alt" -> "⌥"
"enter" -> "Enter"
"esc" -> "Esc"
_ -> String.upcase(key)
end
end
@doc """
Calls a wired up event listener call a registered shortcut handler.
"""
def js_exec_shortcut(js \\ %JS{}, name, args) do
JS.dispatch(js, "sc:exec", detail: %{name: name, args: args})
end
def disable_shortcuts(js \\ %JS{}, opts) do
JS.dispatch(js, "sc:disable", detail: %{exclude: opts[:exclude], only: opts[:only]})
end
def enable_shortcuts(js \\ %JS{}) do
JS.dispatch(js, "sc:enable")
end
defp show_tooltip(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(
to: "##{id}",
time: 100,
transition:
{"transition ease-out duration-100", "transform opacity-0 scale-95",
"transform opacity-100 scale-100"}
)
end
defp hide_tooltip(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.hide(
to: "##{id}",
time: 75,
transition:
{"transition ease-in duration-75", "transform opacity-100 scale-100",
"transform opacity-0 scale-95"}
)
end
defp maybe_create_event(event) when is_binary(event) do
Phoenix.LiveView.JS.push(event)
end
defp maybe_create_event(event), do: event
end
import { computePosition, offset, Placement } from '@floating-ui/dom';
import { Hook } from '~/types/phoenix_live_view';
import { state } from './state';
interface ExecShortcutEvent extends Event {
readonly detail: {
name: string;
args: any[];
};
target: HTMLElement;
}
let execListenerAdded = false;
type Shortcut = {
getSeq(): string | undefined;
getHandler(): string | undefined;
registerCustomHandlers(): void;
unregisterCustomHandlers(): void;
maybeSetupExecListener(): void;
setupTooltip(): void;
};
export type ShortcutHook = Hook<Shortcut>;
export const Shortcut: ShortcutHook = {
getSeq() {
return this.el?.dataset.seq;
},
getHandler() {
return this.el?.dataset.handler;
},
mounted() {
this.maybeSetupExecListener();
this.setupTooltip();
const seq = this.getSeq();
if (seq) {
state.register(seq, () => this.liveSocket.execJS(this.el, this.getHandler()));
}
},
destroyed() {
const seq = this.getSeq();
if (seq) {
state.unregister(seq);
}
},
maybeSetupExecListener() {
const execListener = (e: Event) => {
const { detail } = e as ExecShortcutEvent;
state.handlers.get(detail.name)?.(...detail.args);
};
if (!execListenerAdded) {
window.addEventListener('sc:exec', execListener);
execListenerAdded = true;
}
},
setupTooltip() {
const content = document.getElementById(`${this.el.id}-content`);
const tooltip = document.getElementById(`${this.el.id}-tooltip`);
const update = async () => {
if (content && tooltip) {
const { x, y } = await computePosition(content, tooltip, {
middleware: [offset(8)],
...(tooltip.dataset.placement && { placement: tooltip.dataset.placement as Placement }),
});
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
}
};
let timeout: number | null = null;
const show = () => {
if (tooltip) {
timeout = setTimeout(() => {
this.liveSocket.execJS(tooltip, tooltip.dataset.show);
update();
}, 500);
}
};
const hide = () => {
if (timeout) {
clearTimeout(timeout);
}
if (tooltip) {
this.liveSocket.execJS(tooltip, tooltip.dataset.hide);
}
};
const eventTuples: [keyof HTMLElementEventMap, () => void][] = [
['mouseenter', show],
['mouseleave', hide],
['focus', show],
['blur', hide],
];
eventTuples.forEach(([event, listener]) => {
content?.addEventListener(event, listener);
});
},
};
<.shortcut
id="shortcut-test"
seq={@shortcuts[:open_create_modal]}
handler={JS.patch(~p"/subscribers/new")}
>
<.link_icon_button class="ml-2" patch={~p"/subscribers/new"}>
<Heroicons.plus class="w-4 h-4" />
</.link_icon_button>
<:tooltip placement="right" platform={@platform}>
Add subscriber
</:tooltip>
</.shortcut>
import { createHandlers } from './handlers';
export type ShortcutHandler = (shortcut: Shortcut) => void;
export type Shortcut = {
handler: ShortcutHandler;
enabled: boolean;
};
export class ShortcutState {
shortcuts = new Map<string, Shortcut>();
/**
* Map of handlers which are called via the js_exec_shortcut function.
*/
handlers = createHandlers();
register(seq: string, handler: ShortcutHandler) {
this.shortcuts.set(seq, { handler, enabled: true });
}
unregister(seq: string) {
this.shortcuts.delete(seq);
}
enableShortcut(seq: string) {
const shortcut = this.shortcuts.get(seq);
if (shortcut) {
shortcut.enabled = true;
}
}
disableShortcut(seq: string) {
const shortcut = this.shortcuts.get(seq);
if (shortcut) {
shortcut.enabled = false;
}
}
enableShortcuts() {
this.shortcuts.forEach(s => (s.enabled = true));
}
disableShortcuts() {
this.shortcuts.forEach(s => (s.enabled = false));
}
}
export const state = new ShortcutState();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment