helpers.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import { SettingSchemaDesc, StyleString, UIOptions } from './LSPlugin'
  2. import { PluginLocal } from './LSPlugin.core'
  3. import * as nodePath from 'path'
  4. import DOMPurify from 'dompurify'
  5. import merge from 'deepmerge'
  6. import { snakeCase } from 'snake-case'
  7. import * as callables from './callable.apis'
  8. import EventEmitter from 'eventemitter3'
  9. declare global {
  10. interface Window {
  11. api: any
  12. apis: any
  13. }
  14. }
  15. export const path =
  16. navigator.platform.toLowerCase() === 'win32' ? nodePath.win32 : nodePath.posix
  17. export const IS_DEV = process.env.NODE_ENV === 'development'
  18. export const PROTOCOL_FILE = 'file://'
  19. export const PROTOCOL_LSP = 'lsp://'
  20. export const URL_LSP = PROTOCOL_LSP + 'logseq.io/'
  21. let _appPathRoot
  22. // TODO: snakeCase of lodash is incompatible with `snake-case`
  23. export const safeSnakeCase = snakeCase
  24. export async function getAppPathRoot(): Promise<string> {
  25. if (_appPathRoot) {
  26. return _appPathRoot
  27. }
  28. return (_appPathRoot = await invokeHostExportedApi(
  29. '_callApplication',
  30. 'getAppPath'
  31. ))
  32. }
  33. export async function getSDKPathRoot(): Promise<string> {
  34. if (IS_DEV) {
  35. // TODO: cache in preference file
  36. return localStorage.getItem('LSP_DEV_SDK_ROOT') || 'http://localhost:8080'
  37. }
  38. const appPathRoot = await getAppPathRoot()
  39. return safetyPathJoin(appPathRoot, 'js')
  40. }
  41. export function isObject(item: any) {
  42. return item === Object(item) && !Array.isArray(item)
  43. }
  44. export function deepMerge<T>(a: Partial<T>, b: Partial<T>): T {
  45. const overwriteArrayMerge = (destinationArray, sourceArray) => sourceArray
  46. return merge(a, b, { arrayMerge: overwriteArrayMerge })
  47. }
  48. export class PluginLogger extends EventEmitter<'change'> {
  49. private _logs: Array<[type: string, payload: any]> = []
  50. constructor(
  51. private _tag?: string,
  52. private _opts?: {
  53. console: boolean
  54. }
  55. ) {
  56. super()
  57. }
  58. write(type: string, payload: any[], inConsole?: boolean) {
  59. if (payload?.length && true === payload[payload.length - 1]) {
  60. inConsole = true
  61. payload.pop()
  62. }
  63. const msg = payload.reduce((ac, it) => {
  64. if (it && it instanceof Error) {
  65. ac += `${it.message} ${it.stack}`
  66. } else {
  67. ac += it.toString()
  68. }
  69. return ac
  70. }, `[${this._tag}][${new Date().toLocaleTimeString()}] `)
  71. this._logs.push([type, msg])
  72. if (inConsole || this._opts?.console) {
  73. console?.['ERROR' === type ? 'error' : 'debug'](`${type}: ${msg}`)
  74. }
  75. this.emit('change')
  76. }
  77. clear() {
  78. this._logs = []
  79. this.emit('change')
  80. }
  81. info(...args: any[]) {
  82. this.write('INFO', args)
  83. }
  84. error(...args: any[]) {
  85. this.write('ERROR', args)
  86. }
  87. warn(...args: any[]) {
  88. this.write('WARN', args)
  89. }
  90. setTag(s: string) {
  91. this._tag = s
  92. }
  93. toJSON() {
  94. return this._logs
  95. }
  96. }
  97. export function isValidUUID(s: string) {
  98. return (
  99. typeof s === 'string' &&
  100. s.length === 36 &&
  101. /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/gi.test(
  102. s
  103. )
  104. )
  105. }
  106. export function genID() {
  107. // Math.random should be unique because of its seeding algorithm.
  108. // Convert it to base 36 (numbers + letters), and grab the first 9 characters
  109. // after the decimal.
  110. return '_' + Math.random().toString(36).substr(2, 9)
  111. }
  112. export function ucFirst(str: string) {
  113. return str.charAt(0).toUpperCase() + str.slice(1)
  114. }
  115. export function withFileProtocol(path: string) {
  116. if (!path) return ''
  117. const reg = /^(http|file|lsp)/
  118. if (!reg.test(path)) {
  119. path = PROTOCOL_FILE + path
  120. }
  121. return path
  122. }
  123. export function safetyPathJoin(basePath: string, ...parts: Array<string>) {
  124. try {
  125. const url = new URL(basePath)
  126. if (!url.origin) throw new Error(null)
  127. const fullPath = path.join(basePath.substr(url.origin.length), ...parts)
  128. return url.origin + fullPath
  129. } catch (e) {
  130. return path.join(basePath, ...parts)
  131. }
  132. }
  133. export function safetyPathNormalize(basePath: string) {
  134. if (!basePath?.match(/^(http?|lsp|assets):/)) {
  135. basePath = path.normalize(basePath)
  136. }
  137. return basePath
  138. }
  139. /**
  140. * @param timeout milliseconds
  141. * @param tag string
  142. */
  143. export function deferred<T = any>(timeout?: number, tag?: string) {
  144. let resolve: any, reject: any
  145. let settled = false
  146. const timeFn = (r: Function) => {
  147. return (v: T) => {
  148. timeout && clearTimeout(timeout)
  149. r(v)
  150. settled = true
  151. }
  152. }
  153. const promise = new Promise<T>((resolve1, reject1) => {
  154. resolve = timeFn(resolve1)
  155. reject = timeFn(reject1)
  156. if (timeout) {
  157. // @ts-ignore
  158. timeout = setTimeout(
  159. () => reject(new Error(`[deferred timeout] ${tag}`)),
  160. timeout
  161. )
  162. }
  163. })
  164. return {
  165. created: Date.now(),
  166. setTag: (t: string) => (tag = t),
  167. resolve,
  168. reject,
  169. promise,
  170. get settled() {
  171. return settled
  172. },
  173. }
  174. }
  175. export function invokeHostExportedApi(method: string, ...args: Array<any>) {
  176. method = method?.startsWith('_call') ? method : method?.replace(/^[_$]+/, '')
  177. let method1 = safeSnakeCase(method)
  178. // @ts-ignore
  179. const nsSDK = window.logseq?.sdk
  180. const supportedNS = nsSDK && Object.keys(nsSDK)
  181. let nsTarget = {}
  182. const ns0 = method1?.split('_')?.[0]
  183. if (ns0 && supportedNS.includes(ns0)) {
  184. method1 = method1.replace(new RegExp(`^${ns0}_`), '')
  185. nsTarget = nsSDK?.[ns0]
  186. }
  187. const logseqHostExportedApi = Object.assign(
  188. // @ts-ignore
  189. {}, window.logseq?.api,
  190. nsTarget, callables
  191. )
  192. const fn =
  193. logseqHostExportedApi[method1] ||
  194. window.apis[method1] ||
  195. logseqHostExportedApi[method] ||
  196. window.apis[method]
  197. if (!fn) {
  198. throw new Error(`Not existed method #${method}`)
  199. }
  200. return typeof fn !== 'function' ? fn : fn.apply(this, args)
  201. }
  202. export function setupIframeSandbox(
  203. props: Record<string, any>,
  204. target: HTMLElement
  205. ) {
  206. const iframe = document.createElement('iframe')
  207. iframe.classList.add('lsp-iframe-sandbox')
  208. Object.entries(props).forEach(([k, v]) => {
  209. iframe.setAttribute(k, v)
  210. })
  211. target.appendChild(iframe)
  212. return async () => {
  213. target.removeChild(iframe)
  214. }
  215. }
  216. export function setupInjectedStyle(
  217. style: StyleString,
  218. attrs: Record<string, any>
  219. ) {
  220. const key = attrs['data-injected-style']
  221. let el = key && document.querySelector(`[data-injected-style=${key}]`)
  222. if (el) {
  223. el.textContent = style
  224. return
  225. }
  226. el = document.createElement('style')
  227. el.textContent = style
  228. attrs &&
  229. Object.entries(attrs).forEach(([k, v]) => {
  230. el.setAttribute(k, v)
  231. })
  232. document.head.append(el)
  233. return () => {
  234. document.head.removeChild(el)
  235. }
  236. }
  237. const injectedUIEffects = new Map<string, () => void>()
  238. // @ts-ignore
  239. window.__injectedUIEffects = injectedUIEffects
  240. export function setupInjectedUI(
  241. this: PluginLocal,
  242. ui: UIOptions,
  243. attrs: Record<string, string>,
  244. initialCallback?: (e: { el: HTMLElement; float: boolean }) => void
  245. ) {
  246. let slot: string = ''
  247. let selector: string
  248. let float: boolean
  249. const pl = this
  250. if ('slot' in ui) {
  251. slot = ui.slot
  252. selector = `#${slot}`
  253. } else if ('path' in ui) {
  254. selector = ui.path
  255. } else {
  256. float = true
  257. }
  258. const id = `${pl.id}--${ui.key || genID()}`
  259. const key = id
  260. const target = float
  261. ? document.body
  262. : selector && document.querySelector(selector)
  263. if (!target) {
  264. console.error(
  265. `${this.debugTag} can not resolve selector target ${selector}`
  266. )
  267. return false
  268. }
  269. if (ui.template) {
  270. // safe template
  271. ui.template = DOMPurify.sanitize(ui.template, {
  272. ADD_TAGS: ['iframe'],
  273. ALLOW_UNKNOWN_PROTOCOLS: true,
  274. ADD_ATTR: [
  275. 'allow',
  276. 'src',
  277. 'allowfullscreen',
  278. 'frameborder',
  279. 'scrolling',
  280. 'target',
  281. ],
  282. })
  283. } else {
  284. // remove ui
  285. injectedUIEffects.get(id)?.call(null)
  286. return
  287. }
  288. let el = document.querySelector(`#${id}`) as HTMLElement
  289. let content = float ? el?.querySelector('.ls-ui-float-content') : el
  290. if (content) {
  291. content.innerHTML = ui.template
  292. // update attributes
  293. attrs &&
  294. Object.entries(attrs).forEach(([k, v]) => {
  295. el.setAttribute(k, v)
  296. })
  297. let positionDirty = el.dataset.dx != null
  298. ui.style &&
  299. Object.entries(ui.style).forEach(([k, v]) => {
  300. if (
  301. positionDirty &&
  302. ['left', 'top', 'bottom', 'right', 'width', 'height'].includes(k)
  303. ) {
  304. return
  305. }
  306. el.style[k] = v
  307. })
  308. return
  309. }
  310. el = document.createElement('div')
  311. el.id = id
  312. el.dataset.injectedUi = key || ''
  313. if (float) {
  314. content = document.createElement('div')
  315. content.classList.add('ls-ui-float-content')
  316. el.appendChild(content)
  317. } else {
  318. content = el
  319. }
  320. // TODO: enhance template
  321. content.innerHTML = ui.template
  322. attrs &&
  323. Object.entries(attrs).forEach(([k, v]) => {
  324. el.setAttribute(k, v)
  325. })
  326. ui.style &&
  327. Object.entries(ui.style).forEach(([k, v]) => {
  328. el.style[k] = v
  329. })
  330. let teardownUI: () => void
  331. let disposeFloat: () => void
  332. // seu up float container
  333. if (float) {
  334. el.setAttribute('draggable', 'true')
  335. el.setAttribute('resizable', 'true')
  336. ui.close && (el.dataset.close = ui.close)
  337. el.classList.add('lsp-ui-float-container', 'visible')
  338. disposeFloat =
  339. (pl._setupResizableContainer(el, key),
  340. pl._setupDraggableContainer(el, {
  341. key,
  342. close: () => teardownUI(),
  343. title: attrs?.title,
  344. }))
  345. }
  346. if (!!slot && ui.reset) {
  347. const exists = Array.from(
  348. target.querySelectorAll('[data-injected-ui]')
  349. ).map((it: HTMLElement) => it.id)
  350. exists?.forEach((exist: string) => {
  351. injectedUIEffects.get(exist)?.call(null)
  352. })
  353. }
  354. target.appendChild(el)
  355. // TODO: How handle events
  356. ;[
  357. 'click',
  358. 'focus',
  359. 'focusin',
  360. 'focusout',
  361. 'blur',
  362. 'dblclick',
  363. 'keyup',
  364. 'keypress',
  365. 'keydown',
  366. 'change',
  367. 'input',
  368. 'contextmenu',
  369. ].forEach((type) => {
  370. el.addEventListener(
  371. type,
  372. (e) => {
  373. const target = e.target! as HTMLElement
  374. const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
  375. if (!trigger) return
  376. const { preventDefault } = trigger.dataset
  377. const msgType = trigger.dataset[`on${ucFirst(type)}`]
  378. if (msgType)
  379. pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
  380. if (preventDefault?.toLowerCase() === 'true') e.preventDefault()
  381. },
  382. false
  383. )
  384. })
  385. // callback
  386. initialCallback?.({ el, float })
  387. teardownUI = () => {
  388. disposeFloat?.()
  389. injectedUIEffects.delete(id)
  390. target!.removeChild(el)
  391. }
  392. injectedUIEffects.set(id, teardownUI)
  393. return teardownUI
  394. }
  395. export function cleanInjectedUI(id: string) {
  396. if (!injectedUIEffects.has(id)) return
  397. const clean = injectedUIEffects.get(id)
  398. try {
  399. clean()
  400. } catch (e) {
  401. console.warn('[CLEAN Injected UI] ', id, e)
  402. }
  403. }
  404. export function cleanInjectedScripts(this: PluginLocal) {
  405. const scripts = document.head.querySelectorAll(`script[data-ref=${this.id}]`)
  406. scripts?.forEach((it) => it.remove())
  407. }
  408. export function transformableEvent(target: HTMLElement, e: Event) {
  409. const obj: any = {}
  410. if (target) {
  411. obj.type = e.type
  412. const ds = target.dataset
  413. const FLAG_RECT = 'rect'
  414. ;['value', 'id', 'className', 'dataset', FLAG_RECT].forEach((k) => {
  415. let v: any
  416. switch (k) {
  417. case FLAG_RECT:
  418. if (!ds.hasOwnProperty(FLAG_RECT)) return
  419. v = target.getBoundingClientRect().toJSON()
  420. break
  421. default:
  422. v = target[k]
  423. }
  424. if (typeof v === 'object') {
  425. v = { ...v }
  426. }
  427. obj[k] = v
  428. })
  429. }
  430. return obj
  431. }
  432. export function injectTheme(url: string) {
  433. const link = document.createElement('link')
  434. link.rel = 'stylesheet'
  435. link.href = url
  436. document.head.appendChild(link)
  437. const ejectTheme = () => {
  438. try {
  439. document.head.removeChild(link)
  440. } catch (e) {
  441. console.error(e)
  442. }
  443. }
  444. return ejectTheme
  445. }
  446. export function mergeSettingsWithSchema(
  447. settings: Record<string, any>,
  448. schema: Array<SettingSchemaDesc>
  449. ) {
  450. const defaults = (schema || []).reduce((a, b) => {
  451. if ('default' in b) {
  452. a[b.key] = b.default
  453. }
  454. return a
  455. }, {})
  456. // shadow copy
  457. return Object.assign(defaults, settings)
  458. }
  459. export function normalizeKeyStr(s: string) {
  460. if (typeof s !== 'string') return
  461. return s.trim().replace(/\s/g, '_').toLowerCase()
  462. }