utils.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. import path from 'path/path.js'
  2. // TODO split the capacitor abilities to a separate file for capacitor APIs
  3. import { Capacitor } from '@capacitor/core'
  4. import { StatusBar, Style } from '@capacitor/status-bar'
  5. import { Clipboard as CapacitorClipboard } from '@capacitor/clipboard'
  6. if (typeof window === 'undefined') {
  7. global.window = {}
  8. }
  9. // Copy from https://github.com/primetwig/react-nestable/blob/dacea9dc191399a3520f5dc7623f5edebc83e7b7/dist/utils.js
  10. export const closest = (target, selector) => {
  11. // closest(e.target, '.field')
  12. while (target) {
  13. if (target.matches && target.matches(selector)) return target
  14. target = target.parentNode
  15. }
  16. return null
  17. }
  18. export const getOffsetRect = (elem) => {
  19. // (1)
  20. const box = elem.getBoundingClientRect(),
  21. body = document.body,
  22. docElem = document.documentElement,
  23. // (2)
  24. scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop,
  25. scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft,
  26. // (3)
  27. clientTop = docElem.clientTop || body.clientTop || 0,
  28. clientLeft = docElem.clientLeft || body.clientLeft || 0,
  29. // (4)
  30. top = box.top + scrollTop - clientTop,
  31. left = box.left + scrollLeft - clientLeft;
  32. return {
  33. top: Math.round(top),
  34. left: Math.round(left)
  35. }
  36. }
  37. // jquery focus
  38. export const focus = (elem) => {
  39. return elem === document.activeElement &&
  40. document.hasFocus() &&
  41. !!(elem.type || elem.href || ~elem.tabIndex)
  42. }
  43. // copied from https://stackoverflow.com/a/32180863
  44. export const timeConversion = (millisec) => {
  45. let seconds = (millisec / 1000).toFixed(0),
  46. minutes = (millisec / (1000 * 60)).toFixed(0),
  47. hours = (millisec / (1000 * 60 * 60)).toFixed(1),
  48. days = (millisec / (1000 * 60 * 60 * 24)).toFixed(1);
  49. if (seconds < 60) {
  50. return seconds + 's'
  51. } else if (minutes < 60) {
  52. return minutes + 'm'
  53. } else if (hours < 24) {
  54. return hours + 'h'
  55. } else {
  56. return days + 'd'
  57. }
  58. }
  59. export const getSelectionText = () => {
  60. const selection = (window.getSelection() || '').toString().trim()
  61. if (selection) {
  62. return selection
  63. }
  64. // Firefox fix
  65. const activeElement = window.document.activeElement
  66. if (activeElement) {
  67. if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') {
  68. const el = activeElement
  69. return el.value.slice(el.selectionStart || 0, el.selectionEnd || 0)
  70. }
  71. }
  72. return ''
  73. }
  74. // Modified from https://github.com/GoogleChromeLabs/browser-nativefs
  75. // because shadow-cljs doesn't handle this babel transform
  76. export const getFiles = async (dirHandle, recursive, cb, path = dirHandle.name) => {
  77. const dirs = []
  78. const files = []
  79. for await (const entry of dirHandle.values()) {
  80. const nestedPath = `${path}/${entry.name}`
  81. if (entry.kind === 'file') {
  82. if (cb) {
  83. cb(nestedPath, entry)
  84. }
  85. files.push(
  86. entry.getFile().then((file) => {
  87. Object.defineProperty(file, 'webkitRelativePath', {
  88. configurable: true,
  89. enumerable: true,
  90. get: () => nestedPath,
  91. })
  92. Object.defineProperty(file, 'handle', {
  93. configurable: true,
  94. enumerable: true,
  95. get: () => entry,
  96. })
  97. return file
  98. })
  99. )
  100. } else if (entry.kind === 'directory' && recursive) {
  101. if (cb) { cb(nestedPath, entry) }
  102. dirs.push(...(await getFiles(entry, recursive, cb, nestedPath)))
  103. }
  104. }
  105. return [...(await Promise.all(dirs)), ...(await Promise.all(files))]
  106. }
  107. export const verifyPermission = async (handle, readWrite) => {
  108. const options = {}
  109. if (readWrite) {
  110. options.mode = 'readwrite'
  111. }
  112. // Check if permission was already granted.
  113. if ((await handle.queryPermission(options)) === 'granted') {
  114. return
  115. }
  116. // Request permission. If the user grants permission, just return.
  117. if ((await handle.requestPermission(options)) === 'granted') {
  118. return
  119. }
  120. // The user didn't grant permission, throw an error.
  121. throw new Error('Permission is not granted')
  122. }
  123. // NOTE: Need externs to prevent `options.recursive` been munged
  124. // When building with release.
  125. // browser-fs-access doesn't return directory handles
  126. // Ref: https://github.com/GoogleChromeLabs/browser-fs-access/blob/3876499caefe8512bfcf7ce9e16c20fd10199c8b/src/fs-access/directory-open.mjs#L55-L69
  127. export const openDirectory = async (options = {}, cb) => {
  128. options.recursive = options.recursive || false;
  129. const handle = await window.showDirectoryPicker({
  130. mode: 'readwrite'
  131. });
  132. const _ask = await verifyPermission(handle, true);
  133. return [handle, ...(await getFiles(handle, options.recursive, cb))];
  134. };
  135. export const writeFile = async (fileHandle, contents) => {
  136. // Create a FileSystemWritableFileStream to write to.
  137. const writable = await fileHandle.createWritable()
  138. if (contents instanceof ReadableStream) {
  139. await contents.pipeTo(writable)
  140. } else {
  141. // Write the contents of the file to the stream.
  142. await writable.write(contents)
  143. // Close the file and write the contents to disk.
  144. await writable.close()
  145. }
  146. }
  147. export const nfsSupported = () => {
  148. if ('chooseFileSystemEntries' in self) {
  149. return 'chooseFileSystemEntries'
  150. } else if ('showOpenFilePicker' in self) {
  151. return 'showOpenFilePicker'
  152. }
  153. return false
  154. }
  155. const inputTypes = [
  156. window.HTMLInputElement,
  157. window.HTMLSelectElement,
  158. window.HTMLTextAreaElement,
  159. ]
  160. export const triggerInputChange = (node, value = '', name = 'change') => {
  161. // only process the change on elements we know have a value setter in their constructor
  162. if (inputTypes.indexOf(node.__proto__.constructor) > -1) {
  163. const setValue = Object.getOwnPropertyDescriptor(node.__proto__, 'value').set
  164. const event = new Event('change', {
  165. bubbles: true
  166. })
  167. setValue.call(node, value)
  168. node.dispatchEvent(event)
  169. }
  170. }
  171. // Copied from https://github.com/google/diff-match-patch/issues/29#issuecomment-647627182
  172. export const reversePatch = patch => {
  173. return patch.map(patchObj => ({
  174. diffs: patchObj.diffs.map(([op, val]) => [
  175. op * -1, // The money maker
  176. val
  177. ]),
  178. start1: patchObj.start2,
  179. start2: patchObj.start1,
  180. length1: patchObj.length2,
  181. length2: patchObj.length1
  182. }));
  183. };
  184. // Copied from https://github.com/sindresorhus/path-is-absolute/blob/main/index.js
  185. export const win32 = path => {
  186. // https://github.com/nodejs/node/blob/b3fcc245fb25539909ef1d5eaa01dbf92e168633/lib/path.js#L56
  187. const splitDeviceRe = /^([a-zA-Z]:|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)?([\\/])?([\s\S]*?)$/,
  188. result = splitDeviceRe.exec(path),
  189. device = result[1] || '',
  190. isUnc = Boolean(device && device.charAt(1) !== ':');
  191. // UNC paths are always absolute
  192. return Boolean(result[2] || isUnc);
  193. };
  194. export const ios = () => {
  195. return [
  196. 'iPad Simulator',
  197. 'iPhone Simulator',
  198. 'iPod Simulator',
  199. 'iPad',
  200. 'iPhone',
  201. 'iPod'
  202. ].includes(navigator.platform)
  203. // iPad on iOS 13 detection
  204. ||
  205. (navigator.userAgent.includes("Mac") && "ontouchend" in document)
  206. }
  207. export const getClipText = (cb, errorHandler) => {
  208. navigator.permissions.query({
  209. name: "clipboard-read"
  210. }).then((result) => {
  211. if (result.state == "granted" || result.state == "prompt") {
  212. navigator.clipboard.readText()
  213. .then(text => {
  214. cb(text);
  215. })
  216. .catch(err => {
  217. errorHandler(err)
  218. });
  219. }
  220. })
  221. }
  222. export const writeClipboard = ({text, html, blocks}, ownerWindow) => {
  223. if (Capacitor.isNativePlatform()) {
  224. CapacitorClipboard.write({ string: text });
  225. return
  226. }
  227. const navigator = (ownerWindow || window).navigator
  228. navigator.permissions.query({
  229. name: "clipboard-write"
  230. }).then((result) => {
  231. if (result.state != "granted" && result.state != "prompt"){
  232. console.debug("Copy without `clipboard-write` permission:", text)
  233. return
  234. }
  235. let promise_written = null
  236. if (typeof ClipboardItem !== 'undefined') {
  237. let blob = new Blob([text], {
  238. type: ["text/plain"]
  239. });
  240. let data = [new ClipboardItem({
  241. ["text/plain"]: blob
  242. })];
  243. if (html) {
  244. let richBlob = new Blob([html], {
  245. type: ["text/html"]
  246. })
  247. data = [new ClipboardItem({
  248. ["text/plain"]: blob,
  249. ["text/html"]: richBlob
  250. })];
  251. }
  252. if (blocks) {
  253. let blocksBlob = new Blob([blocks], {
  254. type: ["web application/logseq"]
  255. })
  256. let richBlob = new Blob([html], {
  257. type: ["text/html"]
  258. })
  259. data = [new ClipboardItem({
  260. ["text/plain"]: blob,
  261. ["text/html"]: richBlob,
  262. ["web application/logseq"]: blocksBlob
  263. })];
  264. }
  265. promise_written = navigator.clipboard.write(data)
  266. } else {
  267. console.debug("Degraded copy without `ClipboardItem` support:", text)
  268. promise_written = navigator.clipboard.writeText(text)
  269. }
  270. promise_written.then(() => {
  271. /* success */
  272. }).catch(e => {
  273. console.log(e, "fail")
  274. })
  275. })
  276. }
  277. export const toPosixPath = (input) => {
  278. return input && input.replace(/\\+/g, '/')
  279. }
  280. export const saveToFile = (data, fileName, format) => {
  281. if (!data) return
  282. const url = URL.createObjectURL(data)
  283. const link = document.createElement('a')
  284. link.href = url
  285. link.download = `${fileName}.${format}`
  286. link.click()
  287. }
  288. export const canvasToImage = (canvas, title = 'Untitled', format = 'png') => {
  289. canvas.toBlob(
  290. (blob) => {
  291. console.log(blob)
  292. saveToFile(blob, title, format)
  293. },
  294. `image/.${format}`
  295. )
  296. }
  297. export const nodePath = Object.assign({}, path, {
  298. basename (input) {
  299. input = toPosixPath(input)
  300. return path.basename(input)
  301. },
  302. name (input) {
  303. input = toPosixPath(input)
  304. return path.parse(input).name
  305. },
  306. dirname (input) {
  307. input = toPosixPath(input)
  308. return path.dirname(input)
  309. },
  310. extname (input) {
  311. input = toPosixPath(input)
  312. return path.extname(input)
  313. },
  314. join (input, ...paths) {
  315. let orURI = null
  316. const s = [
  317. 'file://', 'http://',
  318. 'https://', 'content://'
  319. ]
  320. if (s.some(p => input.startsWith(p))) {
  321. try {
  322. orURI = new URL(input)
  323. input = input.replace(orURI.protocol + '//', '')
  324. .replace(orURI.protocol, '')
  325. .replace(/^\/+/, '/')
  326. } catch (_e) {}
  327. }
  328. input = path.join(input, ...paths)
  329. return (orURI ? (orURI.protocol + '//') : '') + input
  330. }
  331. })
  332. // https://stackoverflow.com/questions/376373/pretty-printing-xml-with-javascript
  333. export const prettifyXml = (sourceXml) => {
  334. const xmlDoc = new DOMParser().parseFromString(sourceXml, 'application/xml')
  335. const xsltDoc = new DOMParser().parseFromString([
  336. // describes how we want to modify the XML - indent everything
  337. '<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform">',
  338. ' <xsl:strip-space elements="*"/>',
  339. ' <xsl:template match="para[content-style][not(text())]">', // change to just text() to strip space in text nodes
  340. ' <xsl:value-of select="normalize-space(.)"/>',
  341. ' </xsl:template>',
  342. ' <xsl:template match="node()|@*">',
  343. ' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
  344. ' </xsl:template>',
  345. ' <xsl:output indent="yes"/>',
  346. '</xsl:stylesheet>',
  347. ].join('\n'), 'application/xml')
  348. const xsltProcessor = new XSLTProcessor()
  349. xsltProcessor.importStylesheet(xsltDoc)
  350. const resultDoc = xsltProcessor.transformToDocument(xmlDoc)
  351. const resultXml = new XMLSerializer().serializeToString(resultDoc)
  352. // if it has parsererror, then return the original text
  353. return resultXml.indexOf('<parsererror') === -1 ? resultXml : sourceXml
  354. }
  355. export const elementIsVisibleInViewport = (el, partiallyVisible = false) => {
  356. const { top, left, bottom, right } = el.getBoundingClientRect()
  357. const { innerHeight, innerWidth } = window
  358. return partiallyVisible
  359. ? ((top > 0 && top < innerHeight) ||
  360. (bottom > 0 && bottom < innerHeight)) &&
  361. ((left > 0 && left < innerWidth) || (right > 0 && right < innerWidth))
  362. : top >= 0 && left >= 0 && bottom <= innerHeight && right <= innerWidth
  363. }
  364. export const convertToLetters = (num) => {
  365. if (!+num) return false
  366. let s = '', t
  367. while (num > 0) {
  368. t = (num - 1) % 26
  369. s = String.fromCharCode(65 + t) + s
  370. num = ((num - t) / 26) | 0
  371. }
  372. return s
  373. }
  374. export const convertToRoman = (num) => {
  375. if (!+num) return false
  376. const digits = String(+num).split('')
  377. const key = ['','C','CC','CCC','CD','D','DC','DCC','DCCC','CM',
  378. '','X','XX','XXX','XL','L','LX','LXX','LXXX','XC',
  379. '','I','II','III','IV','V','VI','VII','VIII','IX']
  380. let roman = '', i = 3
  381. while (i--) roman = (key[+digits.pop() + i * 10] || '') + roman
  382. return Array(+digits.join('') + 1).join('M') + roman
  383. }
  384. export function hsl2hex(h, s, l, alpha) {
  385. l /= 100
  386. const a = s * Math.min(l, 1 - l) / 100
  387. const f = n => {
  388. const k = (n + h / 30) % 12
  389. const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
  390. return Math.round(255 * color).toString(16).padStart(2, '0')
  391. // convert to Hex and prefix "0" if needed
  392. }
  393. //alpha conversion
  394. if (alpha) {
  395. alpha = Math.round(alpha * 255).toString(16).padStart(2, '0')
  396. } else {
  397. alpha = ''
  398. }
  399. return `#${f(0)}${f(8)}${f(4)}${alpha}`
  400. }