Created
November 16, 2022 12:56
-
-
Save benvp/15fd4545e9399d725fd91a65808a8dc9 to your computer and use it in GitHub Desktop.
LiveView Shortcuts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<main id="main" phx-hook="GlobalShortcuts"> | |
<!-- content --> | |
</main> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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?.(); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); | |
}, | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<.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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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