Files
logseq/libs/src/LSPlugin.caller.ts
Charlie 72c038e6fe Feat/moveable plugin UI container (#3045)
* improve(plugin): add container for main ui frame

* feat(plugin): support draggable & resizable UI container for main ui

* feat: support fork sub layout container

* improve(plugin): add editor selection api

* improve(plugin): click outside configure for float container

* improve(plugin): api of journal for create-page

* improve(plugin): api of open-in-right-sidebar

* improve(plugin): add full screen api

* improve(plugin): api of register-palette-command

* improve(plugin): add apis
2021-11-15 16:57:20 +08:00

378 lines
9.5 KiB
TypeScript

import Debug from 'debug'
import { Postmate, Model, ParentAPI, ChildAPI } from './postmate'
import EventEmitter from 'eventemitter3'
import { PluginLocal } from './LSPlugin.core'
import { deferred, IS_DEV } from './helpers'
import { LSPluginShadowFrame } from './LSPlugin.shadow'
const debug = Debug('LSPlugin:caller')
type DeferredActor = ReturnType<typeof deferred>
export const FLAG_AWAIT = '#await#response#'
export const LSPMSG = '#lspmsg#'
export const LSPMSG_ERROR_TAG = '#lspmsg#error#'
export const LSPMSG_SETTINGS = '#lspmsg#settings#'
export const LSPMSG_BEFORE_UNLOAD = '#lspmsg#beforeunload#'
export const LSPMSG_SYNC = '#lspmsg#reply#'
export const LSPMSG_READY = '#lspmsg#ready#'
export const LSPMSGFn = (id: string) => `${LSPMSG}${id}`
export const AWAIT_LSPMSGFn = (id: string) => `${FLAG_AWAIT}${id}`
/**
* Call between core and user
*/
class LSPluginCaller extends EventEmitter {
private _connected: boolean = false
private _parent?: ParentAPI
private _child?: ChildAPI
private _shadow?: LSPluginShadowFrame
private _status?: 'pending' | 'timeout'
private _userModel: any = {}
private _call?: (type: string, payload: any, actor?: DeferredActor) => Promise<any>
private _callUserModel?: (type: string, payload: any) => Promise<any>
private _debugTag = ''
constructor (
private _pluginLocal: PluginLocal | null
) {
super()
if (_pluginLocal) {
this._debugTag = _pluginLocal.debugTag
}
}
async connectToChild () {
if (this._connected) return
const { shadow } = this._pluginLocal!
if (shadow) {
await this._setupShadowSandbox()
} else {
await this._setupIframeSandbox()
}
}
// run in sandbox
async connectToParent (userModel = {}) {
if (this._connected) return
const caller = this
const isShadowMode = this._pluginLocal != null
let syncGCTimer: any = 0
let syncTag = 0
const syncActors = new Map<number, DeferredActor>()
const readyDeferred = deferred(1000 * 5)
const model: any = this._extendUserModel({
[LSPMSG_READY]: async (baseInfo) => {
// dynamically setup common msg handler
model[LSPMSGFn(baseInfo?.pid)] = ({ type, payload }: { type: string, payload: any }) => {
debug(`[call from host (_call)] ${this._debugTag}`, type, payload)
// host._call without async
caller.emit(type, payload)
}
await readyDeferred.resolve()
},
[LSPMSG_BEFORE_UNLOAD]: async (e) => {
const actor = deferred(10 * 1000)
caller.emit('beforeunload', Object.assign({ actor }, e))
await actor.promise
},
[LSPMSG_SETTINGS]: async ({ type, payload }) => {
caller.emit('settings:changed', payload)
},
[LSPMSG]: async ({ ns, type, payload }: any) => {
debug(`[call from host (async)] ${this._debugTag}`, ns, type, payload)
if (ns && ns.startsWith('hook')) {
caller.emit(`${ns}:${type}`, payload)
return
}
caller.emit(type, payload)
},
[LSPMSG_SYNC]: ({ _sync, result }: any) => {
debug(`[sync reply] #${_sync}`, result)
if (syncActors.has(_sync)) {
const actor = syncActors.get(_sync)
if (actor) {
if (result?.hasOwnProperty(LSPMSG_ERROR_TAG)) {
actor.reject(result[LSPMSG_ERROR_TAG])
} else {
actor.resolve(result)
}
syncActors.delete(_sync)
}
}
},
...userModel
})
if (isShadowMode) {
await readyDeferred.promise
return JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()))
}
const pm = new Model(model)
const handshake = pm.sendHandshakeReply()
this._status = 'pending'
await handshake.then((refParent: ChildAPI) => {
this._child = refParent
this._connected = true
this._call = async (type, payload = {}, actor) => {
if (actor) {
const tag = ++syncTag
syncActors.set(tag, actor)
payload._sync = tag
actor.setTag(`async call #${tag}`)
debug('async call #', tag)
}
refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
return actor?.promise as Promise<any>
}
this._callUserModel = async (type, payload) => {
try {
model[type](payload)
} catch (e) {
debug(`[model method] #${type} not existed`)
}
}
// actors GC
syncGCTimer = setInterval(() => {
if (syncActors.size > 100) {
for (const [k, v] of syncActors) {
if (v.settled) {
syncActors.delete(k)
}
}
}
}, 1000 * 60 * 30)
}).finally(() => {
this._status = undefined
})
await readyDeferred.promise
return model.baseInfo
}
async call (type: any, payload: any = {}) {
return this._call?.call(this, type, payload)
}
async callAsync (type: any, payload: any = {}) {
const actor = deferred(1000 * 10)
return this._call?.call(this, type, payload, actor)
}
async callUserModel (type: string, payload: any = {}) {
return this._callUserModel?.call(this, type, payload)
}
// run in host
async _setupIframeSandbox () {
const pl = this._pluginLocal!
const id = pl.id
const url = new URL(pl.options.entry!)
url.searchParams
.set(`__v__`, IS_DEV ? Date.now().toString() : pl.options.version)
// clear zombie sandbox
const zb = document.querySelector(`#${id}`)
if (zb) zb.parentElement.removeChild(zb)
const cnt = document.createElement('div')
cnt.classList.add('lsp-iframe-sandbox-container')
cnt.id = id
// TODO: apply any container layout data
{
const mainLayoutInfo = this._pluginLocal.settings.get('layout')?.[0]
if (mainLayoutInfo) {
cnt.dataset.inited_layout = 'true'
const { width, height, left, top } = mainLayoutInfo
Object.assign(cnt.style, {
width: width + 'px', height: height + 'px',
left: left + 'px', top: top + 'px'
})
}
}
document.body.appendChild(cnt)
const pt = new Postmate({
id: id + '_iframe', container: cnt, url: url.href,
classListArray: ['lsp-iframe-sandbox'],
model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
})
let handshake = pt.sendHandshake()
this._status = 'pending'
// timeout for handshake
let timer
return new Promise((resolve, reject) => {
timer = setTimeout(() => {
reject(new Error(`handshake Timeout`))
}, 3 * 1000) // 3secs
handshake.then((refChild: ParentAPI) => {
this._parent = refChild
this._connected = true
this.emit('connected')
refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
debug(`[call from plugin] `, type, payload)
this._pluginLocal?.emit(type, payload || {})
})
this._call = async (...args: any) => {
// parent all will get message before handshaked
await refChild.call(LSPMSGFn(pl.id), {
type: args[0], payload: Object.assign(args[1] || {}, {
$$pid: pl.id
})
})
}
this._callUserModel = async (type, payload: any) => {
if (type.startsWith(FLAG_AWAIT)) {
// TODO: attach payload with method call
return await refChild.get(type.replace(FLAG_AWAIT, ''))
} else {
refChild.call(type, payload)
}
}
resolve(null)
}).catch(e => {
reject(e)
}).finally(() => {
clearTimeout(timer)
})
}).catch(e => {
debug('[iframe sandbox] error', e)
throw e
}).finally(() => {
this._status = undefined
})
}
async _setupShadowSandbox () {
const pl = this._pluginLocal!
const shadow = this._shadow = new LSPluginShadowFrame(pl)
try {
this._status = 'pending'
await shadow.load()
this._connected = true
this.emit('connected')
this._call = async (type, payload = {}, actor) => {
actor && (payload.actor = actor)
// @ts-ignore Call in same thread
this._pluginLocal?.emit(type, Object.assign(payload, {
$$pid: pl.id
}))
return actor?.promise
}
this._callUserModel = async (...args: any) => {
let type = args[0] as string
if (type?.startsWith(FLAG_AWAIT)) {
type = type.replace(FLAG_AWAIT, '')
}
const payload = args[1] || {}
const fn = this._userModel[type]
if (typeof fn === 'function') {
await fn.call(null, payload)
}
}
} catch (e) {
debug('[shadow sandbox] error', e)
throw e
} finally {
this._status = undefined
}
}
_extendUserModel (model: any) {
return Object.assign(this._userModel, model)
}
_getSandboxIframeContainer () {
return this._parent?.frame.parentNode as HTMLDivElement
}
_getSandboxShadowContainer () {
return this._shadow?.frame.parentNode as HTMLDivElement
}
_getSandboxIframeRoot () {
return this._parent?.frame
}
_getSandboxShadowRoot () {
return this._shadow?.frame
}
set debugTag (value: string) {
this._debugTag = value
}
async destroy () {
let root: HTMLElement = null
if (this._parent) {
root = this._getSandboxIframeContainer()
await this._parent.destroy()
}
if (this._shadow) {
root = this._getSandboxShadowContainer()
this._shadow.destroy()
}
root?.parentNode.removeChild(root)
}
}
export {
LSPluginCaller
}