import { StyleString, UIOptions } from './LSPlugin' import { PluginLocal } from './LSPlugin.core' import { snakeCase } from 'snake-case' import * as nodePath from 'path' import DOMPurify from 'dompurify' 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 { if (_appPathRoot) { return _appPathRoot } return (_appPathRoot = await invokeHostExportedApi('_callApplication', 'getAppPath') ) } export async function getSDKPathRoot (): Promise { 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 function deepMerge ( target: IObject, ...sources: Array ) { // 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|lsp)/ if (!reg.test(path)) { path = PROTOCOL_FILE + path } return path } export function safetyPathJoin (basePath: string, ...parts: Array) { 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 (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((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 ) { 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, 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 ) { 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 void>() export function setupInjectedUI ( this: PluginLocal, ui: UIOptions, attrs: Record, 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'] }) } 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 }) }