helpers.ts 12 KB

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