helpers.ts 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. import { StyleString, UIOptions } from './LSPlugin'
  2. import { PluginLocal } from './LSPlugin.core'
  3. import { snakeCase } from 'snake-case'
  4. import * as nodePath from 'path'
  5. import DOMPurify from 'dompurify'
  6. interface IObject {
  7. [key: string]: any;
  8. }
  9. declare global {
  10. interface Window {
  11. api: any
  12. apis: any
  13. }
  14. }
  15. export const path = 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. export async function getAppPathRoot (): Promise<string> {
  22. if (_appPathRoot) {
  23. return _appPathRoot
  24. }
  25. return (_appPathRoot =
  26. await invokeHostExportedApi('_callApplication', 'getAppPath')
  27. )
  28. }
  29. export async function getSDKPathRoot (): Promise<string> {
  30. if (IS_DEV) {
  31. // TODO: cache in preference file
  32. return localStorage.getItem('LSP_DEV_SDK_ROOT') || 'http://localhost:8080'
  33. }
  34. const appPathRoot = await getAppPathRoot()
  35. return safetyPathJoin(appPathRoot, 'js')
  36. }
  37. export function isObject (item: any) {
  38. return (item === Object(item) && !Array.isArray(item))
  39. }
  40. export function deepMerge (
  41. target: IObject,
  42. ...sources: Array<IObject>
  43. ) {
  44. // return the target if no sources passed
  45. if (!sources.length) {
  46. return target
  47. }
  48. const result: IObject = target
  49. if (isObject(result)) {
  50. const len: number = sources.length
  51. for (let i = 0; i < len; i += 1) {
  52. const elm: any = sources[i]
  53. if (isObject(elm)) {
  54. for (const key in elm) {
  55. if (elm.hasOwnProperty(key)) {
  56. if (isObject(elm[key])) {
  57. if (!result[key] || !isObject(result[key])) {
  58. result[key] = {}
  59. }
  60. deepMerge(result[key], elm[key])
  61. } else {
  62. if (Array.isArray(result[key]) && Array.isArray(elm[key])) {
  63. // concatenate the two arrays and remove any duplicate primitive values
  64. result[key] = Array.from(new Set(result[key].concat(elm[key])))
  65. } else {
  66. result[key] = elm[key]
  67. }
  68. }
  69. }
  70. }
  71. }
  72. }
  73. }
  74. return result
  75. }
  76. export function genID () {
  77. // Math.random should be unique because of its seeding algorithm.
  78. // Convert it to base 36 (numbers + letters), and grab the first 9 characters
  79. // after the decimal.
  80. return '_' + Math.random().toString(36).substr(2, 9)
  81. }
  82. export function ucFirst (str: string) {
  83. return str.charAt(0).toUpperCase() + str.slice(1)
  84. }
  85. export function withFileProtocol (path: string) {
  86. if (!path) return ''
  87. const reg = /^(http|file|lsp)/
  88. if (!reg.test(path)) {
  89. path = PROTOCOL_FILE + path
  90. }
  91. return path
  92. }
  93. export function safetyPathJoin (basePath: string, ...parts: Array<string>) {
  94. try {
  95. const url = new URL(basePath)
  96. if (!url.origin) throw new Error(null)
  97. const fullPath = path.join(basePath.substr(url.origin.length), ...parts)
  98. return url.origin + fullPath
  99. } catch (e) {
  100. return path.join(basePath, ...parts)
  101. }
  102. }
  103. export function safetyPathNormalize (basePath: string) {
  104. if (!basePath?.match(/^(http?|lsp|assets):/)) {
  105. basePath = path.normalize(basePath)
  106. }
  107. return basePath
  108. }
  109. /**
  110. * @param timeout milliseconds
  111. * @param tag string
  112. */
  113. export function deferred<T = any> (timeout?: number, tag?: string) {
  114. let resolve: any, reject: any
  115. let settled = false
  116. const timeFn = (r: Function) => {
  117. return (v: T) => {
  118. timeout && clearTimeout(timeout)
  119. r(v)
  120. settled = true
  121. }
  122. }
  123. const promise = new Promise<T>((resolve1, reject1) => {
  124. resolve = timeFn(resolve1)
  125. reject = timeFn(reject1)
  126. if (timeout) {
  127. // @ts-ignore
  128. timeout = setTimeout(() => reject(new Error(`[deferred timeout] ${tag}`)), timeout)
  129. }
  130. })
  131. return {
  132. created: Date.now(),
  133. setTag: (t: string) => tag = t,
  134. resolve, reject, promise,
  135. get settled () {
  136. return settled
  137. }
  138. }
  139. }
  140. export function invokeHostExportedApi (
  141. method: string,
  142. ...args: Array<any>
  143. ) {
  144. method = method?.replace(/^[_$]+/, '')
  145. const method1 = snakeCase(method)
  146. // @ts-ignore
  147. const logseqHostExportedApi = window.logseq?.api || {}
  148. const fn = logseqHostExportedApi[method1] || window.apis[method1] ||
  149. logseqHostExportedApi[method] || window.apis[method]
  150. if (!fn) {
  151. throw new Error(`Not existed method #${method}`)
  152. }
  153. return typeof fn !== 'function' ? fn : fn.apply(null, args)
  154. }
  155. export function setupIframeSandbox (
  156. props: Record<string, any>,
  157. target: HTMLElement
  158. ) {
  159. const iframe = document.createElement('iframe')
  160. iframe.classList.add('lsp-iframe-sandbox')
  161. Object.entries(props).forEach(([k, v]) => {
  162. iframe.setAttribute(k, v)
  163. })
  164. target.appendChild(iframe)
  165. return async () => {
  166. target.removeChild(iframe)
  167. }
  168. }
  169. export function setupInjectedStyle (
  170. style: StyleString,
  171. attrs: Record<string, any>
  172. ) {
  173. const key = attrs['data-injected-style']
  174. let el = key && document.querySelector(`[data-injected-style=${key}]`)
  175. if (el) {
  176. el.textContent = style
  177. return
  178. }
  179. el = document.createElement('style')
  180. el.textContent = style
  181. attrs && Object.entries(attrs).forEach(([k, v]) => {
  182. el.setAttribute(k, v)
  183. })
  184. document.head.append(el)
  185. return () => {
  186. document.head.removeChild(el)
  187. }
  188. }
  189. export function setupInjectedUI (
  190. this: PluginLocal,
  191. ui: UIOptions,
  192. attrs: Record<string, any>
  193. ) {
  194. const pl = this
  195. let slot = ''
  196. let selector = ''
  197. if ('slot' in ui) {
  198. slot = ui.slot
  199. selector = `#${slot}`
  200. } else {
  201. selector = ui.path
  202. }
  203. const target = selector && document.querySelector(selector)
  204. if (!target) {
  205. console.error(`${this.debugTag} can not resolve selector target ${selector}`)
  206. return
  207. }
  208. const id = `${ui.key}-${slot}-${pl.id}`
  209. const key = `${ui.key}-${pl.id}`
  210. let el = document.querySelector(`#${id}`) as HTMLElement
  211. if (ui.template) {
  212. // safe template
  213. ui.template = DOMPurify.sanitize(
  214. ui.template, {
  215. ADD_TAGS: ['iframe'],
  216. ALLOW_UNKNOWN_PROTOCOLS: true,
  217. ADD_ATTR: ['allow', 'src', 'allowfullscreen', 'frameborder', 'scrolling']
  218. })
  219. }
  220. if (el) {
  221. el.innerHTML = ui.template
  222. return
  223. }
  224. el = document.createElement('div')
  225. el.id = id
  226. el.dataset.injectedUi = key || ''
  227. // TODO: Support more
  228. el.innerHTML = ui.template
  229. attrs && Object.entries(attrs).forEach(([k, v]) => {
  230. el.setAttribute(k, v)
  231. })
  232. target.appendChild(el);
  233. // TODO: How handle events
  234. ['click', 'focus', 'focusin', 'focusout', 'blur', 'dblclick',
  235. 'keyup', 'keypress', 'keydown', 'change', 'input'].forEach((type) => {
  236. el.addEventListener(type, (e) => {
  237. const target = e.target! as HTMLElement
  238. const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
  239. if (!trigger) return
  240. const msgType = trigger.dataset[`on${ucFirst(type)}`]
  241. msgType && pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
  242. }, false)
  243. })
  244. return () => {
  245. target!.removeChild(el)
  246. }
  247. }
  248. export function transformableEvent (target: HTMLElement, e: Event) {
  249. const obj: any = {}
  250. if (target) {
  251. const ds = target.dataset
  252. const FLAG_RECT = 'rect'
  253. ;['value', 'id', 'className',
  254. 'dataset', FLAG_RECT
  255. ].forEach((k) => {
  256. let v: any
  257. switch (k) {
  258. case FLAG_RECT:
  259. if (!ds.hasOwnProperty(FLAG_RECT)) return
  260. v = target.getBoundingClientRect().toJSON()
  261. break
  262. default:
  263. v = target[k]
  264. }
  265. if (typeof v === 'object') {
  266. v = { ...v }
  267. }
  268. obj[k] = v
  269. })
  270. }
  271. return obj
  272. }
  273. let injectedThemeEffect: any = null
  274. export function setupInjectedTheme (url?: string) {
  275. injectedThemeEffect?.call()
  276. if (!url) return
  277. const link = document.createElement('link')
  278. link.rel = 'stylesheet'
  279. link.href = url
  280. document.head.appendChild(link)
  281. return (injectedThemeEffect = () => {
  282. try {
  283. document.head.removeChild(link)
  284. } catch (e) {
  285. console.error(e)
  286. }
  287. injectedThemeEffect = null
  288. })
  289. }