helpers.ts 9.7 KB

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