mirror of
https://github.com/logseq/logseq.git
synced 2026-04-29 08:26:40 +00:00
402 lines
9.3 KiB
TypeScript
402 lines
9.3 KiB
TypeScript
import { SettingSchemaDesc, StyleString, UIOptions } from './LSPlugin'
|
|
import { PluginLocal } from './LSPlugin.core'
|
|
import { snakeCase } from 'snake-case'
|
|
import * as nodePath from 'path'
|
|
import DOMPurify from 'dompurify'
|
|
import { merge } from 'lodash-es'
|
|
|
|
interface IObject {
|
|
[key: string]: any;
|
|
}
|
|
|
|
declare global {
|
|
interface Window {
|
|
api: any
|
|
apis: any
|
|
}
|
|
}
|
|
|
|
export const path = navigator.platform.toLowerCase() === 'win32' ? nodePath.win32 : nodePath.posix
|
|
export const IS_DEV = process.env.NODE_ENV === 'development'
|
|
export const PROTOCOL_FILE = 'file://'
|
|
export const PROTOCOL_LSP = 'lsp://'
|
|
export const URL_LSP = PROTOCOL_LSP + 'logseq.io/'
|
|
|
|
let _appPathRoot
|
|
|
|
export async function getAppPathRoot (): Promise<string> {
|
|
if (_appPathRoot) {
|
|
return _appPathRoot
|
|
}
|
|
|
|
return (_appPathRoot =
|
|
await invokeHostExportedApi('_callApplication', 'getAppPath')
|
|
)
|
|
}
|
|
|
|
export async function getSDKPathRoot (): Promise<string> {
|
|
if (IS_DEV) {
|
|
// TODO: cache in preference file
|
|
return localStorage.getItem('LSP_DEV_SDK_ROOT') || 'http://localhost:8080'
|
|
}
|
|
|
|
const appPathRoot = await getAppPathRoot()
|
|
|
|
return safetyPathJoin(appPathRoot, 'js')
|
|
}
|
|
|
|
export function isObject (item: any) {
|
|
return (item === Object(item) && !Array.isArray(item))
|
|
}
|
|
|
|
export const deepMerge = merge
|
|
|
|
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|lsp)/
|
|
|
|
if (!reg.test(path)) {
|
|
path = PROTOCOL_FILE + path
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
export function safetyPathJoin (basePath: string, ...parts: Array<string>) {
|
|
try {
|
|
const url = new URL(basePath)
|
|
if (!url.origin) throw new Error(null)
|
|
const fullPath = path.join(basePath.substr(url.origin.length), ...parts)
|
|
return url.origin + fullPath
|
|
} catch (e) {
|
|
return path.join(basePath, ...parts)
|
|
}
|
|
}
|
|
|
|
export function safetyPathNormalize (basePath: string) {
|
|
if (!basePath?.match(/^(http?|lsp|assets):/)) {
|
|
basePath = path.normalize(basePath)
|
|
}
|
|
return basePath
|
|
}
|
|
|
|
/**
|
|
* @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>
|
|
) {
|
|
method = method?.startsWith('_call') ? method :
|
|
method?.replace(/^[_$]+/, '')
|
|
const method1 = snakeCase(method)
|
|
|
|
// @ts-ignore
|
|
const logseqHostExportedApi = window.logseq?.api || {}
|
|
|
|
const fn = logseqHostExportedApi[method1] || window.apis[method1] ||
|
|
logseqHostExportedApi[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)
|
|
}
|
|
}
|
|
|
|
const injectedUIEffects = new Map<string, () => void>()
|
|
|
|
export function setupInjectedUI (
|
|
this: PluginLocal,
|
|
ui: UIOptions,
|
|
attrs: Record<string, string>,
|
|
initialCallback?: (e: { el: HTMLElement, float: boolean }) => void
|
|
) {
|
|
let slot: string = ''
|
|
let selector: string
|
|
let float: boolean
|
|
|
|
const pl = this
|
|
|
|
if ('slot' in ui) {
|
|
slot = ui.slot
|
|
selector = `#${slot}`
|
|
} else if ('path' in ui) {
|
|
selector = ui.path
|
|
} else {
|
|
float = true
|
|
}
|
|
|
|
const id = `${ui.key}-${slot}-${pl.id}`
|
|
const key = `${ui.key}--${pl.id}`
|
|
|
|
const target = float ? document.body : (selector && document.querySelector(selector))
|
|
if (!target) {
|
|
console.error(`${this.debugTag} can not resolve selector target ${selector}`)
|
|
return
|
|
}
|
|
|
|
if (ui.template) {
|
|
// safe template
|
|
ui.template = DOMPurify.sanitize(
|
|
ui.template, {
|
|
ADD_TAGS: ['iframe'],
|
|
ALLOW_UNKNOWN_PROTOCOLS: true,
|
|
ADD_ATTR: ['allow', 'src', 'allowfullscreen', 'frameborder', 'scrolling', 'target']
|
|
})
|
|
} else { // remove ui
|
|
injectedUIEffects.get(id)?.call(null)
|
|
return
|
|
}
|
|
|
|
let el = document.querySelector(`#${id}`) as HTMLElement
|
|
let content = float ? el?.querySelector('.ls-ui-float-content') : el
|
|
|
|
if (content) {
|
|
content.innerHTML = ui.template
|
|
|
|
// update attributes
|
|
attrs && Object.entries(attrs).forEach(([k, v]) => {
|
|
el.setAttribute(k, v)
|
|
})
|
|
|
|
let positionDirty = el.dataset.dx != null
|
|
ui.style && Object.entries(ui.style).forEach(([k, v]) => {
|
|
if (positionDirty && [
|
|
'left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
|
|
) {
|
|
return
|
|
}
|
|
|
|
el.style[k] = v
|
|
})
|
|
return
|
|
}
|
|
|
|
el = document.createElement('div')
|
|
el.id = id
|
|
el.dataset.injectedUi = key || ''
|
|
|
|
if (float) {
|
|
content = document.createElement('div')
|
|
content.classList.add('ls-ui-float-content')
|
|
el.appendChild(content)
|
|
} else {
|
|
content = el
|
|
}
|
|
|
|
// TODO: enhance template
|
|
content.innerHTML = ui.template
|
|
|
|
attrs && Object.entries(attrs).forEach(([k, v]) => {
|
|
el.setAttribute(k, v)
|
|
})
|
|
|
|
ui.style && Object.entries(ui.style).forEach(([k, v]) => {
|
|
el.style[k] = v
|
|
})
|
|
|
|
let teardownUI: () => void
|
|
let disposeFloat: () => void
|
|
|
|
if (float) {
|
|
el.setAttribute('draggable', 'true')
|
|
el.setAttribute('resizable', 'true')
|
|
ui.close && (el.dataset.close = ui.close)
|
|
el.classList.add('lsp-ui-float-container', 'visible')
|
|
disposeFloat = (
|
|
pl._setupResizableContainer(el, key),
|
|
pl._setupDraggableContainer(el, { key, close: () => teardownUI(), title: attrs?.title }))
|
|
}
|
|
|
|
if (!!slot && ui.reset) {
|
|
const exists = Array.from(target.querySelectorAll('[data-injected-ui]'))
|
|
.map((it: HTMLElement) => it.id)
|
|
|
|
exists?.forEach((exist: string) => {
|
|
injectedUIEffects.get(exist)?.call(null)
|
|
})
|
|
}
|
|
|
|
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)
|
|
})
|
|
|
|
// callback
|
|
initialCallback?.({ el, float })
|
|
|
|
teardownUI = () => {
|
|
disposeFloat?.()
|
|
injectedUIEffects.delete(id)
|
|
target!.removeChild(el)
|
|
}
|
|
|
|
injectedUIEffects.set(id, teardownUI)
|
|
return teardownUI
|
|
}
|
|
|
|
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 = () => {
|
|
try {
|
|
document.head.removeChild(link)
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
injectedThemeEffect = null
|
|
})
|
|
}
|
|
|
|
export function mergeSettingsWithSchema (
|
|
settings: Record<string, any>,
|
|
schema: Array<SettingSchemaDesc>) {
|
|
const defaults = (schema || []).reduce((a, b) => {
|
|
if ('default' in b) {
|
|
a[b.key] = b.default
|
|
}
|
|
return a
|
|
}, {})
|
|
|
|
// shadow copy
|
|
return Object.assign(defaults, settings)
|
|
} |