| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907 |
- import {
- isValidUUID,
- deepMerge,
- mergeSettingsWithSchema,
- PluginLogger,
- safeSnakeCase,
- safetyPathJoin, normalizeKeyStr,
- } from './helpers'
- import { LSPluginCaller } from './LSPlugin.caller'
- import * as callableAPIs from './callable.apis'
- import {
- IAppProxy,
- IDBProxy,
- IEditorProxy,
- ILSPluginUser,
- LSPluginBaseInfo,
- LSPluginUserEvents,
- SlashCommandAction,
- BlockCommandCallback,
- StyleString,
- Theme,
- UIOptions,
- IHookEvent,
- BlockIdentity,
- BlockPageName,
- UIContainerAttrs,
- SimpleCommandCallback,
- SimpleCommandKeybinding,
- SettingSchemaDesc,
- IUserOffHook,
- IGitProxy,
- IUIProxy,
- UserProxyNSTags,
- BlockUUID,
- BlockEntity,
- IDatom,
- IAssetsProxy,
- AppInfo,
- IPluginSearchServiceHooks,
- PageEntity, IUtilsProxy,
- } from './LSPlugin'
- import Debug from 'debug'
- import * as CSS from 'csstype'
- import EventEmitter from 'eventemitter3'
- import { IAsyncStorage, LSPluginFileStorage } from './modules/LSPlugin.Storage'
- import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
- import { LSPluginRequest } from './modules/LSPlugin.Request'
- import { LSPluginSearchService } from './modules/LSPlugin.Search'
- declare global {
- interface Window {
- __LSP__HOST__: boolean
- logseq: LSPluginUser
- }
- }
- type callableMethods = keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
- const PROXY_CONTINUE = Symbol.for('proxy-continue')
- const debug = Debug('LSPlugin:user')
- const logger = new PluginLogger('', { console: true })
- /**
- * @param type (key of group commands)
- * @param opts
- * @param action
- */
- function registerSimpleCommand(
- this: LSPluginUser,
- type: string,
- opts: {
- key: string
- label: string
- desc?: string
- palette?: boolean
- keybinding?: SimpleCommandKeybinding
- extras?: Record<string, any>
- },
- action: SimpleCommandCallback
- ) {
- const { key, label, desc, palette, keybinding, extras } = opts
- if (typeof action !== 'function') {
- this.logger.error(`${key || label}: command action should be function.`)
- return false
- }
- const normalizedKey = normalizeKeyStr(key)
- if (!normalizedKey) {
- this.logger.error(`${label}: command key is required.`)
- return false
- }
- const eventKey = `SimpleCommandHook${normalizedKey}${++registeredCmdUid}`
- this.Editor['on' + eventKey](action)
- this.caller?.call(`api:call`, {
- method: 'register-plugin-simple-command',
- args: [
- this.baseInfo.id,
- // [cmd, action]
- [
- { key: normalizedKey, label, type, desc, keybinding, extras },
- ['editor/hook', eventKey],
- ],
- palette,
- ],
- })
- }
- function shouldValidUUID(uuid: string) {
- if (!isValidUUID(uuid)) {
- logger.error(`#${uuid} is not a valid UUID string.`)
- return false
- }
- return true
- }
- function checkEffect(p: LSPluginUser) {
- return p && (p.baseInfo?.effect || !p.baseInfo?.iir)
- }
- let _appBaseInfo: AppInfo = null
- let _searchServices: Map<string, LSPluginSearchService> = new Map()
- const app: Partial<IAppProxy> = {
- async getInfo(this: LSPluginUser, key) {
- if (!_appBaseInfo) {
- _appBaseInfo = await this._execCallableAPIAsync('get-app-info')
- }
- return typeof key === 'string' ? _appBaseInfo[key] : _appBaseInfo
- },
- registerCommand: registerSimpleCommand,
- registerSearchService<T extends IPluginSearchServiceHooks>(
- this: LSPluginUser,
- s: T
- ) {
- if (_searchServices.has(s.name)) {
- throw new Error(`SearchService: #${s.name} has registered!`)
- }
- _searchServices.set(s.name, new LSPluginSearchService(this, s))
- },
- registerCommandPalette(
- opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
- action: SimpleCommandCallback
- ) {
- const { key, label, keybinding } = opts
- const group = '$palette$'
- return registerSimpleCommand.call(
- this,
- group,
- { key, label, palette: true, keybinding },
- action
- )
- },
- registerCommandShortcut(
- keybinding: SimpleCommandKeybinding | string,
- action: SimpleCommandCallback,
- opts: Partial<{
- key: string
- label: string
- desc: string
- extras: Record<string, any>
- }> = {}
- ) {
- if (typeof keybinding == 'string') {
- keybinding = {
- mode: 'global',
- binding: keybinding,
- }
- }
- const { binding } = keybinding
- const group = '$shortcut$'
- const key = opts.key || (group + safeSnakeCase(binding?.toString()))
- return registerSimpleCommand.call(
- this,
- group,
- { ...opts, key, palette: false, keybinding },
- action
- )
- },
- registerUIItem(
- type: 'toolbar' | 'pagebar',
- opts: { key: string; template: string }
- ) {
- const pid = this.baseInfo.id
- // opts.key = `${pid}_${opts.key}`
- this.caller?.call(`api:call`, {
- method: 'register-plugin-ui-item',
- args: [pid, type, opts],
- })
- },
- registerPageMenuItem(
- this: LSPluginUser,
- tag: string,
- action: (e: IHookEvent & { page: string }) => void
- ) {
- if (typeof action !== 'function') {
- return false
- }
- const key = tag + '_' + this.baseInfo.id
- const label = tag
- const type = 'page-menu-item'
- registerSimpleCommand.call(
- this,
- type,
- {
- key,
- label,
- },
- action
- )
- },
- onBlockRendererSlotted(uuid, callback: (payload: any) => void) {
- if (!shouldValidUUID(uuid)) return
- const pid = this.baseInfo.id
- const hook = `hook:editor:${safeSnakeCase(`slot:${uuid}`)}`
- this.caller.on(hook, callback)
- this.App._installPluginHook(pid, hook)
- return () => {
- this.caller.off(hook, callback)
- this.App._uninstallPluginHook(pid, hook)
- }
- },
- invokeExternalPlugin(this: LSPluginUser, type: string, ...args: Array<any>) {
- type = type?.trim()
- if (!type) return
- let [pid, group] = type.split('.')
- if (!['models', 'commands'].includes(group?.toLowerCase())) {
- throw new Error(`Type only support '.models' or '.commands' currently.`)
- }
- const key = type.replace(`${pid}.${group}.`, '')
- if (!pid || !group || !key) {
- throw new Error(`Illegal type of #${type} to invoke external plugin.`)
- }
- return this._execCallableAPIAsync(
- 'invoke_external_plugin_cmd',
- pid,
- group.toLowerCase(),
- key,
- args
- )
- },
- setFullScreen(flag) {
- const sf = (...args) => this._callWin('setFullScreen', ...args)
- if (flag === 'toggle') {
- this._callWin('isFullScreen').then((r) => {
- r ? sf() : sf(true)
- })
- } else {
- flag ? sf(true) : sf()
- }
- },
- }
- let registeredCmdUid = 0
- const editor: Partial<IEditorProxy> = {
- newBlockUUID(this: LSPluginUser): Promise<string> {
- return this._execCallableAPIAsync('new_block_uuid')
- },
- isPageBlock(
- this: LSPluginUser,
- block: BlockEntity | PageEntity
- ): Boolean {
- return block.uuid && block.hasOwnProperty('name')
- },
- registerSlashCommand(
- this: LSPluginUser,
- tag: string,
- actions: BlockCommandCallback | Array<SlashCommandAction>
- ) {
- debug('Register slash command #', this.baseInfo.id, tag, actions)
- if (typeof actions === 'function') {
- actions = [
- ['editor/clear-current-slash', false],
- ['editor/restore-saved-cursor'],
- ['editor/hook', actions],
- ]
- }
- actions = actions.map((it) => {
- const [tag, ...args] = it
- switch (tag) {
- case 'editor/hook':
- let key = args[0]
- let fn = () => {
- this.caller?.callUserModel(key)
- }
- if (typeof key === 'function') {
- fn = key
- }
- const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
- it[1] = eventKey
- // register command listener
- this.Editor['on' + eventKey](fn)
- break
- default:
- }
- return it
- })
- this.caller?.call(`api:call`, {
- method: 'register-plugin-slash-command',
- args: [this.baseInfo.id, [tag, actions]],
- })
- },
- registerBlockContextMenuItem(
- this: LSPluginUser,
- label: string,
- action: BlockCommandCallback
- ) {
- if (typeof action !== 'function') {
- return false
- }
- const key = label + '_' + this.baseInfo.id
- const type = 'block-context-menu-item'
- registerSimpleCommand.call(
- this,
- type,
- {
- key,
- label,
- },
- action
- )
- },
- registerHighlightContextMenuItem(
- this: LSPluginUser,
- label: string,
- action: SimpleCommandCallback,
- opts?: { clearSelection: boolean }
- ) {
- if (typeof action !== 'function') {
- return false
- }
- const key = label + '_' + this.baseInfo.id
- const type = 'highlight-context-menu-item'
- registerSimpleCommand.call(
- this,
- type,
- {
- key,
- label,
- extras: opts,
- },
- action
- )
- },
- scrollToBlockInPage(
- this: LSPluginUser,
- pageName: BlockPageName,
- blockId: BlockIdentity,
- opts?: { replaceState: boolean }
- ) {
- const anchor = `block-content-` + blockId
- if (opts?.replaceState) {
- this.App.replaceState('page', { name: pageName }, { anchor })
- } else {
- this.App.pushState('page', { name: pageName }, { anchor })
- }
- },
- }
- const db: Partial<IDBProxy> = {
- onBlockChanged(
- this: LSPluginUser,
- uuid: BlockUUID,
- callback: (
- block: BlockEntity,
- txData: Array<IDatom>,
- txMeta?: { outlinerOp: string; [p: string]: any }
- ) => void
- ): IUserOffHook {
- if (!shouldValidUUID(uuid)) return
- const pid = this.baseInfo.id
- const hook = `hook:db:${safeSnakeCase(`block:${uuid}`)}`
- const aBlockChange = ({ block, txData, txMeta }) => {
- if (block.uuid !== uuid) {
- return
- }
- callback(block, txData, txMeta)
- }
- this.caller.on(hook, aBlockChange)
- this.App._installPluginHook(pid, hook)
- return () => {
- this.caller.off(hook, aBlockChange)
- this.App._uninstallPluginHook(pid, hook)
- }
- },
- datascriptQuery<T = any>(
- this: LSPluginUser,
- query: string,
- ...inputs: Array<any>
- ): Promise<T> {
- // force remove proxy ns flag `db`
- inputs.pop()
- if (inputs?.some((it) => typeof it === 'function')) {
- const host = this.Experiments.ensureHostScope()
- return host.logseq.api.datascript_query(query, ...inputs)
- }
- return this._execCallableAPIAsync(`datascript_query`, ...[query, ...inputs])
- },
- }
- const git: Partial<IGitProxy> = {}
- const ui: Partial<IUIProxy> = {}
- const utils: Partial<IUtilsProxy> = {}
- const assets: Partial<IAssetsProxy> = {
- makeSandboxStorage(this: LSPluginUser): IAsyncStorage {
- return new LSPluginFileStorage(this, { assets: true })
- },
- }
- type uiState = {
- key?: number
- visible: boolean
- }
- const KEY_MAIN_UI = 0
- /**
- * User plugin instance from global namespace `logseq`.
- * @example
- * ```ts
- * logseq.UI.showMsg('Hello, Logseq')
- * ```
- * @public
- */
- export class LSPluginUser
- extends EventEmitter<LSPluginUserEvents>
- implements ILSPluginUser {
- // @ts-ignore
- private _version: string = LIB_VERSION
- private _debugTag: string = ''
- private _settingsSchema?: Array<SettingSchemaDesc>
- private _connected: boolean = false
- /**
- * ui frame identities
- * @private
- */
- private _ui = new Map<number, uiState>()
- private _mFileStorage: LSPluginFileStorage
- private _mRequest: LSPluginRequest
- private _mExperiments: LSPluginExperiments
- /**
- * handler of before unload plugin
- * @private
- */
- private _beforeunloadCallback?: (e: any) => Promise<void>
- /**
- * @param _baseInfo
- * @param _caller
- */
- constructor(
- private _baseInfo: LSPluginBaseInfo,
- private _caller: LSPluginCaller
- ) {
- super()
- _caller.on('sys:ui:visible', (payload) => {
- if (payload?.toggle) {
- this.toggleMainUI()
- }
- })
- _caller.on('settings:changed', (payload) => {
- const b = Object.assign({}, this.settings)
- const a = Object.assign(this._baseInfo.settings, payload)
- this.emit('settings:changed', { ...a }, b)
- })
- _caller.on('beforeunload', async (payload) => {
- const { actor, ...rest } = payload
- const cb = this._beforeunloadCallback
- try {
- cb && (await cb(rest))
- actor?.resolve(null)
- } catch (e) {
- this.logger.error(`[beforeunload] `, e)
- actor?.reject(e)
- }
- })
- }
- // Life related
- async ready(model?: any, callback?: any) {
- if (this._connected) return
- try {
- if (typeof model === 'function') {
- callback = model
- model = {}
- }
- let baseInfo = await this._caller.connectToParent(model)
- this._connected = true
- baseInfo = deepMerge(this._baseInfo, baseInfo)
- this._baseInfo = baseInfo
- if (baseInfo?.id) {
- this._debugTag =
- this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
- this.logger.setTag(this._debugTag)
- }
- if (this._settingsSchema) {
- baseInfo.settings = mergeSettingsWithSchema(
- baseInfo.settings,
- this._settingsSchema
- )
- // TODO: sync host settings schema
- await this.useSettingsSchema(this._settingsSchema)
- }
- try {
- await this._execCallableAPIAsync('setSDKMetadata', {
- version: this._version,
- })
- } catch (e) {
- console.warn(e)
- }
- callback && callback.call(this, baseInfo)
- } catch (e) {
- console.error(`${this._debugTag} [Ready Error]`, e)
- }
- }
- ensureConnected() {
- if (!this._connected) {
- throw new Error('not connected')
- }
- }
- beforeunload(callback: (e: any) => Promise<void>): void {
- if (typeof callback !== 'function') return
- this._beforeunloadCallback = callback
- }
- provideModel(model: Record<string, any>) {
- this.caller._extendUserModel(model)
- return this
- }
- provideTheme(theme: Theme) {
- this.caller.call('provider:theme', theme)
- return this
- }
- provideStyle(style: StyleString) {
- this.caller.call('provider:style', style)
- return this
- }
- provideUI(ui: UIOptions) {
- this.caller.call('provider:ui', ui)
- return this
- }
- // Settings related
- useSettingsSchema(schema: Array<SettingSchemaDesc>) {
- if (this.connected) {
- this.caller.call('settings:schema', {
- schema,
- isSync: true,
- })
- }
- this._settingsSchema = schema
- return this
- }
- updateSettings(attrs: Record<string, any>) {
- this.caller.call('settings:update', attrs)
- // TODO: update associated baseInfo settings
- }
- onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook {
- const type = 'settings:changed'
- this.on(type, cb)
- return () => this.off(type, cb)
- }
- showSettingsUI() {
- this.caller.call('settings:visible:changed', { visible: true })
- }
- hideSettingsUI() {
- this.caller.call('settings:visible:changed', { visible: false })
- }
- // UI related
- setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
- this.caller.call('main-ui:attrs', attrs)
- }
- setMainUIInlineStyle(style: CSS.Properties): void {
- this.caller.call('main-ui:style', style)
- }
- hideMainUI(opts?: { restoreEditingCursor: boolean }): void {
- const payload = {
- key: KEY_MAIN_UI,
- visible: false,
- cursor: opts?.restoreEditingCursor,
- }
- this.caller.call('main-ui:visible', payload)
- this.emit('ui:visible:changed', payload)
- this._ui.set(payload.key, payload)
- }
- showMainUI(opts?: { autoFocus: boolean }): void {
- const payload = {
- key: KEY_MAIN_UI,
- visible: true,
- autoFocus: opts?.autoFocus,
- }
- this.caller.call('main-ui:visible', payload)
- this.emit('ui:visible:changed', payload)
- this._ui.set(payload.key, payload)
- }
- toggleMainUI(): void {
- const payload = { key: KEY_MAIN_UI, toggle: true }
- const state = this._ui.get(payload.key)
- if (state && state.visible) {
- this.hideMainUI()
- } else {
- this.showMainUI()
- }
- }
- // Getters
- get version(): string {
- return this._version
- }
- get isMainUIVisible(): boolean {
- const state = this._ui.get(KEY_MAIN_UI)
- return Boolean(state && state.visible)
- }
- get connected(): boolean {
- return this._connected
- }
- get baseInfo(): LSPluginBaseInfo {
- return this._baseInfo
- }
- get effect(): Boolean {
- return checkEffect(this)
- }
- get logger() {
- return logger
- }
- get settings() {
- return this.baseInfo?.settings
- }
- get caller(): LSPluginCaller {
- return this._caller
- }
- resolveResourceFullUrl(filePath: string) {
- this.ensureConnected()
- if (!filePath) return
- filePath = filePath.replace(/^[.\\/]+/, '')
- return safetyPathJoin(this._baseInfo.lsr, filePath)
- }
- /**
- * @internal
- */
- _makeUserProxy(target: any, nstag?: UserProxyNSTags) {
- const that = this
- const caller = this.caller
- return new Proxy(target, {
- get(target: any, propKey, _receiver) {
- const origMethod = target[propKey]
- return function (this: any, ...args: any) {
- if (origMethod) {
- if (args?.length !== 0) args.concat(nstag)
- const ret = origMethod.apply(that, args)
- if (ret !== PROXY_CONTINUE) return ret
- }
- // Handle hook
- if (nstag) {
- const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
- if (hookMatcher != null) {
- const f = hookMatcher[0].toLowerCase()
- const s = hookMatcher.input!
- const isOff = f === 'off'
- const pid = that.baseInfo.id
- let type = s.slice(f.length)
- let handler = args[0]
- let opts = args[1]
- // condition mode
- if (typeof handler === 'string' && typeof opts === 'function') {
- handler = handler.replace(/^logseq./, ':')
- type = `${type}${handler}`
- handler = opts
- opts = args[2]
- }
- type = `hook:${nstag}:${safeSnakeCase(type)}`
- caller[f](type, handler)
- const unlisten = () => {
- caller.off(type, handler)
- if (!caller.listenerCount(type)) {
- that.App._uninstallPluginHook(pid, type)
- }
- }
- if (!isOff) {
- that.App._installPluginHook(pid, type, opts)
- } else {
- unlisten()
- return
- }
- return unlisten
- }
- }
- let method = propKey as string
- // TODO: refactor api call with the explicit tag
- if ((['git', 'ui', 'assets', 'utils'] as UserProxyNSTags[]).includes(nstag)) {
- method = nstag + '_' + method
- }
- // Call host
- return caller.callAsync(`api:call`, {
- tag: nstag,
- method,
- args: args,
- })
- }
- },
- })
- }
- _execCallableAPIAsync(method: callableMethods, ...args) {
- return this._caller.callAsync(`api:call`, {
- method,
- args,
- })
- }
- _execCallableAPI(method: callableMethods, ...args) {
- this._caller.call(`api:call`, {
- method,
- args,
- })
- }
- _callWin(...args) {
- return this._execCallableAPIAsync(`_callMainWin`, ...args)
- }
- // User Proxies
- #appProxy: IAppProxy
- #editorProxy: IEditorProxy
- #dbProxy: IDBProxy
- #uiProxy: IUIProxy
- #utilsProxy: IUtilsProxy
- get App(): IAppProxy {
- if (this.#appProxy) return this.#appProxy
- return (this.#appProxy = this._makeUserProxy(app, 'app'))
- }
- get Editor(): IEditorProxy {
- if (this.#editorProxy) return this.#editorProxy
- return (this.#editorProxy = this._makeUserProxy(editor, 'editor'))
- }
- get DB(): IDBProxy {
- if (this.#dbProxy) return this.#dbProxy
- return (this.#dbProxy = this._makeUserProxy(db, 'db'))
- }
- get UI(): IUIProxy {
- if (this.#uiProxy) return this.#uiProxy
- return (this.#uiProxy = this._makeUserProxy(ui, 'ui'))
- }
- get Utils(): IUtilsProxy {
- if (this.#utilsProxy) return this.#utilsProxy
- return (this.#utilsProxy = this._makeUserProxy(utils, 'utils'))
- }
- get Git(): IGitProxy {
- return this._makeUserProxy(git, 'git')
- }
- get Assets(): IAssetsProxy {
- return this._makeUserProxy(assets, 'assets')
- }
- get FileStorage(): LSPluginFileStorage {
- let m = this._mFileStorage
- if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
- return m
- }
- get Request(): LSPluginRequest {
- let m = this._mRequest
- if (!m) m = this._mRequest = new LSPluginRequest(this)
- return m
- }
- get Experiments(): LSPluginExperiments {
- let m = this._mExperiments
- if (!m) m = this._mExperiments = new LSPluginExperiments(this)
- return m
- }
- }
- export * from './LSPlugin'
- /**
- * @internal
- */
- export function setupPluginUserInstance(
- pluginBaseInfo: LSPluginBaseInfo,
- pluginCaller: LSPluginCaller
- ) {
- return new LSPluginUser(pluginBaseInfo, pluginCaller)
- }
- // entry of iframe mode
- if (window.__LSP__HOST__ == null) {
- const caller = new LSPluginCaller(null)
- window.logseq = setupPluginUserInstance({} as any, caller)
- }
|