refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

No files matched your search

@@ -0,0 +1,94 @@
import type { Drag, GestureHost } from './gesture-types'
import { setCurrentTime } from '../control/progress/position'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { clamp, secondToTime } from '../utils'
import { slideDirection } from './gesture-direction'
export function gestureController(art: GestureHost) {
let drag: Drag | undefined
const cancel = () => {
drag = undefined
}
const active = (current: Drag) => drag === current && !isClosing(art) && !art.isLock
&& !art.option.isLive && art.isRotate === current.rotated && current.sourceActive()
const onLock = (locked: boolean) => {
if (locked)
cancel()
}
art.on('document:touchend', cancel)
art.on('document:touchcancel', cancel)
art.on('lock', onLock)
getScope(art).add(() => {
cancel()
art.off('document:touchend', cancel)
art.off('document:touchcancel', cancel)
art.off('lock', onLock)
})
const start = (target: EventTarget, event: TouchEvent) => {
cancel()
const touch = event.touches[0]
if (isClosing(art) || art.isLock || art.option.isLive || event.touches.length !== 1 || !touch)
return
if (![touch.pageX, touch.pageY, touch.clientX, touch.clientY, art.currentTime].every(Number.isFinite))
return
const current: Drag = {
target,
identifier: touch.identifier,
x: touch.pageX,
y: touch.pageY,
time: art.currentTime,
rotated: art.isRotate,
sourceActive: captureSource(art),
}
drag = current
if (target === art.template.$progress) {
const size = art.isRotate ? art.height : art.template.$progress.clientWidth
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(art.duration) || art.duration <= 0) {
cancel()
return
}
try {
setCurrentTime(art, event, () => active(current))
}
catch (error) {
if (drag === current)
cancel()
throw error
}
if (active(current))
current.time = art.currentTime
}
}
const move = (event: TouchEvent) => {
const current = drag
const touch = event.touches[0]
if (!current)
return
if (!active(current) || event.touches.length !== 1 || !touch || touch.identifier !== current.identifier) {
cancel()
return
}
const size = current.rotated ? art.height : art.width
const multiplier = current.target === art.template.$video ? art.constructor.TOUCH_MOVE_RATIO : 1
if (![touch.pageX, touch.pageY, size, art.duration, multiplier].every(Number.isFinite) || size <= 0 || art.duration <= 0) {
cancel()
return
}
const direction = slideDirection(current.x, current.y, touch.pageX, touch.pageY)
if (!(current.rotated ? direction === 1 || direction === 2 : direction === 3 || direction === 4))
return
const distance = current.rotated ? touch.pageY - current.y : touch.pageX - current.x
const ratio = clamp(distance / size, -1, 1)
const time = clamp(current.time + art.duration * ratio * multiplier, 0, art.duration)
art.seek = time
if (!active(current))
return
art.emit('setBar', 'played', clamp(time / art.duration, 0, 1), event)
if (active(current))
art.notice.show = `${secondToTime(time)} / ${secondToTime(art.duration)}`
}
return { start, move, cancel }
}
@@ -0,0 +1,16 @@
export function slideDirection(startX: number, startY: number, endX: number, endY: number): 0 | 1 | 2 | 3 | 4 {
const dy = startY - endY
const dx = endX - startX
if (Math.abs(dx) < 2 && Math.abs(dy) < 2)
return 0
const angle = Math.atan2(dy, dx) * 180 / Math.PI
if (angle >= -45 && angle < 45)
return 4
if (angle >= 45 && angle < 135)
return 1
if (angle >= -135 && angle < -45)
return 2
if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135))
return 3
return 0
}
@@ -0,0 +1,26 @@
import type { SubscriptionHost } from '../component/resources'
import type { ProgressPositionHost } from '../control/progress/position'
export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
'document:touchend': [Event]
'document:touchcancel': [Event]
'lock': [boolean]
}> {
template: { $video: EventTarget, $progress: HTMLElement }
option: { isLive: boolean, gesture: boolean }
constructor: { TOUCH_MOVE_RATIO: number }
isLock: boolean
width: number
currentTime: number
notice: { show: string }
}
export interface Drag {
target: EventTarget
identifier: number
x: number
y: number
time: number
rotated: boolean
sourceActive: () => boolean
}
@@ -0,0 +1,27 @@
import type { HotkeyCallback, HotkeyHost } from './hotkey-types'
import { silencePromise } from '../utils/error'
export function defaultHotkeys<Host extends HotkeyHost>(art: Host): Record<string, HotkeyCallback<Host>> {
const { constructor } = art
return {
Escape: () => {
if (art.fullscreenWeb)
art.fullscreenWeb = false
},
Space: () => {
silencePromise(art.toggle())
},
ArrowLeft: () => {
art.backward = constructor.SEEK_STEP
},
ArrowUp: () => {
art.volume += constructor.VOLUME_STEP
},
ArrowRight: () => {
art.forward = constructor.SEEK_STEP
},
ArrowDown: () => {
art.volume -= constructor.VOLUME_STEP
},
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface HotkeyHost extends SubscriptionHost<{ 'document:keydown': [KeyboardEvent] }> {
constructor: { SEEK_STEP: number, VOLUME_STEP: number }
option: { hotkey?: boolean }
template: { $player: HTMLElement }
isFocus: boolean
fullscreenWeb: boolean
backward: number
forward: number
volume: number
toggle: () => unknown
emit: (name: 'hotkey' | 'keydown', event: KeyboardEvent) => unknown
}
export type HotkeyCallback<Host> = (this: Host, event: KeyboardEvent) => unknown
@@ -0,0 +1,30 @@
function asElement(target: EventTarget | null | undefined): HTMLElement | undefined {
if (target && (target as Node).nodeType === 1)
return target as HTMLElement
}
function eventDocument(event: KeyboardEvent, fallback: Document): Document {
const target = event.target as Node | null
return target?.nodeType === 9 ? target as Document : target?.ownerDocument || event.view?.document || fallback
}
function editable(target: HTMLElement): boolean {
const tag = target.tagName.toUpperCase()
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
return true
return target.isContentEditable
}
export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
return false
const doc = eventDocument(event, fallback)
let active = asElement(doc.activeElement)
while (active?.shadowRoot?.activeElement)
active = asElement(active.shadowRoot.activeElement)
if (active && editable(active))
return false
// The composed path exposes an editable descendant behind a retargeted shadow host.
const first = asElement(event.composedPath?.()[0]) || asElement(event.target)
return !first || !editable(first)
}
@@ -0,0 +1,21 @@
import type { FocusHost } from './pointer-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function pointerFocus(art: FocusHost): void {
const { $player } = art.template
const onDocumentClick = (event: Event) => {
if (isClosing(art))
return
const inside = includeFromEvent(event, $player)
art.isInput = inside && (event.target as Element | null)?.tagName === 'INPUT'
art.isFocus = inside
art.emit(inside ? 'focus' : 'blur', event)
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
getScope(art).add(() => {
art.off('document:click', onDocumentClick)
art.off('document:contextmenu', onDocumentClick)
})
}
@@ -0,0 +1,39 @@
import type { SubscriptionHost } from '../component/resources'
import type { Disposer, Listener } from '../events/listener-registry'
export interface PointerRegistry {
proxy: (target: EventTarget, name: string, callback: Listener) => Disposer
hover: (target: EventTarget, enter?: (event: Event) => unknown, leave?: (event: Event) => unknown) => void
}
export interface FocusHost extends SubscriptionHost<{ 'document:click': [Event], 'document:contextmenu': [Event] }> {
template: { $player: HTMLElement }
isInput: boolean
isFocus: boolean
emit: (name: 'focus' | 'blur', event: Event) => unknown
}
export interface ClickHost extends FocusHost {
constructor: {
MOBILE_CLICK_PLAY: boolean
MOBILE_DBCLICK_PLAY: boolean
DBCLICK_TIME: number
DBCLICK_FULLSCREEN: boolean
}
template: { $player: HTMLElement, $video: EventTarget }
isLock: boolean
fullscreen: boolean
toggle: () => unknown
emit: (name: 'focus' | 'blur' | 'click' | 'dblclick', event: Event) => unknown
}
export interface HoverHost {
template: { $player: HTMLElement }
emit: (name: 'hover', state: boolean, event: Event) => unknown
}
export interface MoveHost {
template: { $player: HTMLElement }
emit: (name: 'mousemove', event: Event) => unknown
}