LSPlugin.user.ts 17 KB

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