packages/@interactjs/core/scope.ts
import browser from '@interactjs/utils/browser'
import clone from '@interactjs/utils/clone'
import domObjects from '@interactjs/utils/domObjects'
import extend from '@interactjs/utils/extend'
import is from '@interactjs/utils/is'
import raf from '@interactjs/utils/raf'
import * as win from '@interactjs/utils/window'
import type Interaction from '@interactjs/core/Interaction'
import { Eventable } from './Eventable'
/* eslint-disable import/no-duplicates -- for typescript module augmentations */
import './events'
import './interactions'
import events from './events'
import { Interactable as InteractableBase } from './Interactable'
import { InteractableSet } from './InteractableSet'
import { InteractEvent } from './InteractEvent'
import interactions from './interactions'
/* eslint-enable import/no-duplicates */
import { createInteractStatic } from './InteractStatic'
import type { OptionsArg } from './options'
import { defaults } from './options'
import type { Actions } from './types'
export interface SignalArgs {
'scope:add-document': DocSignalArg
'scope:remove-document': DocSignalArg
'interactable:unset': { interactable: InteractableBase }
'interactable:set': { interactable: InteractableBase; options: OptionsArg }
'interactions:destroy': { interaction: Interaction }
}
export type ListenerName = keyof SignalArgs
export type ListenerMap = {
[P in ListenerName]?: (arg: SignalArgs[P], scope: Scope, signalName: P) => void | boolean
}
interface DocSignalArg {
doc: Document
window: Window
scope: Scope
options: Record<string, any>
}
/** @internal */
export interface Plugin {
[key: string]: any
id?: string
listeners?: ListenerMap
before?: string[]
install?(scope: Scope, options?: any): void
}
/** @internal */
export class Scope {
id = `__interact_scope_${Math.floor(Math.random() * 100)}`
isInitialized = false
listenerMaps: Array<{
map: ListenerMap
id?: string
}> = []
browser = browser
defaults = clone(defaults) as typeof defaults
Eventable = Eventable
actions: Actions = {
map: {},
phases: {
start: true,
move: true,
end: true,
},
methodDict: {} as any,
phaselessTypes: {},
}
interactStatic = createInteractStatic(this)
InteractEvent = InteractEvent
Interactable: typeof InteractableBase
interactables = new InteractableSet(this)
// main window
_win!: Window
// main document
document!: Document
// main window
window!: Window
// all documents being listened to
documents: Array<{ doc: Document; options: any }> = []
_plugins: {
list: Plugin[]
map: { [id: string]: Plugin }
} = {
list: [],
map: {},
}
constructor() {
const scope = this
this.Interactable = class extends InteractableBase {
get _defaults() {
return scope.defaults
}
set<T extends InteractableBase>(this: T, options: OptionsArg) {
super.set(options)
scope.fire('interactable:set', {
options,
interactable: this,
})
return this
}
unset(this: InteractableBase) {
super.unset()
const index = scope.interactables.list.indexOf(this)
if (index < 0) return
scope.interactables.list.splice(index, 1)
scope.fire('interactable:unset', { interactable: this })
}
}
}
addListeners(map: ListenerMap, id?: string) {
this.listenerMaps.push({ id, map })
}
fire<T extends ListenerName>(name: T, arg: SignalArgs[T]): void | false {
for (const {
map: { [name]: listener },
} of this.listenerMaps) {
if (!!listener && listener(arg as any, this, name as never) === false) {
return false
}
}
}
onWindowUnload = (event: BeforeUnloadEvent) => this.removeDocument(event.target as Document)
init(window: Window | typeof globalThis) {
return this.isInitialized ? this : initScope(this, window)
}
pluginIsInstalled(plugin: Plugin) {
const { id } = plugin
return id ? !!this._plugins.map[id] : this._plugins.list.indexOf(plugin) !== -1
}
usePlugin(plugin: Plugin, options?: { [key: string]: any }) {
if (!this.isInitialized) {
return this
}
if (this.pluginIsInstalled(plugin)) {
return this
}
if (plugin.id) {
this._plugins.map[plugin.id] = plugin
}
this._plugins.list.push(plugin)
if (plugin.install) {
plugin.install(this, options)
}
if (plugin.listeners && plugin.before) {
let index = 0
const len = this.listenerMaps.length
const before = plugin.before.reduce((acc, id) => {
acc[id] = true
acc[pluginIdRoot(id)] = true
return acc
}, {})
for (; index < len; index++) {
const otherId = this.listenerMaps[index].id
if (otherId && (before[otherId] || before[pluginIdRoot(otherId)])) {
break
}
}
this.listenerMaps.splice(index, 0, { id: plugin.id, map: plugin.listeners })
} else if (plugin.listeners) {
this.listenerMaps.push({ id: plugin.id, map: plugin.listeners })
}
return this
}
addDocument(doc: Document, options?: any): void | false {
// do nothing if document is already known
if (this.getDocIndex(doc) !== -1) {
return false
}
const window = win.getWindow(doc)
options = options ? extend({}, options) : {}
this.documents.push({ doc, options })
this.events.documents.push(doc)
// don't add an unload event for the main document
// so that the page may be cached in browser history
if (doc !== this.document) {
this.events.add(window, 'unload', this.onWindowUnload)
}
this.fire('scope:add-document', { doc, window, scope: this, options })
}
removeDocument(doc: Document) {
const index = this.getDocIndex(doc)
const window = win.getWindow(doc)
const options = this.documents[index].options
this.events.remove(window, 'unload', this.onWindowUnload)
this.documents.splice(index, 1)
this.events.documents.splice(index, 1)
this.fire('scope:remove-document', { doc, window, scope: this, options })
}
getDocIndex(doc: Document) {
for (let i = 0; i < this.documents.length; i++) {
if (this.documents[i].doc === doc) {
return i
}
}
return -1
}
getDocOptions(doc: Document) {
const docIndex = this.getDocIndex(doc)
return docIndex === -1 ? null : this.documents[docIndex].options
}
now() {
return (((this.window as any).Date as typeof Date) || Date).now()
}
}
/** @internal */
export function initScope(scope: Scope, window: Window | typeof globalThis) {
scope.isInitialized = true
if (is.window(window)) {
win.init(window)
}
domObjects.init(window)
browser.init(window)
raf.init(window)
// @ts-expect-error
scope.window = window
scope.document = window.document
scope.usePlugin(interactions)
scope.usePlugin(events)
return scope
}
function pluginIdRoot(id: string) {
return id && id.replace(/\/.*$/, '')
}