LSPlugin.user.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
  1. import {
  2. deepMerge,
  3. mergeSettingsWithSchema,
  4. safeSnakeCase,
  5. safetyPathJoin,
  6. } from './helpers'
  7. import { LSPluginCaller } from './LSPlugin.caller'
  8. import * as callableAPIs from './callable.apis'
  9. import {
  10. IAppProxy,
  11. IDBProxy,
  12. IEditorProxy,
  13. ILSPluginUser,
  14. LSPluginBaseInfo,
  15. LSPluginUserEvents,
  16. SlashCommandAction,
  17. BlockCommandCallback,
  18. StyleString,
  19. Theme,
  20. UIOptions,
  21. IHookEvent,
  22. BlockIdentity,
  23. BlockPageName,
  24. UIContainerAttrs,
  25. SimpleCommandCallback,
  26. SimpleCommandKeybinding,
  27. SettingSchemaDesc,
  28. IUserOffHook,
  29. IGitProxy,
  30. IUIProxy,
  31. UserProxyTags,
  32. BlockUUID,
  33. BlockEntity,
  34. IDatom,
  35. IAssetsProxy,
  36. AppInfo,
  37. } from './LSPlugin'
  38. import Debug from 'debug'
  39. import * as CSS from 'csstype'
  40. import EventEmitter from 'eventemitter3'
  41. import { LSPluginFileStorage } from './modules/LSPlugin.Storage'
  42. import { LSPluginExperiments } from './modules/LSPlugin.Experiments'
  43. import { LSPluginRequest } from './modules/LSPlugin.Request'
  44. declare global {
  45. interface Window {
  46. __LSP__HOST__: boolean
  47. logseq: LSPluginUser
  48. }
  49. }
  50. type callableMethods =
  51. keyof typeof callableAPIs | string // host exported SDK apis & host platform related apis
  52. const PROXY_CONTINUE = Symbol.for('proxy-continue')
  53. const debug = Debug('LSPlugin:user')
  54. /**
  55. * @param type (key of group commands)
  56. * @param opts
  57. * @param action
  58. */
  59. function registerSimpleCommand(
  60. this: LSPluginUser,
  61. type: string,
  62. opts: {
  63. key: string
  64. label: string
  65. desc?: string
  66. palette?: boolean
  67. keybinding?: SimpleCommandKeybinding
  68. },
  69. action: SimpleCommandCallback
  70. ) {
  71. if (typeof action !== 'function') {
  72. return false
  73. }
  74. const { key, label, desc, palette, keybinding } = opts
  75. const eventKey = `SimpleCommandHook${key}${++registeredCmdUid}`
  76. this.Editor['on' + eventKey](action)
  77. this.caller?.call(`api:call`, {
  78. method: 'register-plugin-simple-command',
  79. args: [
  80. this.baseInfo.id,
  81. [{ key, label, type, desc, keybinding }, ['editor/hook', eventKey]],
  82. palette,
  83. ],
  84. })
  85. }
  86. let _appBaseInfo: AppInfo = null
  87. const app: Partial<IAppProxy> = {
  88. async getInfo(
  89. this: LSPluginUser,
  90. key
  91. ) {
  92. if (!_appBaseInfo) {
  93. _appBaseInfo = await this._execCallableAPIAsync('get-app-info')
  94. }
  95. return typeof key === 'string' ? _appBaseInfo[key] : _appBaseInfo
  96. },
  97. registerCommand: registerSimpleCommand,
  98. registerCommandPalette(
  99. opts: { key: string; label: string; keybinding?: SimpleCommandKeybinding },
  100. action: SimpleCommandCallback
  101. ) {
  102. const { key, label, keybinding } = opts
  103. const group = '$palette$'
  104. return registerSimpleCommand.call(
  105. this,
  106. group,
  107. { key, label, palette: true, keybinding },
  108. action
  109. )
  110. },
  111. registerCommandShortcut(
  112. keybinding: SimpleCommandKeybinding,
  113. action: SimpleCommandCallback
  114. ) {
  115. const { binding } = keybinding
  116. const group = '$shortcut$'
  117. const key = group + safeSnakeCase(binding)
  118. return registerSimpleCommand.call(
  119. this,
  120. group,
  121. { key, palette: false, keybinding },
  122. action
  123. )
  124. },
  125. registerUIItem(
  126. type: 'toolbar' | 'pagebar',
  127. opts: { key: string; template: string }
  128. ) {
  129. const pid = this.baseInfo.id
  130. // opts.key = `${pid}_${opts.key}`
  131. this.caller?.call(`api:call`, {
  132. method: 'register-plugin-ui-item',
  133. args: [pid, type, opts],
  134. })
  135. },
  136. registerPageMenuItem(
  137. this: LSPluginUser,
  138. tag: string,
  139. action: (e: IHookEvent & { page: string }) => void
  140. ) {
  141. if (typeof action !== 'function') {
  142. return false
  143. }
  144. const key = tag + '_' + this.baseInfo.id
  145. const label = tag
  146. const type = 'page-menu-item'
  147. registerSimpleCommand.call(
  148. this,
  149. type,
  150. {
  151. key,
  152. label,
  153. },
  154. action
  155. )
  156. },
  157. setFullScreen(flag) {
  158. const sf = (...args) => this._callWin('setFullScreen', ...args)
  159. if (flag === 'toggle') {
  160. this._callWin('isFullScreen').then((r) => {
  161. r ? sf() : sf(true)
  162. })
  163. } else {
  164. flag ? sf(true) : sf()
  165. }
  166. }
  167. }
  168. let registeredCmdUid = 0
  169. const editor: Partial<IEditorProxy> = {
  170. registerSlashCommand(
  171. this: LSPluginUser,
  172. tag: string,
  173. actions: BlockCommandCallback | Array<SlashCommandAction>
  174. ) {
  175. debug('Register slash command #', this.baseInfo.id, tag, actions)
  176. if (typeof actions === 'function') {
  177. actions = [
  178. ['editor/clear-current-slash', false],
  179. ['editor/restore-saved-cursor'],
  180. ['editor/hook', actions],
  181. ]
  182. }
  183. actions = actions.map((it) => {
  184. const [tag, ...args] = it
  185. switch (tag) {
  186. case 'editor/hook':
  187. let key = args[0]
  188. let fn = () => {
  189. this.caller?.callUserModel(key)
  190. }
  191. if (typeof key === 'function') {
  192. fn = key
  193. }
  194. const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
  195. it[1] = eventKey
  196. // register command listener
  197. this.Editor['on' + eventKey](fn)
  198. break
  199. default:
  200. }
  201. return it
  202. })
  203. this.caller?.call(`api:call`, {
  204. method: 'register-plugin-slash-command',
  205. args: [this.baseInfo.id, [tag, actions]],
  206. })
  207. },
  208. registerBlockContextMenuItem(
  209. this: LSPluginUser,
  210. tag: string,
  211. action: BlockCommandCallback
  212. ) {
  213. if (typeof action !== 'function') {
  214. return false
  215. }
  216. const key = tag + '_' + this.baseInfo.id
  217. const label = tag
  218. const type = 'block-context-menu-item'
  219. registerSimpleCommand.call(
  220. this,
  221. type,
  222. {
  223. key,
  224. label,
  225. },
  226. action
  227. )
  228. },
  229. scrollToBlockInPage(
  230. this: LSPluginUser,
  231. pageName: BlockPageName,
  232. blockId: BlockIdentity,
  233. opts?: { replaceState: boolean }
  234. ) {
  235. const anchor = `block-content-` + blockId
  236. if (opts?.replaceState) {
  237. this.App.replaceState('page', { name: pageName }, { anchor })
  238. } else {
  239. this.App.pushState('page', { name: pageName }, { anchor })
  240. }
  241. },
  242. }
  243. const db: Partial<IDBProxy> = {
  244. onBlockChanged(
  245. this: LSPluginUser,
  246. uuid: BlockUUID,
  247. callback: (
  248. block: BlockEntity,
  249. txData: Array<IDatom>,
  250. txMeta?: { outlinerOp: string; [p: string]: any }
  251. ) => void
  252. ): IUserOffHook {
  253. const pid = this.baseInfo.id
  254. const hook = `hook:db:${safeSnakeCase(`block:${uuid}`)}`
  255. const aBlockChange = ({ block, txData, txMeta }) => {
  256. if (block.uuid !== uuid) {
  257. return
  258. }
  259. callback(block, txData, txMeta)
  260. }
  261. this.caller.on(hook, aBlockChange)
  262. this.App._installPluginHook(pid, hook)
  263. return () => {
  264. this.caller.off(hook, aBlockChange)
  265. this.App._uninstallPluginHook(pid, hook)
  266. }
  267. },
  268. }
  269. const git: Partial<IGitProxy> = {}
  270. const ui: Partial<IUIProxy> = {}
  271. const assets: Partial<IAssetsProxy> = {}
  272. type uiState = {
  273. key?: number
  274. visible: boolean
  275. }
  276. const KEY_MAIN_UI = 0
  277. /**
  278. * User plugin instance
  279. * @public
  280. */
  281. export class LSPluginUser
  282. extends EventEmitter<LSPluginUserEvents>
  283. implements ILSPluginUser {
  284. // @ts-ignore
  285. private _version: string = LIB_VERSION
  286. private _debugTag: string = ''
  287. private _settingsSchema?: Array<SettingSchemaDesc>
  288. private _connected: boolean = false
  289. /**
  290. * ui frame identities
  291. * @private
  292. */
  293. private _ui = new Map<number, uiState>()
  294. private _mFileStorage: LSPluginFileStorage
  295. private _mRequest: LSPluginRequest
  296. private _mExperiments: LSPluginExperiments
  297. /**
  298. * handler of before unload plugin
  299. * @private
  300. */
  301. private _beforeunloadCallback?: (e: any) => Promise<void>
  302. /**
  303. * @param _baseInfo
  304. * @param _caller
  305. */
  306. constructor(
  307. private _baseInfo: LSPluginBaseInfo,
  308. private _caller: LSPluginCaller
  309. ) {
  310. super()
  311. _caller.on('sys:ui:visible', (payload) => {
  312. if (payload?.toggle) {
  313. this.toggleMainUI()
  314. }
  315. })
  316. _caller.on('settings:changed', (payload) => {
  317. const b = Object.assign({}, this.settings)
  318. const a = Object.assign(this._baseInfo.settings, payload)
  319. this.emit('settings:changed', { ...a }, b)
  320. })
  321. _caller.on('beforeunload', async (payload) => {
  322. const { actor, ...rest } = payload
  323. const cb = this._beforeunloadCallback
  324. try {
  325. cb && (await cb(rest))
  326. actor?.resolve(null)
  327. } catch (e) {
  328. console.debug(`${_caller.debugTag} [beforeunload] `, e)
  329. actor?.reject(e)
  330. }
  331. })
  332. }
  333. async ready(model?: any, callback?: any) {
  334. if (this._connected) return
  335. try {
  336. if (typeof model === 'function') {
  337. callback = model
  338. model = {}
  339. }
  340. let baseInfo = await this._caller.connectToParent(model)
  341. this._connected = true
  342. baseInfo = deepMerge(this._baseInfo, baseInfo)
  343. if (this._settingsSchema) {
  344. baseInfo.settings = mergeSettingsWithSchema(
  345. baseInfo.settings,
  346. this._settingsSchema
  347. )
  348. // TODO: sync host settings schema
  349. await this.useSettingsSchema(this._settingsSchema)
  350. }
  351. if (baseInfo?.id) {
  352. this._debugTag =
  353. this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
  354. }
  355. try {
  356. await this._execCallableAPIAsync('setSDKMetadata', {
  357. version: this._version,
  358. })
  359. } catch (e) {
  360. console.warn(e)
  361. }
  362. callback && callback.call(this, baseInfo)
  363. } catch (e) {
  364. console.error(`${this._debugTag} [Ready Error]`, e)
  365. }
  366. }
  367. ensureConnected() {
  368. if (!this._connected) {
  369. throw new Error('not connected')
  370. }
  371. }
  372. beforeunload(callback: (e: any) => Promise<void>): void {
  373. if (typeof callback !== 'function') return
  374. this._beforeunloadCallback = callback
  375. }
  376. provideModel(model: Record<string, any>) {
  377. this.caller._extendUserModel(model)
  378. return this
  379. }
  380. provideTheme(theme: Theme) {
  381. this.caller.call('provider:theme', theme)
  382. return this
  383. }
  384. provideStyle(style: StyleString) {
  385. this.caller.call('provider:style', style)
  386. return this
  387. }
  388. provideUI(ui: UIOptions) {
  389. this.caller.call('provider:ui', ui)
  390. return this
  391. }
  392. useSettingsSchema(schema: Array<SettingSchemaDesc>) {
  393. if (this.connected) {
  394. this.caller.call('settings:schema', {
  395. schema,
  396. isSync: true,
  397. })
  398. }
  399. this._settingsSchema = schema
  400. return this
  401. }
  402. updateSettings(attrs: Record<string, any>) {
  403. this.caller.call('settings:update', attrs)
  404. // TODO: update associated baseInfo settings
  405. }
  406. onSettingsChanged<T = any>(cb: (a: T, b: T) => void): IUserOffHook {
  407. const type = 'settings:changed'
  408. this.on(type, cb)
  409. return () => this.off(type, cb)
  410. }
  411. showSettingsUI() {
  412. this.caller.call('settings:visible:changed', { visible: true })
  413. }
  414. hideSettingsUI() {
  415. this.caller.call('settings:visible:changed', { visible: false })
  416. }
  417. setMainUIAttrs(attrs: Partial<UIContainerAttrs>): void {
  418. this.caller.call('main-ui:attrs', attrs)
  419. }
  420. setMainUIInlineStyle(style: CSS.Properties): void {
  421. this.caller.call('main-ui:style', style)
  422. }
  423. hideMainUI(opts?: { restoreEditingCursor: boolean }): void {
  424. const payload = {
  425. key: KEY_MAIN_UI,
  426. visible: false,
  427. cursor: opts?.restoreEditingCursor,
  428. }
  429. this.caller.call('main-ui:visible', payload)
  430. this.emit('ui:visible:changed', payload)
  431. this._ui.set(payload.key, payload)
  432. }
  433. showMainUI(opts?: { autoFocus: boolean }): void {
  434. const payload = {
  435. key: KEY_MAIN_UI,
  436. visible: true,
  437. autoFocus: opts?.autoFocus,
  438. }
  439. this.caller.call('main-ui:visible', payload)
  440. this.emit('ui:visible:changed', payload)
  441. this._ui.set(payload.key, payload)
  442. }
  443. toggleMainUI(): void {
  444. const payload = { key: KEY_MAIN_UI, toggle: true }
  445. const state = this._ui.get(payload.key)
  446. if (state && state.visible) {
  447. this.hideMainUI()
  448. } else {
  449. this.showMainUI()
  450. }
  451. }
  452. get version(): string {
  453. return this._version
  454. }
  455. get isMainUIVisible(): boolean {
  456. const state = this._ui.get(KEY_MAIN_UI)
  457. return Boolean(state && state.visible)
  458. }
  459. get connected(): boolean {
  460. return this._connected
  461. }
  462. get baseInfo(): LSPluginBaseInfo {
  463. return this._baseInfo
  464. }
  465. get settings() {
  466. return this.baseInfo?.settings
  467. }
  468. get caller(): LSPluginCaller {
  469. return this._caller
  470. }
  471. resolveResourceFullUrl(filePath: string) {
  472. this.ensureConnected()
  473. if (!filePath) return
  474. filePath = filePath.replace(/^[.\\/]+/, '')
  475. return safetyPathJoin(this._baseInfo.lsr, filePath)
  476. }
  477. /**
  478. * @internal
  479. */
  480. _makeUserProxy(target: any, tag?: UserProxyTags) {
  481. const that = this
  482. const caller = this.caller
  483. return new Proxy(target, {
  484. get(target: any, propKey, receiver) {
  485. const origMethod = target[propKey]
  486. return function (this: any, ...args: any) {
  487. if (origMethod) {
  488. const ret = origMethod.apply(that, args.concat(tag))
  489. if (ret !== PROXY_CONTINUE) return ret
  490. }
  491. // Handle hook
  492. if (tag) {
  493. const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
  494. if (hookMatcher != null) {
  495. const f = hookMatcher[0].toLowerCase()
  496. const s = hookMatcher.input!
  497. const e = s.slice(f.length)
  498. const isOff = f === 'off'
  499. const pid = that.baseInfo.id
  500. const type = `hook:${tag}:${safeSnakeCase(e)}`
  501. const handler = args[0]
  502. caller[f](type, handler)
  503. const unlisten = () => {
  504. caller.off(type, handler)
  505. if (!caller.listenerCount(type)) {
  506. that.App._uninstallPluginHook(pid, type)
  507. }
  508. }
  509. if (!isOff) {
  510. that.App._installPluginHook(pid, type)
  511. } else {
  512. unlisten()
  513. return
  514. }
  515. return unlisten
  516. }
  517. }
  518. let method = propKey as string
  519. if ((['git', 'ui', 'assets'] as UserProxyTags[]).includes(tag)) {
  520. method = tag + '_' + method
  521. }
  522. // Call host
  523. return caller.callAsync(`api:call`, {
  524. tag,
  525. method,
  526. args: args,
  527. })
  528. }
  529. },
  530. })
  531. }
  532. _execCallableAPIAsync(method: callableMethods, ...args) {
  533. return this._caller.callAsync(`api:call`, {
  534. method,
  535. args,
  536. })
  537. }
  538. _execCallableAPI(method: callableMethods, ...args) {
  539. this._caller.call(`api:call`, {
  540. method,
  541. args,
  542. })
  543. }
  544. _callWin(...args) {
  545. return this._execCallableAPIAsync(`_callMainWin`, ...args)
  546. }
  547. /**
  548. * The interface methods of {@link IAppProxy}
  549. */
  550. get App(): IAppProxy {
  551. return this._makeUserProxy(app, 'app')
  552. }
  553. get Editor(): IEditorProxy {
  554. return this._makeUserProxy(editor, 'editor')
  555. }
  556. get DB(): IDBProxy {
  557. return this._makeUserProxy(db, 'db')
  558. }
  559. get Git(): IGitProxy {
  560. return this._makeUserProxy(git, 'git')
  561. }
  562. get UI(): IUIProxy {
  563. return this._makeUserProxy(ui, 'ui')
  564. }
  565. get Assets(): IAssetsProxy {
  566. return this._makeUserProxy(assets, 'assets')
  567. }
  568. get FileStorage(): LSPluginFileStorage {
  569. let m = this._mFileStorage
  570. if (!m) m = this._mFileStorage = new LSPluginFileStorage(this)
  571. return m
  572. }
  573. get Request(): LSPluginRequest {
  574. let m = this._mRequest
  575. if (!m) m = this._mRequest = new LSPluginRequest(this)
  576. return m
  577. }
  578. get Experiments(): LSPluginExperiments {
  579. let m = this._mExperiments
  580. if (!m) m = this._mExperiments = new LSPluginExperiments(this)
  581. return m
  582. }
  583. }
  584. export * from './LSPlugin'
  585. /**
  586. * @internal
  587. */
  588. export function setupPluginUserInstance(
  589. pluginBaseInfo: LSPluginBaseInfo,
  590. pluginCaller: LSPluginCaller
  591. ) {
  592. return new LSPluginUser(pluginBaseInfo, pluginCaller)
  593. }
  594. // entry of iframe mode
  595. if (window.__LSP__HOST__ == null) {
  596. const caller = new LSPluginCaller(null)
  597. window.logseq = setupPluginUserInstance({} as any, caller)
  598. }