LSPlugin.user.ts 15 KB

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