mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 14:14:55 +00:00
feat(plugin): merge implementation
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ strings.csv
|
||||
resources/electron.js
|
||||
.clj-kondo/
|
||||
.lsp/
|
||||
/libs/dist/
|
||||
|
||||
3
deps.edn
3
deps.edn
@@ -34,7 +34,8 @@
|
||||
thheller/shadow-cljs {:mvn/version "2.12.5"}
|
||||
expound/expound {:mvn/version "0.8.6"}
|
||||
com.lambdaisland/glogi {:mvn/version "1.0.116"}
|
||||
binaryage/devtools {:mvn/version "1.0.2"}}
|
||||
binaryage/devtools {:mvn/version "1.0.2"}
|
||||
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"}}
|
||||
|
||||
:aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
|
||||
:extra-deps {org.clojure/clojurescript {:mvn/version "1.10.844"}
|
||||
|
||||
2
libs/.npmignore
Normal file
2
libs/.npmignore
Normal file
@@ -0,0 +1,2 @@
|
||||
src/
|
||||
webpack.*
|
||||
17
libs/README.md
Normal file
17
libs/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
## @logseq/libs
|
||||
|
||||
🚀 Logseq SDK libraries [WIP].
|
||||
|
||||
#### Installation
|
||||
|
||||
```shell
|
||||
yarn add @logseq/libs
|
||||
```
|
||||
|
||||
#### Usage
|
||||
|
||||
Load `logseq` plugin sdk as global namespace
|
||||
|
||||
```js
|
||||
import "@logseq/libs"
|
||||
```
|
||||
5
libs/index.d.ts
vendored
Normal file
5
libs/index.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ILSPluginUser } from './dist/LSPlugin'
|
||||
|
||||
declare global {
|
||||
var logseq: ILSPluginUser
|
||||
}
|
||||
34
libs/package.json
Normal file
34
libs/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@logseq/libs",
|
||||
"version": "0.0.1-alpha.6",
|
||||
"description": "Logseq SDK libraries",
|
||||
"main": "dist/lsplugin.user.js",
|
||||
"typings": "index.d.ts",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"build:user": "webpack --mode production",
|
||||
"dev:user": "npm run build:user -- --mode development --watch",
|
||||
"build:core": "webpack --config webpack.config.core.js --mode production",
|
||||
"dev:core": "npm run build:core -- --mode development --watch",
|
||||
"build": "tsc && rm dist/*.js && cp src/*.d.ts dist/ && npm run build:user"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"dompurify": "^2.2.7",
|
||||
"eventemitter3": "^4.0.7",
|
||||
"path": "^0.12.7",
|
||||
"postmate": "^1.5.2",
|
||||
"snake-case": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.5",
|
||||
"@types/dompurify": "^2.2.1",
|
||||
"@types/lodash-es": "^4.17.4",
|
||||
"@types/postmate": "^1.5.1",
|
||||
"ts-loader": "^8.0.17",
|
||||
"typescript": "^4.2.2",
|
||||
"webpack": "^5.24.3",
|
||||
"webpack-bundle-analyzer": "^4.4.0",
|
||||
"webpack-cli": "^4.5.0"
|
||||
}
|
||||
}
|
||||
286
libs/src/LSPlugin.caller.ts
Normal file
286
libs/src/LSPlugin.caller.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import Postmate from 'postmate'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import { PluginLocal } from './LSPlugin.core'
|
||||
import Debug from 'debug'
|
||||
import { deferred } from './helpers'
|
||||
import { LSPluginShadowFrame } from './LSPlugin.shadow'
|
||||
|
||||
const debug = Debug('LSPlugin:caller')
|
||||
|
||||
type DeferredActor = ReturnType<typeof deferred>
|
||||
|
||||
export const LSPMSG = '#lspmsg#'
|
||||
export const LSPMSG_SETTINGS = '#lspmsg#settings#'
|
||||
export const LSPMSG_SYNC = '#lspmsg#reply#'
|
||||
export const LSPMSG_READY = '#lspmsg#ready#'
|
||||
export const LSPMSGFn = (id: string) => `${LSPMSG}${id}`
|
||||
|
||||
/**
|
||||
* Call between core and user
|
||||
*/
|
||||
class LSPluginCaller extends EventEmitter {
|
||||
private _connected: boolean = false
|
||||
|
||||
private _parent?: Postmate.ParentAPI
|
||||
private _child?: Postmate.ChildAPI
|
||||
|
||||
private _shadow?: LSPluginShadowFrame
|
||||
|
||||
private _status?: 'pending' | 'timeout'
|
||||
private _userModel: any = {}
|
||||
|
||||
private _call?: (type: string, payload: any, actor?: DeferredActor) => Promise<any>
|
||||
private _callUserModel?: (type: string, payload: any) => Promise<any>
|
||||
|
||||
constructor (
|
||||
private _pluginLocal: PluginLocal | null
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async connectToChild () {
|
||||
if (this._connected) return
|
||||
|
||||
const { shadow } = this._pluginLocal!
|
||||
|
||||
if (shadow) {
|
||||
await this._setupShadowSandbox()
|
||||
} else {
|
||||
await this._setupIframeSandbox()
|
||||
}
|
||||
}
|
||||
|
||||
async connectToParent (userModel = {}) {
|
||||
if (this._connected) return
|
||||
|
||||
const caller = this
|
||||
const isShadowMode = this._pluginLocal != null
|
||||
|
||||
let syncGCTimer: any = 0
|
||||
let syncTag = 0
|
||||
const syncActors = new Map<number, DeferredActor>()
|
||||
const readyDeferred = deferred()
|
||||
|
||||
const model: any = this._extendUserModel({
|
||||
[LSPMSG_READY]: async () => {
|
||||
await readyDeferred.resolve()
|
||||
},
|
||||
|
||||
[LSPMSG_SETTINGS]: async ({ type, payload }) => {
|
||||
caller.emit('settings:changed', payload)
|
||||
},
|
||||
|
||||
[LSPMSG]: async ({ ns, type, payload }: any) => {
|
||||
debug(`[call from host #${this._pluginLocal?.id}]`, ns, type, payload)
|
||||
|
||||
if (ns && ns.startsWith('hook')) {
|
||||
caller.emit(`${ns}:${type}`, payload)
|
||||
return
|
||||
}
|
||||
|
||||
caller.emit(type, payload)
|
||||
},
|
||||
|
||||
[LSPMSG_SYNC]: ({ _sync, result }: any) => {
|
||||
debug(`sync reply #${_sync}`, result)
|
||||
if (syncActors.has(_sync)) {
|
||||
// TODO: handle exception
|
||||
syncActors.get(_sync)?.resolve(result)
|
||||
syncActors.delete(_sync)
|
||||
}
|
||||
},
|
||||
|
||||
...userModel
|
||||
})
|
||||
|
||||
if (isShadowMode) {
|
||||
await readyDeferred.promise
|
||||
return JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()))
|
||||
}
|
||||
|
||||
const handshake = new Postmate.Model(model)
|
||||
|
||||
this._status = 'pending'
|
||||
|
||||
await handshake.then(refParent => {
|
||||
this._child = refParent
|
||||
this._connected = true
|
||||
|
||||
this._call = async (type, payload = {}, actor) => {
|
||||
if (actor) {
|
||||
const tag = ++syncTag
|
||||
syncActors.set(tag, actor)
|
||||
payload._sync = tag
|
||||
|
||||
actor.setTag(`async call #${tag}`)
|
||||
debug('async call #', tag)
|
||||
}
|
||||
|
||||
refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
|
||||
|
||||
return actor?.promise as Promise<any>
|
||||
}
|
||||
|
||||
this._callUserModel = async (type, payload) => {
|
||||
try {
|
||||
model[type](payload)
|
||||
} catch (e) {
|
||||
debug(`model method #${type} not existed`)
|
||||
}
|
||||
}
|
||||
|
||||
// actors GC
|
||||
syncGCTimer = setInterval(() => {
|
||||
if (syncActors.size > 100) {
|
||||
for (const [k, v] of syncActors) {
|
||||
if (v.settled) {
|
||||
syncActors.delete(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000 * 60 * 30)
|
||||
}).finally(() => {
|
||||
this._status = undefined
|
||||
})
|
||||
|
||||
// TODO: timeout
|
||||
await readyDeferred.promise
|
||||
|
||||
return model.baseInfo
|
||||
}
|
||||
|
||||
async call (type: any, payload: any = {}) {
|
||||
// TODO: ?
|
||||
this.emit(type, payload)
|
||||
return this._call?.call(this, type, payload)
|
||||
}
|
||||
|
||||
async callAsync (type: any, payload: any = {}) {
|
||||
const actor = deferred(1000 * 10)
|
||||
return this._call?.call(this, type, payload, actor)
|
||||
}
|
||||
|
||||
async callUserModel (type: string, payload: any = {}) {
|
||||
return this._callUserModel?.call(this, type, payload)
|
||||
}
|
||||
|
||||
async _setupIframeSandbox () {
|
||||
const pl = this._pluginLocal!
|
||||
|
||||
const handshake = new Postmate({
|
||||
container: document.body,
|
||||
url: pl.options.entry!,
|
||||
classListArray: ['lsp-iframe-sandbox'],
|
||||
model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
|
||||
})
|
||||
|
||||
this._status = 'pending'
|
||||
|
||||
// timeout for handshake
|
||||
let timer
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`handshake Timeout`))
|
||||
}, 3 * 1000) // 3secs
|
||||
|
||||
handshake.then(refChild => {
|
||||
this._parent = refChild
|
||||
this._connected = true
|
||||
this.emit('connected')
|
||||
|
||||
refChild.frame.setAttribute('id', pl.id)
|
||||
refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
|
||||
debug(`[call from plugin] `, type, payload)
|
||||
|
||||
this._pluginLocal?.emit(type, payload || {})
|
||||
})
|
||||
|
||||
this._call = async (...args: any) => {
|
||||
// parent all will get message
|
||||
await refChild.call(LSPMSGFn(pl.id), { type: args[0], payload: args[1] || {} })
|
||||
}
|
||||
|
||||
this._callUserModel = async (...args: any) => {
|
||||
await refChild.call(args[0], args[1] || {})
|
||||
}
|
||||
|
||||
resolve(null)
|
||||
}).catch(e => {
|
||||
reject(e)
|
||||
}).finally(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
}).catch(e => {
|
||||
debug('iframe sandbox error', e)
|
||||
throw e
|
||||
}).finally(() => {
|
||||
this._status = undefined
|
||||
})
|
||||
}
|
||||
|
||||
async _setupShadowSandbox () {
|
||||
const pl = this._pluginLocal!
|
||||
const shadow = this._shadow = new LSPluginShadowFrame(pl)
|
||||
|
||||
try {
|
||||
this._status = 'pending'
|
||||
|
||||
await shadow.load()
|
||||
|
||||
this._connected = true
|
||||
this.emit('connected')
|
||||
|
||||
this._call = async (type, payload = {}, actor) => {
|
||||
actor && (payload.actor = actor)
|
||||
|
||||
// TODO: support sync call
|
||||
// @ts-ignore Call in same thread
|
||||
this._pluginLocal?.emit(type, payload)
|
||||
|
||||
return actor?.promise
|
||||
}
|
||||
|
||||
this._callUserModel = async (...args: any) => {
|
||||
const type = args[0]
|
||||
const payload = args[1] || {}
|
||||
const fn = this._userModel[type]
|
||||
|
||||
if (typeof fn === 'function') {
|
||||
await fn.call(null, payload)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('shadow sandbox error', e)
|
||||
throw e
|
||||
} finally {
|
||||
this._status = undefined
|
||||
}
|
||||
}
|
||||
|
||||
_extendUserModel (model: any) {
|
||||
return Object.assign(this._userModel, model)
|
||||
}
|
||||
|
||||
_getSandboxIframeContainer () {
|
||||
return this._parent?.frame
|
||||
}
|
||||
|
||||
_getSandboxShadowContainer () {
|
||||
return this._shadow?.frame
|
||||
}
|
||||
|
||||
async destroy () {
|
||||
if (this._parent) {
|
||||
await this._parent.destroy()
|
||||
}
|
||||
|
||||
if (this._shadow) {
|
||||
this._shadow.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
LSPluginCaller
|
||||
}
|
||||
1001
libs/src/LSPlugin.core.ts
Normal file
1001
libs/src/LSPlugin.core.ts
Normal file
File diff suppressed because it is too large
Load Diff
196
libs/src/LSPlugin.d.ts
vendored
Normal file
196
libs/src/LSPlugin.d.ts
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import { LSPluginCaller } from './LSPlugin.caller'
|
||||
import { LSPluginUser } from './LSPlugin.user'
|
||||
|
||||
type PluginLocalIdentity = string
|
||||
|
||||
type ThemeOptions = {
|
||||
name: string
|
||||
url: string
|
||||
description?: string
|
||||
mode?: 'dark' | 'light'
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type StyleString = string
|
||||
type StyleOptions = {
|
||||
key?: string
|
||||
style: StyleString
|
||||
}
|
||||
|
||||
type UIBaseOptions = {
|
||||
key?: string
|
||||
replace?: boolean
|
||||
template: string
|
||||
}
|
||||
|
||||
type UIPathIdentity = {
|
||||
path: string // dom selector
|
||||
}
|
||||
|
||||
type UISlotIdentity = {
|
||||
slot: string // slot key
|
||||
}
|
||||
|
||||
type UISlotOptions = UIBaseOptions & UISlotIdentity
|
||||
type UIPathOptions = UIBaseOptions & UIPathIdentity
|
||||
type UIOptions = UIPathOptions & UISlotOptions
|
||||
|
||||
interface LSPluginPkgConfig {
|
||||
id: PluginLocalIdentity
|
||||
mode: 'shadow' | 'iframe'
|
||||
themes: Array<ThemeOptions>
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface LSPluginBaseInfo {
|
||||
id: string // should be unique
|
||||
mode: 'shadow' | 'iframe'
|
||||
|
||||
settings: {
|
||||
disabled: boolean
|
||||
[key: string]: any
|
||||
},
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type IHookEvent = {
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type IUserHook = (callback: (e: IHookEvent) => void) => void
|
||||
type IUserSlotHook = (callback: (e: IHookEvent & UISlotIdentity) => void) => void
|
||||
|
||||
interface BlockEntity {
|
||||
uuid: string
|
||||
content: string
|
||||
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
type BlockIdentity = 'string' | Pick<BlockEntity, 'uuid'>
|
||||
type SlashCommandActionTag = 'editor/input' | 'editor/hook' | 'editor/clear-current-slash'
|
||||
type SlashCommandAction = [SlashCommandActionTag, ...args: any]
|
||||
|
||||
interface IAppProxy {
|
||||
pushState: (k: string, params?: {}) => void
|
||||
replaceState: (k: string, params?: {}) => void
|
||||
getUserState: () => Promise<any>
|
||||
showMsg: (content: string, status?: 'success' | 'warning' | string) => void
|
||||
setZoomFactor: (factor: number) => void
|
||||
onThemeModeChanged: IUserHook
|
||||
onPageFileMounted: IUserSlotHook
|
||||
onBlockRendererMounted: IUserSlotHook
|
||||
}
|
||||
|
||||
interface IEditorProxy {
|
||||
registerSlashCommand: (this: LSPluginUser, tag: string, actions: Array<SlashCommandAction>) => boolean
|
||||
registerBlockContextMenu: (this: LSPluginUser, tag: string, action: () => void) => boolean
|
||||
|
||||
// TODO: Block related APIs
|
||||
getCurrentBlock: () => Promise<BlockIdentity>
|
||||
getCurrentPageBlocksTree: <T = any> () => Promise<T>
|
||||
|
||||
insertBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ before: boolean, sibling: boolean, props: {} }>) => Promise<BlockIdentity>
|
||||
updateBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ props: {} }>) => Promise<void>
|
||||
removeBlock: (srcBlock: BlockIdentity, opts: Partial<{ includeChildren: boolean }>) => Promise<void>
|
||||
touchBlock: (srcBlock: BlockIdentity) => Promise<BlockIdentity>
|
||||
moveBlock: (srcBlock: BlockIdentity, targetBlock: BlockIdentity, opts: Partial<{ before: boolean, sibling: boolean }>) => Promise<void>
|
||||
|
||||
updateBlockProperty: (block: BlockIdentity, key: string, value: any) => Promise<void>
|
||||
removeBlockProperty: (block: BlockIdentity) => Promise<void>
|
||||
}
|
||||
|
||||
interface IDBProxy {
|
||||
datascriptQuery: <T = any>(query: string) => Promise<T>
|
||||
}
|
||||
|
||||
interface ILSPluginThemeManager extends EventEmitter {
|
||||
themes: Map<PluginLocalIdentity, Array<ThemeOptions>>
|
||||
|
||||
registerTheme (id: PluginLocalIdentity, opt: ThemeOptions): Promise<void>
|
||||
|
||||
unregisterTheme (id: PluginLocalIdentity): Promise<void>
|
||||
|
||||
selectTheme (opt?: ThemeOptions): Promise<void>
|
||||
}
|
||||
|
||||
type LSPluginUserEvents = 'ui:visible:changed' | 'settings:changed'
|
||||
|
||||
interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
|
||||
/**
|
||||
* Indicate connected with host
|
||||
*/
|
||||
connected: boolean
|
||||
|
||||
/**
|
||||
* Duplex message caller
|
||||
*/
|
||||
caller: LSPluginCaller
|
||||
|
||||
/**
|
||||
* Most from packages
|
||||
*/
|
||||
baseInfo: LSPluginBaseInfo
|
||||
|
||||
/**
|
||||
* Plugin user settings
|
||||
*/
|
||||
settings?: LSPluginBaseInfo['settings']
|
||||
|
||||
/**
|
||||
* Ready for host connected
|
||||
*/
|
||||
ready (model?: Record<string, any>): Promise<any>
|
||||
|
||||
ready (callback?: (e: any) => void | {}): Promise<any>
|
||||
|
||||
ready (model?: Record<string, any>, callback?: (e: any) => void | {}): Promise<any>
|
||||
|
||||
/**
|
||||
* @param model
|
||||
*/
|
||||
provideModel (model: Record<string, any>): this
|
||||
|
||||
/**
|
||||
* @param theme options
|
||||
*/
|
||||
provideTheme (theme: ThemeOptions): this
|
||||
|
||||
/**
|
||||
* @param style
|
||||
*/
|
||||
provideStyle (style: StyleString | StyleOptions): this
|
||||
|
||||
/**
|
||||
* @param ui options
|
||||
*/
|
||||
provideUI (ui: UIOptions): this
|
||||
|
||||
/**
|
||||
* @param attrs
|
||||
*/
|
||||
updateSettings (attrs: Record<string, any>): void
|
||||
|
||||
/**
|
||||
* MainUI for index.html
|
||||
* @param attrs
|
||||
*/
|
||||
setMainUIAttrs (attrs: Record<string, any>): void
|
||||
|
||||
setMainUIInlineStyle (style: CSSStyleDeclaration): void
|
||||
|
||||
showMainUI (): void
|
||||
|
||||
hideMainUI (): void
|
||||
|
||||
toggleMainUI (): void
|
||||
|
||||
isMainUIVisible: boolean
|
||||
|
||||
App: IAppProxy
|
||||
Editor: IEditorProxy
|
||||
DB: IDBProxy
|
||||
}
|
||||
116
libs/src/LSPlugin.shadow.ts
Normal file
116
libs/src/LSPlugin.shadow.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import EventEmitter from 'eventemitter3'
|
||||
import { PluginLocal } from './LSPlugin.core'
|
||||
import { LSPluginUser } from './LSPlugin.user'
|
||||
|
||||
// @ts-ignore
|
||||
const { importHTML, createSandboxContainer } = window.QSandbox || {}
|
||||
|
||||
function userFetch (url, opts) {
|
||||
if (!url.startsWith('http')) {
|
||||
url = url.replace('file://', '')
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const content = await window.apis.doAction(['readFile', url])
|
||||
resolve({
|
||||
text () {
|
||||
return content
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
}
|
||||
|
||||
class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
|
||||
private _frame?: HTMLElement
|
||||
private _root?: ShadowRoot
|
||||
private _loaded = false
|
||||
private _unmountFns: Array<() => Promise<void>> = []
|
||||
|
||||
constructor (
|
||||
private _pluginLocal: PluginLocal
|
||||
) {
|
||||
super()
|
||||
|
||||
_pluginLocal._dispose(() => {
|
||||
this._unmount()
|
||||
})
|
||||
}
|
||||
|
||||
async load () {
|
||||
const { name, entry } = this._pluginLocal.options
|
||||
|
||||
if (this.loaded || !entry) return
|
||||
|
||||
const { template, execScripts } = await importHTML(entry, { fetch: userFetch })
|
||||
|
||||
this._mount(template, document.body)
|
||||
|
||||
const sandbox = createSandboxContainer(
|
||||
name, {
|
||||
elementGetter: () => this._root?.firstChild,
|
||||
}
|
||||
)
|
||||
|
||||
const global = sandbox.instance.proxy as any
|
||||
|
||||
global.__shadow_mode__ = true
|
||||
global.LSPluginLocal = this._pluginLocal
|
||||
global.LSPluginShadow = this
|
||||
global.LSPluginUser = global.logseq = new LSPluginUser(
|
||||
this._pluginLocal.toJSON() as any,
|
||||
this._pluginLocal.caller!
|
||||
)
|
||||
|
||||
// TODO: {mount, unmount}
|
||||
const execResult: any = await execScripts(global, true)
|
||||
|
||||
this._unmountFns.push(execResult.unmount)
|
||||
|
||||
this._loaded = true
|
||||
}
|
||||
|
||||
_mount (content: string, container: HTMLElement) {
|
||||
const frame = this._frame = document.createElement('div')
|
||||
frame.classList.add('lsp-shadow-sandbox')
|
||||
frame.id = this._pluginLocal.id
|
||||
|
||||
this._root = frame.attachShadow({ mode: 'open' })
|
||||
this._root.innerHTML = `<div>${content}</div>`
|
||||
|
||||
container.appendChild(frame)
|
||||
|
||||
this.emit('mounted')
|
||||
}
|
||||
|
||||
_unmount () {
|
||||
for (const fn of this._unmountFns) {
|
||||
fn && fn.call(null)
|
||||
}
|
||||
}
|
||||
|
||||
destroy () {
|
||||
this.frame?.parentNode?.removeChild(this.frame)
|
||||
}
|
||||
|
||||
get loaded (): boolean {
|
||||
return this._loaded
|
||||
}
|
||||
|
||||
get document () {
|
||||
return this._root?.firstChild as HTMLElement
|
||||
}
|
||||
|
||||
get frame (): HTMLElement {
|
||||
return this._frame!
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
LSPluginShadowFrame
|
||||
}
|
||||
303
libs/src/LSPlugin.user.ts
Normal file
303
libs/src/LSPlugin.user.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { deepMerge, invokeHostExportedApi } from './helpers'
|
||||
import { LSPluginCaller } from './LSPlugin.caller'
|
||||
import {
|
||||
IAppProxy, IDBProxy,
|
||||
IEditorProxy,
|
||||
ILSPluginUser,
|
||||
LSPluginBaseInfo, LSPluginUserEvents, SlashCommandAction,
|
||||
StyleString,
|
||||
ThemeOptions,
|
||||
UIOptions
|
||||
} from './LSPlugin'
|
||||
import Debug from 'debug'
|
||||
import { snakeCase } from 'snake-case'
|
||||
import EventEmitter from 'eventemitter3'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__LSP__HOST__: boolean
|
||||
logseq: ILSPluginUser
|
||||
}
|
||||
}
|
||||
|
||||
const debug = Debug('LSPlugin:user')
|
||||
|
||||
const app: Partial<IAppProxy> = {}
|
||||
|
||||
let registeredCmdUid = 0
|
||||
|
||||
const editor: Partial<IEditorProxy> = {
|
||||
registerSlashCommand (
|
||||
this: LSPluginUser,
|
||||
tag: string,
|
||||
actions: Array<SlashCommandAction>
|
||||
) {
|
||||
debug('Register slash command #', this.baseInfo.id, tag, 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]]
|
||||
})
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
registerBlockContextMenu (
|
||||
this: LSPluginUser,
|
||||
tag: string,
|
||||
action: () => void
|
||||
): boolean {
|
||||
if (typeof action !== 'function') {
|
||||
return false
|
||||
}
|
||||
|
||||
const key = tag
|
||||
const label = tag
|
||||
const type = 'block-context-menu'
|
||||
const eventKey = `SimpleCommandHook${tag}${++registeredCmdUid}`
|
||||
|
||||
this.Editor['on' + eventKey](action)
|
||||
|
||||
this.caller?.call(`api:call`, {
|
||||
method: 'register-plugin-simple-command',
|
||||
args: [this.baseInfo.id, [{ key, label, type }, ['editor/hook', eventKey]]]
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const db: Partial<IDBProxy> = {}
|
||||
|
||||
type uiState = {
|
||||
key?: number,
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
const KEY_MAIN_UI = 0
|
||||
|
||||
/**
|
||||
* User plugin instance
|
||||
*/
|
||||
export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements ILSPluginUser {
|
||||
/**
|
||||
* Indicate connected with host
|
||||
* @private
|
||||
*/
|
||||
private _connected: boolean = false
|
||||
private _ui = new Map<number, uiState>()
|
||||
|
||||
/**
|
||||
* @param _baseInfo
|
||||
* @param _caller
|
||||
*/
|
||||
constructor (
|
||||
private _baseInfo: LSPluginBaseInfo,
|
||||
private _caller: LSPluginCaller
|
||||
) {
|
||||
super()
|
||||
|
||||
_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)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
baseInfo = deepMerge(this._baseInfo, baseInfo)
|
||||
|
||||
this._connected = true
|
||||
|
||||
callback && callback.call(this, baseInfo)
|
||||
} catch (e) {
|
||||
console.error('[LSPlugin Ready Error]', e)
|
||||
}
|
||||
}
|
||||
|
||||
provideModel (model: Record<string, any>) {
|
||||
this.caller._extendUserModel(model)
|
||||
return this
|
||||
}
|
||||
|
||||
provideTheme (theme: ThemeOptions) {
|
||||
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
|
||||
}
|
||||
|
||||
updateSettings (attrs: Record<string, any>) {
|
||||
this.caller.call('settings:update', attrs)
|
||||
// TODO: update associated baseInfo settings
|
||||
}
|
||||
|
||||
setMainUIAttrs (attrs: Record<string, any>): void {
|
||||
this.caller.call('main-ui:attrs', attrs)
|
||||
}
|
||||
|
||||
setMainUIInlineStyle (style: CSSStyleDeclaration): void {
|
||||
this.caller.call('main-ui:style', style)
|
||||
}
|
||||
|
||||
hideMainUI (): void {
|
||||
const payload = { key: KEY_MAIN_UI, visible: false }
|
||||
this.caller.call('main-ui:visible', payload)
|
||||
this.emit('ui:visible:changed', payload)
|
||||
this._ui.set(payload.key, payload)
|
||||
}
|
||||
|
||||
showMainUI (): void {
|
||||
const payload = { key: KEY_MAIN_UI, visible: true }
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
get isMainUIVisible (): boolean {
|
||||
const state = this._ui.get(0)
|
||||
return Boolean(state && state.visible)
|
||||
}
|
||||
|
||||
get connected (): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get baseInfo (): LSPluginBaseInfo {
|
||||
return this._baseInfo
|
||||
}
|
||||
|
||||
get settings () {
|
||||
return this.baseInfo?.settings
|
||||
}
|
||||
|
||||
get caller (): LSPluginCaller {
|
||||
return this._caller
|
||||
}
|
||||
|
||||
_makeUserProxy (
|
||||
target: any,
|
||||
tag?: 'app' | 'editor' | 'db'
|
||||
) {
|
||||
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) {
|
||||
const ret = origMethod.apply(that, args)
|
||||
if (ret === false) return
|
||||
}
|
||||
|
||||
// Handle hook
|
||||
if (tag) {
|
||||
const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
|
||||
|
||||
if (hookMatcher != null) {
|
||||
const f = hookMatcher[0]
|
||||
const s = hookMatcher.input!
|
||||
const e = s.slice(f.length)
|
||||
|
||||
caller[f.toLowerCase()](`hook:${tag}:${snakeCase(e)}`, args[0])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Call host
|
||||
return caller.callAsync(`api:call`, {
|
||||
method: propKey,
|
||||
args: args
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get App (): IAppProxy {
|
||||
return this._makeUserProxy(app, 'app')
|
||||
}
|
||||
|
||||
get Editor () {
|
||||
return this._makeUserProxy(editor, 'editor')
|
||||
}
|
||||
|
||||
get DB (): IDBProxy {
|
||||
return this._makeUserProxy(db)
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPluginUserInstance (
|
||||
pluginBaseInfo: LSPluginBaseInfo,
|
||||
pluginCaller: LSPluginCaller
|
||||
) {
|
||||
return new LSPluginUser(pluginBaseInfo, pluginCaller)
|
||||
}
|
||||
|
||||
if (window.__LSP__HOST__ == null) { // Entry of iframe mode
|
||||
debug('Entry of iframe mode.')
|
||||
|
||||
const caller = new LSPluginCaller(null)
|
||||
window.logseq = setupPluginUserInstance({} as any, caller)
|
||||
}
|
||||
279
libs/src/helpers.ts
Normal file
279
libs/src/helpers.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { StyleString, UIOptions } from './LSPlugin'
|
||||
import { PluginLocal } from './LSPlugin.core'
|
||||
import { snakeCase } from 'snake-case'
|
||||
|
||||
interface IObject {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: any
|
||||
apis: any
|
||||
}
|
||||
}
|
||||
|
||||
export function isObject (item: any) {
|
||||
return (item === Object(item) && !Array.isArray(item))
|
||||
}
|
||||
|
||||
export function deepMerge (
|
||||
target: IObject,
|
||||
...sources: Array<IObject>
|
||||
) {
|
||||
// return the target if no sources passed
|
||||
if (!sources.length) {
|
||||
return target
|
||||
}
|
||||
|
||||
const result: IObject = target
|
||||
|
||||
if (isObject(result)) {
|
||||
const len: number = sources.length
|
||||
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const elm: any = sources[i]
|
||||
|
||||
if (isObject(elm)) {
|
||||
for (const key in elm) {
|
||||
if (elm.hasOwnProperty(key)) {
|
||||
if (isObject(elm[key])) {
|
||||
if (!result[key] || !isObject(result[key])) {
|
||||
result[key] = {}
|
||||
}
|
||||
deepMerge(result[key], elm[key])
|
||||
} else {
|
||||
if (Array.isArray(result[key]) && Array.isArray(elm[key])) {
|
||||
// concatenate the two arrays and remove any duplicate primitive values
|
||||
result[key] = Array.from(new Set(result[key].concat(elm[key])))
|
||||
} else {
|
||||
result[key] = elm[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function genID () {
|
||||
// Math.random should be unique because of its seeding algorithm.
|
||||
// Convert it to base 36 (numbers + letters), and grab the first 9 characters
|
||||
// after the decimal.
|
||||
return '_' + Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
export function ucFirst (str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
export function withFileProtocol (path: string) {
|
||||
if (!path) return ''
|
||||
const reg = /^(http|file|assets)/
|
||||
|
||||
if (!reg.test(path)) {
|
||||
path = 'file://' + path
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* @param timeout milliseconds
|
||||
* @param tag string
|
||||
*/
|
||||
export function deferred<T = any> (timeout?: number, tag?: string) {
|
||||
let resolve: any, reject: any
|
||||
let settled = false
|
||||
const timeFn = (r: Function) => {
|
||||
return (v: T) => {
|
||||
timeout && clearTimeout(timeout)
|
||||
r(v)
|
||||
settled = true
|
||||
}
|
||||
}
|
||||
|
||||
const promise = new Promise<T>((resolve1, reject1) => {
|
||||
resolve = timeFn(resolve1)
|
||||
reject = timeFn(reject1)
|
||||
|
||||
if (timeout) {
|
||||
// @ts-ignore
|
||||
timeout = setTimeout(() => reject(new Error(`[deferred timeout] ${tag}`)), timeout)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
created: Date.now(),
|
||||
setTag: (t: string) => tag = t,
|
||||
resolve, reject, promise,
|
||||
get settled () {
|
||||
return settled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function invokeHostExportedApi (
|
||||
method: string,
|
||||
...args: Array<any>
|
||||
) {
|
||||
const method1 = snakeCase(method)
|
||||
const fn = window.api[method1] || window.apis[method1] ||
|
||||
window.api[method] || window.apis[method]
|
||||
|
||||
if (!fn) {
|
||||
throw new Error(`Not existed method #${method}`)
|
||||
}
|
||||
return typeof fn !== 'function' ? fn : fn.apply(null, args)
|
||||
}
|
||||
|
||||
export function setupIframeSandbox (
|
||||
props: Record<string, any>,
|
||||
target: HTMLElement
|
||||
) {
|
||||
const iframe = document.createElement('iframe')
|
||||
|
||||
iframe.classList.add('lsp-iframe-sandbox')
|
||||
|
||||
Object.entries(props).forEach(([k, v]) => {
|
||||
iframe.setAttribute(k, v)
|
||||
})
|
||||
|
||||
target.appendChild(iframe)
|
||||
|
||||
return async () => {
|
||||
target.removeChild(iframe)
|
||||
}
|
||||
}
|
||||
|
||||
export function setupInjectedStyle (
|
||||
style: StyleString,
|
||||
attrs: Record<string, any>
|
||||
) {
|
||||
const key = attrs['data-injected-style']
|
||||
let el = key && document.querySelector(`[data-injected-style=${key}]`)
|
||||
|
||||
if (el) {
|
||||
el.textContent = style
|
||||
return
|
||||
}
|
||||
|
||||
el = document.createElement('style')
|
||||
el.textContent = style
|
||||
|
||||
attrs && Object.entries(attrs).forEach(([k, v]) => {
|
||||
el.setAttribute(k, v)
|
||||
})
|
||||
|
||||
document.head.append(el)
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
export function setupInjectedUI (
|
||||
this: PluginLocal,
|
||||
ui: UIOptions,
|
||||
attrs: Record<string, any>
|
||||
) {
|
||||
const pl = this
|
||||
const selector = ui.path || `#${ui.slot}`
|
||||
|
||||
const target = selector && document.querySelector(selector)
|
||||
if (!target) {
|
||||
console.error(`${this.debugTag} can not resolve selector target ${selector}`)
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${ui.key}-${pl.id}`
|
||||
|
||||
let el = document.querySelector(`div[data-injected-ui="${key}"]`) as HTMLElement
|
||||
|
||||
if (el) {
|
||||
el.innerHTML = ui.template
|
||||
return
|
||||
}
|
||||
|
||||
el = document.createElement('div')
|
||||
el.dataset.injectedUi = key || ''
|
||||
|
||||
// TODO: Support more
|
||||
el.innerHTML = ui.template
|
||||
|
||||
attrs && Object.entries(attrs).forEach(([k, v]) => {
|
||||
el.setAttribute(k, v)
|
||||
})
|
||||
|
||||
target.appendChild(el);
|
||||
|
||||
// TODO: How handle events
|
||||
['click', 'focus', 'focusin', 'focusout', 'blur', 'dblclick',
|
||||
'keyup', 'keypress', 'keydown', 'change', 'input'].forEach((type) => {
|
||||
el.addEventListener(type, (e) => {
|
||||
const target = e.target! as HTMLElement
|
||||
const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
|
||||
if (!trigger) return
|
||||
|
||||
const msgType = trigger.dataset[`on${ucFirst(type)}`]
|
||||
msgType && pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
|
||||
}, false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
target!.removeChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
export function transformableEvent (target: HTMLElement, e: Event) {
|
||||
const obj: any = {}
|
||||
|
||||
if (target) {
|
||||
const ds = target.dataset
|
||||
const FLAG_RECT = 'rect'
|
||||
|
||||
;['value', 'id', 'className',
|
||||
'dataset', FLAG_RECT
|
||||
].forEach((k) => {
|
||||
let v: any
|
||||
|
||||
switch (k) {
|
||||
case FLAG_RECT:
|
||||
if (!ds.hasOwnProperty(FLAG_RECT)) return
|
||||
v = target.getBoundingClientRect().toJSON()
|
||||
break
|
||||
default:
|
||||
v = target[k]
|
||||
}
|
||||
|
||||
if (typeof v === 'object') {
|
||||
v = { ...v }
|
||||
}
|
||||
|
||||
obj[k] = v
|
||||
})
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
let injectedThemeEffect: any = null
|
||||
|
||||
export function setupInjectedTheme (url?: string) {
|
||||
injectedThemeEffect?.call()
|
||||
|
||||
if (!url) return
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = url
|
||||
document.head.appendChild(link)
|
||||
|
||||
return (injectedThemeEffect = () => {
|
||||
document.head.removeChild(link)
|
||||
injectedThemeEffect = null
|
||||
})
|
||||
}
|
||||
88
libs/tsconfig.json
Normal file
88
libs/tsconfig.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "ESNext",
|
||||
/* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
|
||||
"module": "ESNext",
|
||||
/* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
"allowJs": true,
|
||||
/* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "react",
|
||||
/* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"declaration": true,
|
||||
/* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
"sourceMap": false,
|
||||
/* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "dist",
|
||||
/* Redirect output structure to the directory. */
|
||||
"rootDir": "src",
|
||||
/* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true,
|
||||
/* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
// "strict": true,
|
||||
/* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true,
|
||||
/* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true,
|
||||
/* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node",
|
||||
/* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true,
|
||||
/* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true,
|
||||
/* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true
|
||||
/* Disallow inconsistently-cased references to the same file. */
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
]
|
||||
}
|
||||
32
libs/webpack.config.core.js
Normal file
32
libs/webpack.config.core.js
Normal file
@@ -0,0 +1,32 @@
|
||||
const webpack = require('webpack')
|
||||
const path = require('path')
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
|
||||
|
||||
module.exports = {
|
||||
entry: './src/LSPlugin.core.ts',
|
||||
devtool: 'inline-source-map',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
plugins: [
|
||||
new webpack.ProvidePlugin({
|
||||
process: 'process/browser',
|
||||
}),
|
||||
// new BundleAnalyzerPlugin()
|
||||
],
|
||||
output: {
|
||||
library: 'LSPlugin',
|
||||
libraryTarget: 'umd',
|
||||
filename: 'lsplugin.core.js',
|
||||
path: path.resolve(__dirname, '../static/js'),
|
||||
},
|
||||
}
|
||||
27
libs/webpack.config.js
Normal file
27
libs/webpack.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const path = require('path')
|
||||
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
|
||||
|
||||
module.exports = {
|
||||
entry: './src/LSPlugin.user.ts',
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.tsx?$/,
|
||||
use: 'ts-loader',
|
||||
exclude: /node_modules/,
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: {
|
||||
extensions: ['.tsx', '.ts', '.js'],
|
||||
},
|
||||
plugins: [
|
||||
// new BundleAnalyzerPlugin()
|
||||
],
|
||||
output: {
|
||||
library: "LSPluginEntry",
|
||||
libraryTarget: "umd",
|
||||
filename: 'lsplugin.user.js',
|
||||
path: path.resolve(__dirname, 'dist')
|
||||
},
|
||||
}
|
||||
1251
libs/yarn.lock
Normal file
1251
libs/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,137 @@
|
||||
(ns ^:no-doc api
|
||||
(:require [frontend.db :as db]
|
||||
[frontend.db.model :as db-model]
|
||||
[frontend.handler.block :as block-handler]
|
||||
[frontend.util :as util]
|
||||
[electron.ipc :as ipc]
|
||||
[promesa.core :as p]
|
||||
[camel-snake-kebab.core :as csk]
|
||||
[cljs-bean.core :as bean]
|
||||
[frontend.state :as state]
|
||||
[frontend.components.plugins :as plugins]
|
||||
[frontend.handler.plugin :as plugin-handler]
|
||||
[frontend.handler.notification :as notification]
|
||||
[datascript.core :as d]
|
||||
[frontend.fs :as fs]
|
||||
[clojure.string :as string]
|
||||
[clojure.walk :as walk]
|
||||
[cljs.reader]
|
||||
[reitit.frontend.easy :as rfe]
|
||||
[frontend.db.query-dsl :as query-dsl]))
|
||||
|
||||
;; base
|
||||
(def ^:export show_themes
|
||||
(fn []
|
||||
(plugins/open-select-theme!)))
|
||||
|
||||
(def ^:export set_theme_mode
|
||||
(fn [mode]
|
||||
(state/set-theme! (if (= mode "light") "white" "dark"))))
|
||||
|
||||
(def ^:export load_plugin_config
|
||||
(fn [path]
|
||||
(fs/read-file "" (util/node-path.join path "package.json"))))
|
||||
|
||||
(def ^:export load_plugin_readme
|
||||
(fn [path]
|
||||
(fs/read-file "" (util/node-path.join path "readme.md"))))
|
||||
|
||||
(def ^:export save_plugin_config
|
||||
(fn [path ^js data]
|
||||
(let [repo ""
|
||||
path (util/node-path.join path "package.json")]
|
||||
(fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
|
||||
|
||||
(def ^:export write_user_tmp_file
|
||||
(fn [file content]
|
||||
(p/let [repo ""
|
||||
path (plugin-handler/get-ls-dotdir-root)
|
||||
path (util/node-path.join path "tmp")
|
||||
exist? (fs/file-exists? path "")
|
||||
_ (when-not exist? (fs/mkdir! path))
|
||||
path (util/node-path.join path file)
|
||||
_ (fs/write-file! repo "" path content {:skip-mtime? true})]
|
||||
path)))
|
||||
|
||||
(def ^:export load_user_preferences
|
||||
(fn []
|
||||
(p/let [repo ""
|
||||
path (plugin-handler/get-ls-dotdir-root)
|
||||
path (util/node-path.join path "preferences.json")
|
||||
_ (fs/create-if-not-exists repo "" path)
|
||||
json (fs/read-file "" path)
|
||||
json (if (string/blank? json) "{}" json)]
|
||||
(js/JSON.parse json))))
|
||||
|
||||
(def ^:export save_user_preferences
|
||||
(fn [^js data]
|
||||
(when data
|
||||
(p/let [repo ""
|
||||
path (plugin-handler/get-ls-dotdir-root)
|
||||
path (util/node-path.join path "preferences.json")]
|
||||
(fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true})))))
|
||||
|
||||
(def ^:export load_plugin_user_settings
|
||||
(fn [key]
|
||||
(p/let [repo ""
|
||||
path (plugin-handler/get-ls-dotdir-root)
|
||||
exist? (fs/file-exists? path "settings")
|
||||
_ (when-not exist? (fs/mkdir! (util/node-path.join path "settings")))
|
||||
path (util/node-path.join path "settings" (str key ".json"))
|
||||
_ (fs/create-if-not-exists repo "" path "{}")
|
||||
json (fs/read-file "" path)]
|
||||
[path (js/JSON.parse json)])))
|
||||
|
||||
(def ^:export save_plugin_user_settings
|
||||
(fn [key ^js data]
|
||||
(p/let [repo ""
|
||||
path (plugin-handler/get-ls-dotdir-root)
|
||||
path (util/node-path.join path "settings" (str key ".json"))]
|
||||
(fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
|
||||
|
||||
(def ^:export register_plugin_slash_command
|
||||
(fn [pid ^js cmd-actions]
|
||||
(when-let [[cmd actions] (bean/->clj cmd-actions)]
|
||||
(plugin-handler/register-plugin-slash-command
|
||||
pid [cmd (mapv #(into [(keyword (first %))]
|
||||
(rest %)) actions)]))))
|
||||
|
||||
(def ^:export register_plugin_simple_command
|
||||
(fn [pid ^js cmd-action]
|
||||
(when-let [[cmd action] (bean/->clj cmd-action)]
|
||||
(plugin-handler/register-plugin-simple-command
|
||||
pid cmd (assoc action 0 (keyword (first action)))))))
|
||||
|
||||
;; app
|
||||
(def ^:export push_state
|
||||
(fn [^js k ^js params]
|
||||
(rfe/push-state
|
||||
(keyword k) (bean/->clj params))))
|
||||
|
||||
(def ^:export replace_state
|
||||
(fn [^js k ^js params]
|
||||
(rfe/replace-state
|
||||
(keyword k) (bean/->clj params))))
|
||||
|
||||
;; editor
|
||||
(def ^:export get_current_page_blocks_tree
|
||||
(fn []
|
||||
(when-let [page (state/get-current-page)]
|
||||
(let [blocks (db-model/get-page-blocks-no-cache page)
|
||||
blocks (mapv #(-> %
|
||||
(dissoc :block/children)
|
||||
(assoc :block/uuid (str (:block/uuid %))))
|
||||
blocks)
|
||||
blocks (block-handler/blocks->vec-tree blocks)
|
||||
;; clean key
|
||||
blocks (walk/postwalk
|
||||
(fn [a]
|
||||
(if (keyword? a)
|
||||
(csk/->camelCase (name a))
|
||||
a)) blocks)]
|
||||
(bean/->js blocks)))))
|
||||
|
||||
;; db
|
||||
(defn ^:export q
|
||||
[query-string]
|
||||
(when-let [repo (state/get-current-repo)]
|
||||
@@ -21,3 +148,8 @@
|
||||
(clj->js result)))))
|
||||
|
||||
(def ^:export custom_query db/custom-query)
|
||||
|
||||
;; helpers
|
||||
(defn ^:export show_msg
|
||||
([content] (show_msg content :success))
|
||||
([content status] (notification/show! content (keyword status))))
|
||||
|
||||
154
src/main/frontend/components/plugins.cljs
Normal file
154
src/main/frontend/components/plugins.cljs
Normal file
@@ -0,0 +1,154 @@
|
||||
(ns frontend.components.plugins
|
||||
(:require [rum.core :as rum]
|
||||
[frontend.state :as state]
|
||||
[cljs-bean.core :as bean]
|
||||
[frontend.ui :as ui]
|
||||
[frontend.util :as util]
|
||||
[electron.ipc :as ipc]
|
||||
[promesa.core :as p]
|
||||
[frontend.components.svg :as svg]
|
||||
[frontend.handler.notification :as notification]
|
||||
[frontend.handler.plugin :as plugin-handler]))
|
||||
|
||||
(rum/defc installed-themes
|
||||
< rum/reactive
|
||||
[]
|
||||
(let [themes (state/sub :plugin/installed-themes)
|
||||
selected (state/sub :plugin/selected-theme)]
|
||||
[:div.cp__themes-installed
|
||||
[:h2.mb-4.text-xl "Installed Themes"]
|
||||
(for [opt themes]
|
||||
(let [current-selected (= selected (:url opt))]
|
||||
[:div.it.flex.px-3.py-2.mb-2.rounded-sm.justify-between
|
||||
{:key (:url opt)
|
||||
:class [(if current-selected "selected")]
|
||||
:on-click #(do (js/LSPluginCore.selectTheme (if current-selected nil (clj->js opt)))
|
||||
(state/set-modal! nil))}
|
||||
[:section
|
||||
[:strong.block (:name opt)]
|
||||
[:small.opacity-30 (:description opt)]]
|
||||
[:small.flex-shrink-0.flex.items-center.opacity-10
|
||||
(if current-selected "current")]]))]))
|
||||
|
||||
(rum/defc unpacked-plugin-loader
|
||||
[unpacked-pkg-path]
|
||||
(rum/use-effect!
|
||||
(fn []
|
||||
(let [err-handle
|
||||
(fn [^js e]
|
||||
(case (keyword (aget e "name"))
|
||||
:IllegalPluginPackageError
|
||||
(notification/show! "Illegal Logseq plugin package." :error)
|
||||
:ExistedImportedPluginPackageError
|
||||
(notification/show! "Existed Imported plugin package." :error)
|
||||
:default)
|
||||
(plugin-handler/reset-unpacked-state))
|
||||
reg-handle #(plugin-handler/reset-unpacked-state)]
|
||||
(when unpacked-pkg-path
|
||||
(doto js/LSPluginCore
|
||||
(.once "error" err-handle)
|
||||
(.once "registered" reg-handle)
|
||||
(.register (bean/->js {:url unpacked-pkg-path}))))
|
||||
#(doto js/LSPluginCore
|
||||
(.off "error" err-handle)
|
||||
(.off "registered" reg-handle))))
|
||||
[unpacked-pkg-path])
|
||||
|
||||
(when unpacked-pkg-path
|
||||
[:strong.inline-flex.px-3 "Loading ..."]))
|
||||
|
||||
(rum/defc simple-markdown-display
|
||||
< rum/reactive
|
||||
[]
|
||||
(let [content (state/sub :plugin/active-readme)]
|
||||
[:textarea.p-1.bg-transparent.border-none
|
||||
{:style {:width "700px" :min-height "60vw"}}
|
||||
content]))
|
||||
|
||||
(rum/defc plugin-item-card
|
||||
[{:keys [id name settings version url description author icon usf] :as item}]
|
||||
(let [disabled (:disabled settings)]
|
||||
[:div.cp__plugins-item-card
|
||||
[:div.l.link-block
|
||||
{:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
|
||||
(if icon
|
||||
[:img.icon {:src icon}]
|
||||
svg/folder)]
|
||||
[:div.r
|
||||
[:h3.head.text-xl.font-bold.pt-1.5
|
||||
{:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
|
||||
[:span name]
|
||||
[:sup.inline-block.px-1.text-xs.opacity-30 version]]
|
||||
[:div.desc.text-xs.opacity-60
|
||||
[:p description]
|
||||
[:small (js/JSON.stringify (bean/->js settings))]]
|
||||
[:div.flag
|
||||
[:p.text-xs.text-gray-300.pr-2.flex.justify-between.dark:opacity-40
|
||||
[:small author]
|
||||
[:small (str "ID: " id)]]]
|
||||
|
||||
[:div.ctl
|
||||
[:div.l
|
||||
[:div.de
|
||||
[:strong svg/settings-sm]
|
||||
[:ul.menu-list
|
||||
[:li {:on-click #(if usf (js/apis.openPath usf))} "Open settings"]
|
||||
[:li {:on-click
|
||||
#(let [confirm-fn
|
||||
(ui/make-confirm-modal
|
||||
{:title (str "Are you sure uninstall plugin - " name "?")
|
||||
:on-confirm (fn [_ {:keys [close-fn]}]
|
||||
(close-fn)
|
||||
(plugin-handler/unregister-plugin id))})]
|
||||
(state/set-modal! confirm-fn))}
|
||||
"Uninstall plugin"]]]]
|
||||
|
||||
[:div.flex.items-center
|
||||
[:small.de (if disabled "Disabled" "Enabled")]
|
||||
(ui/toggle (not disabled)
|
||||
(fn []
|
||||
(js-invoke js/LSPluginCore (if disabled "enable" "disable") id))
|
||||
true)]]]]))
|
||||
|
||||
(rum/defc installed-page
|
||||
< rum/reactive
|
||||
[]
|
||||
(let [installed-plugins (state/sub :plugin/installed-plugins)
|
||||
selected-unpacked-pkg (state/sub :plugin/selected-unpacked-pkg)]
|
||||
[:div.cp__plugins-page-installed
|
||||
[:h1 "Installed Plugins"]
|
||||
[:div.mb-6.flex.items-center.justify-between
|
||||
(ui/button
|
||||
"Load unpacked plugin"
|
||||
:intent "logseq"
|
||||
:on-click plugin-handler/load-unpacked-plugin)
|
||||
(unpacked-plugin-loader selected-unpacked-pkg)
|
||||
(when (util/electron?)
|
||||
(ui/button
|
||||
[:span.flex.items-center
|
||||
;;svg/settings-sm
|
||||
"Open plugin preferences file"]
|
||||
:intent "logseq"
|
||||
:on-click (fn []
|
||||
(p/let [root (plugin-handler/get-ls-dotdir-root)]
|
||||
(js/apis.openPath (str root "/preferences.json"))))))]
|
||||
|
||||
[:div.cp__plugins-item-lists.grid-cols-1.md:grid-cols-2.lg:grid-cols-3
|
||||
(for [[_ item] installed-plugins]
|
||||
(rum/with-key (plugin-item-card item) (:id item)))]]))
|
||||
|
||||
(defn open-select-theme!
|
||||
[]
|
||||
(state/set-modal! installed-themes))
|
||||
|
||||
(rum/defc hook-ui-slot
|
||||
([type payload] (hook-ui-slot type payload nil))
|
||||
([type payload opts]
|
||||
(let [id (str "slot__" (util/rand-str 8))]
|
||||
(rum/use-effect!
|
||||
(fn []
|
||||
(plugin-handler/hook-plugin-app type {:slot id :payload payload} nil)
|
||||
#())
|
||||
[])
|
||||
[:div.lsp-hook-ui-slot
|
||||
(merge opts {:id id})])))
|
||||
185
src/main/frontend/components/plugins.css
Normal file
185
src/main/frontend/components/plugins.css
Normal file
@@ -0,0 +1,185 @@
|
||||
.cp__plugins {
|
||||
&-page-installed {
|
||||
min-height: 60vh;
|
||||
padding-top: 20px;
|
||||
|
||||
> h1 {
|
||||
padding: 20px 0;
|
||||
font-size: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
&-item-lists {
|
||||
@apply w-full grid grid-flow-row gap-3 pt-1;
|
||||
}
|
||||
|
||||
&-item-card {
|
||||
@apply flex py-3 px-1 rounded-md;
|
||||
|
||||
background-color: var(--ls-secondary-background-color);
|
||||
height: 180px;
|
||||
|
||||
svg, .icon {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
opacity: .8;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.head {
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
opacity: .8;
|
||||
}
|
||||
}
|
||||
|
||||
.desc {
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.flag {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
> .l {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
> .r {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
p {
|
||||
@apply py-1 m-0;
|
||||
}
|
||||
|
||||
.ctl {
|
||||
@apply flex pl-2 items-center justify-between absolute w-full;
|
||||
|
||||
bottom: -8px;
|
||||
right: 8px;
|
||||
|
||||
.de {
|
||||
font-size: 10px;
|
||||
padding: 5px 0;
|
||||
padding-right: 10px;
|
||||
border-radius: 2px;
|
||||
user-select: none;
|
||||
transition: none;
|
||||
opacity: .2;
|
||||
position: relative;
|
||||
|
||||
.menu-list {
|
||||
@apply shadow-md rounded-sm absolute hidden list-none overflow-hidden m-0 p-0;
|
||||
|
||||
background-color: var(--ls-primary-background-color);
|
||||
top: 20px;
|
||||
left: 0;
|
||||
min-width: 100px;
|
||||
|
||||
> li {
|
||||
margin: 0;
|
||||
padding: 3px;
|
||||
transition: background-color .2s;
|
||||
user-select: none;
|
||||
opacity: .8;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ls-quaternary-background-color);
|
||||
|
||||
&:active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.err {
|
||||
@apply text-red-500 opacity-100;
|
||||
}
|
||||
|
||||
&.log {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
> .l {
|
||||
@apply flex items-center;
|
||||
|
||||
margin-left: -80px;
|
||||
|
||||
.de {
|
||||
&:hover {
|
||||
opacity: .9;
|
||||
|
||||
.menu-list {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cp__themes {
|
||||
&-installed {
|
||||
min-width: 480px;
|
||||
|
||||
> .it {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
background-color: var(--ls-secondary-background-color);
|
||||
border: 1px solid transparent;
|
||||
transition: background-color .3s;
|
||||
|
||||
&:hover, &.selected {
|
||||
background-color: var(--ls-quaternary-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lsp-iframe-sandbox, .lsp-shadow-sandbox {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
visibility: hidden;
|
||||
height: 0;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&.visible {
|
||||
z-index: 1;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
&[data-page=page] {
|
||||
.lsp-hook-ui-slot {
|
||||
@apply flex items-center px-1 opacity-70;
|
||||
}
|
||||
}
|
||||
}
|
||||
181
src/main/frontend/handler/plugin.cljs
Normal file
181
src/main/frontend/handler/plugin.cljs
Normal file
@@ -0,0 +1,181 @@
|
||||
(ns frontend.handler.plugin
|
||||
(:require [promesa.core :as p]
|
||||
[rum.core :as rum]
|
||||
[frontend.util :as util]
|
||||
[frontend.fs :as fs]
|
||||
[frontend.handler.notification :as notifications]
|
||||
[camel-snake-kebab.core :as csk]
|
||||
[frontend.state :as state]
|
||||
[medley.core :as md]
|
||||
[electron.ipc :as ipc]
|
||||
[cljs-bean.core :as bean]
|
||||
[clojure.string :as string]))
|
||||
|
||||
(defonce lsp-enabled? (util/electron?))
|
||||
|
||||
;; state handlers
|
||||
(defn register-plugin
|
||||
[pl]
|
||||
(swap! state/state update-in [:plugin/installed-plugins] assoc (keyword (:id pl)) pl))
|
||||
|
||||
(defn unregister-plugin
|
||||
[id]
|
||||
(js/LSPluginCore.unregister id))
|
||||
|
||||
(defn host-mounted!
|
||||
[]
|
||||
(and lsp-enabled? (js/LSPluginCore.hostMounted)))
|
||||
|
||||
(defn register-plugin-slash-command
|
||||
[pid [cmd actions]]
|
||||
(prn (if-let [pid (keyword pid)]
|
||||
(when (contains? (:plugin/installed-plugins @state/state) pid)
|
||||
(do (swap! state/state update-in [:plugin/installed-commands pid]
|
||||
(fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions)))
|
||||
true)))))
|
||||
|
||||
(defn unregister-plugin-slash-command
|
||||
[pid]
|
||||
(swap! state/state md/dissoc-in [:plugin/installed-commands (keyword pid)]))
|
||||
|
||||
(defn register-plugin-simple-command
|
||||
;; action => [:action-key :event-key]
|
||||
[pid {:keys [key label type] :as cmd} action]
|
||||
(if-let [pid (keyword pid)]
|
||||
(when (contains? (:plugin/installed-plugins @state/state) pid)
|
||||
(do (swap! state/state update-in [:plugin/simple-commands pid]
|
||||
(fnil conj []) [type cmd action pid])
|
||||
true))))
|
||||
|
||||
(defn unregister-plugin-simple-command
|
||||
[pid]
|
||||
(swap! state/state md/dissoc-in [:plugin/simple-commands (keyword pid)]))
|
||||
|
||||
(defn update-plugin-settings
|
||||
[id settings]
|
||||
(swap! state/state update-in [:plugin/installed-plugins id] assoc :settings settings))
|
||||
|
||||
(defn open-readme!
|
||||
[url display]
|
||||
(when url
|
||||
(-> (p/let [content (js/api.load_plugin_readme url)]
|
||||
(state/set-state! :plugin/active-readme content)
|
||||
(state/set-modal! display))
|
||||
(p/catch #(notifications/show! "No README file." :warn)))))
|
||||
|
||||
(defn load-unpacked-plugin
|
||||
[]
|
||||
(if util/electron?
|
||||
(p/let [path (ipc/ipc "openDialogSync")]
|
||||
(when-not (:plugin/selected-unpacked-pkg @state/state)
|
||||
(state/set-state! :plugin/selected-unpacked-pkg path)))))
|
||||
|
||||
(defn reset-unpacked-state
|
||||
[]
|
||||
(state/set-state! :plugin/selected-unpacked-pkg nil))
|
||||
|
||||
(defn hook-plugin
|
||||
[tag type payload plugin-id]
|
||||
(when lsp-enabled?
|
||||
(js-invoke js/LSPluginCore
|
||||
(str "hook" (string/capitalize (name tag)))
|
||||
(name type)
|
||||
(if (map? payload)
|
||||
(bean/->js (into {} (for [[k v] payload] [(csk/->camelCase k) (if (uuid? v) (str v) v)]))))
|
||||
(if (keyword? plugin-id) (name plugin-id) plugin-id))))
|
||||
|
||||
(defn hook-plugin-app
|
||||
([type payload] (hook-plugin-app type payload nil))
|
||||
([type payload plugin-id] (hook-plugin :app type payload plugin-id)))
|
||||
|
||||
(defn hook-plugin-editor
|
||||
([type payload] (hook-plugin-editor type payload nil))
|
||||
([type payload plugin-id] (hook-plugin :editor type payload plugin-id)))
|
||||
|
||||
(defn get-ls-dotdir-root
|
||||
[]
|
||||
(ipc/ipc "getLogseqDotDirRoot"))
|
||||
|
||||
(defn- get-user-default-plugins
|
||||
[]
|
||||
(p/catch
|
||||
(p/let [files ^js (ipc/ipc "getUserDefaultPlugins")
|
||||
files (js->clj files)]
|
||||
(map #(hash-map :url %) files))
|
||||
(fn [e]
|
||||
(js/console.error e))))
|
||||
|
||||
;; components
|
||||
(rum/defc lsp-indicator < rum/reactive
|
||||
[]
|
||||
(let [text (state/sub :plugin/indicator-text)]
|
||||
(if (= text "END")
|
||||
[:span]
|
||||
[:div
|
||||
{:style
|
||||
{:width "100%"
|
||||
:height "100vh"
|
||||
:display "flex"
|
||||
:align-items "center"
|
||||
:justify-content "center"}}
|
||||
[:span
|
||||
{:style
|
||||
{:color "#aaa"
|
||||
:font-size "38px"}} (or text "Loading ...")]])))
|
||||
|
||||
(defn init-plugins
|
||||
[callback]
|
||||
|
||||
(let [el (js/document.createElement "div")]
|
||||
(.appendChild js/document.body el)
|
||||
(rum/mount
|
||||
(lsp-indicator) el))
|
||||
|
||||
(state/set-state! :plugin/indicator-text "Loading...")
|
||||
|
||||
(p/then
|
||||
(p/let [root (get-ls-dotdir-root)
|
||||
_ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root}))
|
||||
_ (doto js/LSPluginCore
|
||||
(.on "registered"
|
||||
(fn [^js pl]
|
||||
(register-plugin
|
||||
(bean/->clj (.parse js/JSON (.stringify js/JSON pl))))))
|
||||
|
||||
(.on "unregistered" (fn [pid]
|
||||
(let [pid (keyword pid)]
|
||||
;; plugins
|
||||
(swap! state/state md/dissoc-in [:plugin/installed-plugins (keyword pid)])
|
||||
;; commands
|
||||
(unregister-plugin-slash-command pid))))
|
||||
|
||||
(.on "theme-changed" (fn [^js themes]
|
||||
(swap! state/state assoc :plugin/installed-themes
|
||||
(vec (mapcat (fn [[_ vs]] (bean/->clj vs)) (bean/->clj themes))))))
|
||||
|
||||
(.on "theme-selected" (fn [^js opts]
|
||||
(let [opts (bean/->clj opts)
|
||||
url (:url opts)
|
||||
mode (:mode opts)]
|
||||
(when mode (state/set-theme! mode))
|
||||
(state/set-state! :plugin/selected-theme url))))
|
||||
|
||||
(.on "settings-changed" (fn [id ^js settings]
|
||||
(let [id (keyword id)]
|
||||
(when (and settings
|
||||
(contains? (:plugin/installed-plugins @state/state) id))
|
||||
(update-plugin-settings id (bean/->clj settings)))))))
|
||||
|
||||
default-plugins (get-user-default-plugins)
|
||||
|
||||
_ (.register js/LSPluginCore (bean/->js (if (seq default-plugins) default-plugins [])) true)])
|
||||
#(do
|
||||
(state/set-state! :plugin/indicator-text "END")
|
||||
(callback))))
|
||||
|
||||
(defn setup!
|
||||
"setup plugin core handler"
|
||||
[callback]
|
||||
(if (not lsp-enabled?)
|
||||
(callback)
|
||||
(init-plugins callback)))
|
||||
Reference in New Issue
Block a user