LSPlugin.user.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { deepMerge } from './helpers'
  2. import { LSPluginCaller } from './LSPlugin.caller'
  3. import {
  4. IAppProxy, IDBProxy,
  5. IEditorProxy,
  6. ILSPluginUser,
  7. LSPluginBaseInfo,
  8. LSPluginUserEvents,
  9. SlashCommandAction,
  10. BlockCommandCallback,
  11. StyleString,
  12. ThemeOptions,
  13. UIOptions, IHookEvent
  14. } from './LSPlugin'
  15. import Debug from 'debug'
  16. import * as CSS from 'csstype'
  17. import { snakeCase } from 'snake-case'
  18. import EventEmitter from 'eventemitter3'
  19. declare global {
  20. interface Window {
  21. __LSP__HOST__: boolean
  22. }
  23. }
  24. const debug = Debug('LSPlugin:user')
  25. /**
  26. * @param type
  27. * @param opts
  28. * @param action
  29. */
  30. function registerSimpleCommand (
  31. this: LSPluginUser,
  32. type: string,
  33. opts: {
  34. key: string,
  35. label: string
  36. },
  37. action: BlockCommandCallback
  38. ) {
  39. if (typeof action !== 'function') {
  40. return false
  41. }
  42. const { key, label } = opts
  43. const eventKey = `SimpleCommandHook${key}${++registeredCmdUid}`
  44. this.Editor['on' + eventKey](action)
  45. this.caller?.call(`api:call`, {
  46. method: 'register-plugin-simple-command',
  47. args: [this.baseInfo.id, [{ key, label, type }, ['editor/hook', eventKey]]]
  48. })
  49. }
  50. const app: Partial<IAppProxy> = {
  51. registerUIItem (
  52. type: 'toolbar' | 'pagebar',
  53. opts: { key: string, template: string }
  54. ) {
  55. const pid = this.baseInfo.id
  56. // opts.key = `${pid}_${opts.key}`
  57. this.caller?.call(`api:call`, {
  58. method: 'register-plugin-ui-item',
  59. args: [pid, type, opts]
  60. })
  61. return false
  62. },
  63. registerPagebarMenuItem (
  64. this: LSPluginUser,
  65. tag: string,
  66. action: (e: IHookEvent & { page: string }) => void
  67. ): unknown {
  68. if (typeof action !== 'function') {
  69. return false
  70. }
  71. const key = tag + '_' + this.baseInfo.id
  72. const label = tag
  73. const type = 'pagebar-menu-item'
  74. registerSimpleCommand.call(this,
  75. type, {
  76. key, label
  77. }, action)
  78. return false
  79. }
  80. }
  81. let registeredCmdUid = 0
  82. const editor: Partial<IEditorProxy> = {
  83. registerSlashCommand (
  84. this: LSPluginUser,
  85. tag: string,
  86. actions: BlockCommandCallback | Array<SlashCommandAction>
  87. ) {
  88. debug('Register slash command #', this.baseInfo.id, tag, actions)
  89. if (typeof actions === 'function') {
  90. actions = [
  91. ['editor/clear-current-slash', false],
  92. ['editor/restore-saved-cursor'],
  93. ['editor/hook', actions]
  94. ]
  95. }
  96. actions = actions.map((it) => {
  97. const [tag, ...args] = it
  98. switch (tag) {
  99. case 'editor/hook':
  100. let key = args[0]
  101. let fn = () => {
  102. this.caller?.callUserModel(key)
  103. }
  104. if (typeof key === 'function') {
  105. fn = key
  106. }
  107. const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
  108. it[1] = eventKey
  109. // register command listener
  110. this.Editor['on' + eventKey](fn)
  111. break
  112. default:
  113. }
  114. return it
  115. })
  116. this.caller?.call(`api:call`, {
  117. method: 'register-plugin-slash-command',
  118. args: [this.baseInfo.id, [tag, actions]]
  119. })
  120. return false
  121. },
  122. registerBlockContextMenuItem (
  123. this: LSPluginUser,
  124. tag: string,
  125. action: BlockCommandCallback
  126. ): unknown {
  127. if (typeof action !== 'function') {
  128. return false
  129. }
  130. const key = + '_' + this.baseInfo.id
  131. const label = tag
  132. const type = 'block-context-menu-item'
  133. registerSimpleCommand.call(this,
  134. type, {
  135. key, label
  136. }, action)
  137. return false
  138. }
  139. }
  140. const db: Partial<IDBProxy> = {}
  141. type uiState = {
  142. key?: number,
  143. visible: boolean
  144. }
  145. const KEY_MAIN_UI = 0
  146. /**
  147. * User plugin instance
  148. * @public
  149. */
  150. export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements ILSPluginUser {
  151. /**
  152. * @private
  153. */
  154. private _connected: boolean = false
  155. /**
  156. * ui frame identities
  157. * @private
  158. */
  159. private _ui = new Map<number, uiState>()
  160. /**
  161. * handler of before unload plugin
  162. * @private
  163. */
  164. private _beforeunloadCallback?: (e: any) => Promise<void>
  165. /**
  166. * @param _baseInfo
  167. * @param _caller
  168. */
  169. constructor (
  170. private _baseInfo: LSPluginBaseInfo,
  171. private _caller: LSPluginCaller
  172. ) {
  173. super()
  174. _caller.on('settings:changed', (payload) => {
  175. const b = Object.assign({}, this.settings)
  176. const a = Object.assign(this._baseInfo.settings, payload)
  177. this.emit('settings:changed', { ...a }, b)
  178. })
  179. _caller.on('beforeunload', async (payload) => {
  180. const { actor, ...rest } = payload
  181. const cb = this._beforeunloadCallback
  182. try {
  183. cb && await cb(rest)
  184. actor?.resolve(null)
  185. } catch (e) {
  186. console.debug(`${_caller.debugTag} [beforeunload] `, e)
  187. actor?.reject(e)
  188. }
  189. })
  190. }
  191. async ready (
  192. model?: any,
  193. callback?: any
  194. ) {
  195. if (this._connected) return
  196. try {
  197. if (typeof model === 'function') {
  198. callback = model
  199. model = {}
  200. }
  201. let baseInfo = await this._caller.connectToParent(model)
  202. baseInfo = deepMerge(this._baseInfo, baseInfo)
  203. this._connected = true
  204. if (baseInfo?.id) {
  205. this._caller.debugTag = `#${baseInfo.id} [${baseInfo.name}]`
  206. }
  207. callback && callback.call(this, baseInfo)
  208. } catch (e) {
  209. console.error('[LSPlugin Ready Error]', e)
  210. }
  211. }
  212. beforeunload (callback: (e: any) => Promise<void>): void {
  213. if (typeof callback !== 'function') return
  214. this._beforeunloadCallback = callback
  215. }
  216. provideModel (model: Record<string, any>) {
  217. this.caller._extendUserModel(model)
  218. return this
  219. }
  220. provideTheme (theme: ThemeOptions) {
  221. this.caller.call('provider:theme', theme)
  222. return this
  223. }
  224. provideStyle (style: StyleString) {
  225. this.caller.call('provider:style', style)
  226. return this
  227. }
  228. provideUI (ui: UIOptions) {
  229. this.caller.call('provider:ui', ui)
  230. return this
  231. }
  232. updateSettings (attrs: Record<string, any>) {
  233. this.caller.call('settings:update', attrs)
  234. // TODO: update associated baseInfo settings
  235. }
  236. setMainUIAttrs (attrs: Record<string, any>): void {
  237. this.caller.call('main-ui:attrs', attrs)
  238. }
  239. setMainUIInlineStyle (style: CSS.Properties): void {
  240. this.caller.call('main-ui:style', style)
  241. }
  242. hideMainUI (opts?: { restoreEditingCursor: boolean }): void {
  243. const payload = { key: KEY_MAIN_UI, visible: false, cursor: opts?.restoreEditingCursor }
  244. this.caller.call('main-ui:visible', payload)
  245. this.emit('ui:visible:changed', payload)
  246. this._ui.set(payload.key, payload)
  247. }
  248. showMainUI (): void {
  249. const payload = { key: KEY_MAIN_UI, visible: true }
  250. this.caller.call('main-ui:visible', payload)
  251. this.emit('ui:visible:changed', payload)
  252. this._ui.set(payload.key, payload)
  253. }
  254. toggleMainUI (): void {
  255. const payload = { key: KEY_MAIN_UI, toggle: true }
  256. const state = this._ui.get(payload.key)
  257. if (state && state.visible) {
  258. this.hideMainUI()
  259. } else {
  260. this.showMainUI()
  261. }
  262. }
  263. get isMainUIVisible (): boolean {
  264. const state = this._ui.get(0)
  265. return Boolean(state && state.visible)
  266. }
  267. get connected (): boolean {
  268. return this._connected
  269. }
  270. get baseInfo (): LSPluginBaseInfo {
  271. return this._baseInfo
  272. }
  273. get settings () {
  274. return this.baseInfo?.settings
  275. }
  276. get caller (): LSPluginCaller {
  277. return this._caller
  278. }
  279. /**
  280. * @internal
  281. */
  282. _makeUserProxy (
  283. target: any,
  284. tag?: 'app' | 'editor' | 'db'
  285. ) {
  286. const that = this
  287. const caller = this.caller
  288. return new Proxy(target, {
  289. get (target: any, propKey, receiver) {
  290. const origMethod = target[propKey]
  291. return function (this: any, ...args: any) {
  292. if (origMethod) {
  293. const ret = origMethod.apply(that, args)
  294. if (ret === false) return
  295. }
  296. // Handle hook
  297. if (tag) {
  298. const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
  299. if (hookMatcher != null) {
  300. const f = hookMatcher[0].toLowerCase()
  301. const s = hookMatcher.input!
  302. const e = s.slice(f.length)
  303. const type = `hook:${tag}:${snakeCase(e)}`
  304. const handler = args[0]
  305. caller[f](type, handler)
  306. return f !== 'off' ? () => (caller.off(type, handler)) : void 0
  307. }
  308. }
  309. // Call host
  310. return caller.callAsync(`api:call`, {
  311. method: propKey,
  312. args: args
  313. })
  314. }
  315. }
  316. })
  317. }
  318. /**
  319. * The interface methods of {@link IAppProxy}
  320. */
  321. get App (): IAppProxy {
  322. return this._makeUserProxy(app, 'app')
  323. }
  324. get Editor (): IEditorProxy {
  325. return this._makeUserProxy(editor, 'editor')
  326. }
  327. get DB (): IDBProxy {
  328. return this._makeUserProxy(db)
  329. }
  330. }
  331. export * from './LSPlugin'
  332. /**
  333. * @internal
  334. */
  335. export function setupPluginUserInstance (
  336. pluginBaseInfo: LSPluginBaseInfo,
  337. pluginCaller: LSPluginCaller
  338. ) {
  339. return new LSPluginUser(pluginBaseInfo, pluginCaller)
  340. }
  341. if (window.__LSP__HOST__ == null) { // Entry of iframe mode
  342. const caller = new LSPluginCaller(null)
  343. // @ts-ignore
  344. window.logseq = setupPluginUserInstance({} as any, caller)
  345. }