Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import type { HstEvent } from '../../stores/events'
import type { Story, Variant } from '../../types'
import { applyState } from '@histoire/shared'
import { useEventListener } from '@vueuse/core'
import { computed, ref, toRaw, watch } from 'vue'
import { computed, onBeforeUnmount, ref, toRaw, watch } from 'vue'
import { useEventsStore } from '../../stores/events'
import { usePreviewSettingsStore } from '../../stores/preview-settings'
import { EVENT_SEND, PREVIEW_SETTINGS_SYNC, SANDBOX_READY, STATE_SYNC } from '../../util/const'
import { trackWindow } from '../../util/keyboard'
import { getSandboxUrl } from '../../util/sandbox'
import { toRawDeep } from '../../util/state'
import StoryResponsivePreview from './StoryResponsivePreview.vue'
Expand Down Expand Up @@ -84,11 +85,21 @@ const sandboxUrl = computed(() => {

const isIframeLoaded = ref(false)

let stopTrackKeyboard: (() => void) | undefined
let unmounted = false

watch(sandboxUrl, () => {
isIframeLoaded.value = false
Object.assign(props.variant, {
previewReady: false,
})
stopTrackKeyboard?.()
stopTrackKeyboard = undefined
})

onBeforeUnmount(() => {
unmounted = true
stopTrackKeyboard?.()
})

// Settings
Expand All @@ -112,9 +123,14 @@ watch(() => settings, () => {
// Iframe load

function onIframeLoad() {
if (unmounted) return
isIframeLoaded.value = true
syncState()
syncSettings()
stopTrackKeyboard?.()
if (iframe.value?.contentWindow) {
stopTrackKeyboard = trackWindow(iframe.value.contentWindow)
}
}
</script>

Expand Down
92 changes: 60 additions & 32 deletions packages/histoire-app/src/app/util/keyboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Ref } from 'vue'
import { useEventListener } from '@vueuse/core'
import { isRef } from 'vue'
import { isRef, ref } from 'vue'
import { isMac } from './env.js'

export type KeyboardShortcut = string[]
Expand All @@ -11,14 +11,6 @@ export interface KeyboardShortcutOptions {
event?: 'keyup' | 'keydown' | 'keypress'
}

export function onKeyboardShortcut(shortcut: KeyboardShortcut | Ref<KeyboardShortcut>, handler: KeyboardHandler, options: KeyboardShortcutOptions = {}) {
useEventListener(options.event ?? 'keydown', (event) => {
if (isMatchingShortcut(isRef(shortcut) ? shortcut.value : shortcut)) {
handler(event)
}
})
}

const modifiers: { [i: string]: { key: string, pressed: boolean } } = {
ctrl: { key: 'Control', pressed: false },
alt: { key: 'Alt', pressed: false },
Expand All @@ -28,37 +20,73 @@ const modifiers: { [i: string]: { key: string, pressed: boolean } } = {

const pressedKeys = new Set<string>()

window.addEventListener('keydown', (event) => {
for (const i in modifiers) {
const mod = modifiers[i]
if (mod.key === event.key) {
mod.pressed = true
return
}
}
pressedKeys.add(event.key.toLocaleLowerCase())
})
const trackedWindows = ref<Window[]>([])

window.addEventListener('keyup', (event) => {
requestAnimationFrame(() => {
pressedKeys.clear()
function bindTracking(target: Window) {
const onKeydown = (event: KeyboardEvent) => {
for (const i in modifiers) {
const mod = modifiers[i]
if (mod.key === event.key) {
mod.pressed = false
break
mod.pressed = true
return
}
}
})
})
pressedKeys.add(event.key.toLocaleLowerCase())
}
const onKeyup = (event: KeyboardEvent) => {
requestAnimationFrame(() => {
pressedKeys.clear()
for (const i in modifiers) {
const mod = modifiers[i]
if (mod.key === event.key) {
mod.pressed = false
break
}
}
})
}
const onBlur = () => {
pressedKeys.clear()
for (const i in modifiers) {
modifiers[i].pressed = false
}
}
target.addEventListener('keydown', onKeydown)
target.addEventListener('keyup', onKeyup)
target.addEventListener('blur', onBlur)
return () => {
target.removeEventListener('keydown', onKeydown)
target.removeEventListener('keyup', onKeyup)
target.removeEventListener('blur', onBlur)
}
}

window.addEventListener('blur', () => {
pressedKeys.clear()
for (const i in modifiers) {
const mod = modifiers[i]
mod.pressed = false
// Forward shortcuts from sandbox iframes. Closes #350.
export function trackWindow(target: Window): () => void {
if (trackedWindows.value.includes(target)) return () => {}
const cleanup = bindTracking(target)
trackedWindows.value = [...trackedWindows.value, target]
return () => {
cleanup()
trackedWindows.value = trackedWindows.value.filter(w => w !== target)
}
})
}
Comment on lines +65 to +73
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Accessing or adding event listeners to a cross-origin iframe's contentWindow will throw a security error (DOMException). Since Histoire sandboxes can be hosted on different origins (e.g., to prevent CSS/JS leakage), we should wrap the tracking initialization in a try-catch block to prevent the application from crashing when a cross-origin iframe is loaded.

export function trackWindow(target: Window): () => void {
  if (trackedWindows.value.includes(target)) return () => {}
  try {
    const cleanup = bindTracking(target)
    trackedWindows.value = [...trackedWindows.value, target]
    return () => {
      cleanup()
      trackedWindows.value = trackedWindows.value.filter(w => w !== target)
    }
  } catch (e) {
    return () => {}
  }
}


trackWindow(window)

export function onKeyboardShortcut(shortcut: KeyboardShortcut | Ref<KeyboardShortcut>, handler: KeyboardHandler, options: KeyboardShortcutOptions = {}) {
useEventListener(trackedWindows, options.event ?? 'keydown', (event: KeyboardEvent) => {
// Sync modifier state from the event so a blur-clear (e.g. focusing an
// iframe while holding a modifier) doesn't drop the shortcut.
modifiers.ctrl.pressed = event.ctrlKey
modifiers.alt.pressed = event.altKey
modifiers.shift.pressed = event.shiftKey
modifiers.meta.pressed = event.metaKey
if (isMatchingShortcut(isRef(shortcut) ? shortcut.value : shortcut)) {
handler(event)
}
})
}
Comment on lines +77 to +89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The global modifiers state is cleared whenever a tracked window blurs (see onBlur at line 48). This creates a race condition: if a user holds a modifier (like Cmd) and clicks into the iframe, the main window blurs, clearing the state before the shortcut key is pressed in the iframe. To fix this, we should synchronize the modifiers state with the current event's modifier properties (ctrlKey, metaKey, etc.) before checking the shortcut.

export function onKeyboardShortcut(shortcut: KeyboardShortcut | Ref<KeyboardShortcut>, handler: KeyboardHandler, options: KeyboardShortcutOptions = {}) {
  useEventListener(trackedWindows, options.event ?? 'keydown', (event: KeyboardEvent) => {
    modifiers.ctrl.pressed = event.ctrlKey
    modifiers.alt.pressed = event.altKey
    modifiers.shift.pressed = event.shiftKey
    modifiers.meta.pressed = event.metaKey
    if (isMatchingShortcut(isRef(shortcut) ? shortcut.value : shortcut)) {
      handler(event)
    }
  })
}


function isMatchingShortcut(shortcut: KeyboardShortcut): boolean {
for (const combination of shortcut) {
Expand Down
Loading