mirror of
https://github.com/logseq/logseq.git
synced 2026-04-24 22:25:01 +00:00
1508 lines
46 KiB
Clojure
1508 lines
46 KiB
Clojure
(ns frontend.util
|
||
"Main ns for utility fns. This ns should be split up into more focused namespaces"
|
||
#?(:clj (:refer-clojure :exclude [format]))
|
||
#?(:cljs (:require-macros [frontend.util]))
|
||
#?(:cljs (:require
|
||
["/frontend/selection" :as selection]
|
||
["/frontend/utils" :as utils]
|
||
["@capacitor/status-bar" :refer [^js StatusBar Style]]
|
||
["@capacitor/core" :refer [Capacitor]]
|
||
["@capacitor/clipboard" :as CapacitorClipboard]
|
||
["grapheme-splitter" :as GraphemeSplitter]
|
||
["sanitize-filename" :as sanitizeFilename]
|
||
["check-password-strength" :refer [passwordStrength]]
|
||
["path-complete-extname" :as pathCompleteExtname]
|
||
["semver" :as semver]
|
||
[frontend.loader :refer [load]]
|
||
[cljs-bean.core :as bean]
|
||
[cljs-time.coerce :as tc]
|
||
[cljs-time.core :as t]
|
||
[clojure.pprint]
|
||
[dommy.core :as d]
|
||
[frontend.mobile.util :as mobile-util]
|
||
[logseq.common.util :as common-util]
|
||
[goog.dom :as gdom]
|
||
[goog.object :as gobj]
|
||
[goog.string :as gstring]
|
||
[goog.functions :as gfun]
|
||
[goog.userAgent]
|
||
[promesa.core :as p]
|
||
[rum.core :as rum]
|
||
[clojure.core.async :as async]
|
||
[frontend.pubsub :as pubsub]
|
||
[datascript.impl.entity :as de]
|
||
[logseq.common.config :as common-config]))
|
||
#?(:cljs (:import [goog.async Debouncer]))
|
||
(:require
|
||
[clojure.pprint]
|
||
[clojure.string :as string]
|
||
[clojure.walk :as walk]))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(def safe-re-find common-util/safe-re-find)
|
||
(defn safe-keyword
|
||
[s]
|
||
(when (string? s)
|
||
(keyword (string/replace s " " "_"))))))
|
||
|
||
#?(:cljs (goog-define NODETEST false)
|
||
:clj (def NODETEST false))
|
||
(defonce node-test? NODETEST)
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn- ios*?
|
||
[]
|
||
(utils/ios))
|
||
(def ios? (memoize ios*?))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn- safari*?
|
||
[]
|
||
(let [ua (string/lower-case js/navigator.userAgent)]
|
||
(and (string/includes? ua "webkit")
|
||
(not (string/includes? ua "chrome")))))
|
||
(def safari? (memoize safari*?))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn- mobile*?
|
||
"Triggering condition: Mobile phones
|
||
*** Warning!!! ***
|
||
For UX logic only! Don't use for FS logic
|
||
iPad / Android Pad doesn't trigger!"
|
||
[]
|
||
(when-not node-test?
|
||
(safe-re-find #"Mobi" js/navigator.userAgent)))
|
||
(def mobile? (memoize mobile*?))
|
||
(def capacitor? (memoize #(and js/window (gobj/get js/window "isCapacitorNew"))))))
|
||
|
||
#?(:cljs
|
||
(extend-protocol IPrintWithWriter
|
||
symbol
|
||
(-pr-writer [sym writer _]
|
||
(-write writer (str "\"" (.toString sym) "\"")))))
|
||
#?(:cljs
|
||
(extend-protocol INamed
|
||
UUID
|
||
(-name [this] (str this))
|
||
(-namespace [_] nil)))
|
||
|
||
#?(:cljs (defonce ^js node-path utils/nodePath))
|
||
#?(:cljs (defonce ^js sem-ver semver))
|
||
#?(:cljs (defonce ^js full-path-extname pathCompleteExtname))
|
||
|
||
#?(:cljs (defn app-scroll-container-node
|
||
([]
|
||
(or
|
||
(gdom/getElement "main-content-container")
|
||
(gdom/getElement "app-main-home")))
|
||
([el]
|
||
(if (or
|
||
(some-> el (.closest "#main-content-container"))
|
||
(some-> el (.closest "#app-main-home")))
|
||
(app-scroll-container-node)
|
||
(or
|
||
(gdom/getElementByClass "sidebar-item-list")
|
||
(app-scroll-container-node))))))
|
||
#?(:cljs (defonce el-visible-in-viewport? utils/elementIsVisibleInViewport))
|
||
#?(:cljs (defonce convert-to-roman utils/convertToRoman))
|
||
#?(:cljs (defonce convert-to-letters utils/convertToLetters))
|
||
#?(:cljs (defonce hsl2hex utils/hsl2hex))
|
||
#?(:cljs (defonce base64string-to-unit8array utils/base64ToUint8Array))
|
||
|
||
#?(:cljs (def string-join-path common-util/string-join-path))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(def uuid-string? common-util/uuid-string?)
|
||
(defn check-password-strength
|
||
{:malli/schema [:=> [:cat :string] [:maybe
|
||
[:map
|
||
[:contains [:sequential :string]]
|
||
[:length :int]
|
||
[:id :int]
|
||
[:value :string]]]]}
|
||
[input]
|
||
(when-let [^js ret (and (string? input)
|
||
(not (string/blank? input))
|
||
(passwordStrength input))]
|
||
(bean/->clj ret)))
|
||
(defn safe-sanitize-file-name
|
||
{:malli/schema [:=> [:cat :string] :string]}
|
||
[s]
|
||
(sanitizeFilename (str s)))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn- electron*?
|
||
[]
|
||
(when (and js/window (gobj/get js/window "navigator"))
|
||
(gstring/caseInsensitiveContains js/navigator.userAgent " electron")))
|
||
(def electron? (memoize electron*?))))
|
||
|
||
#?(:cljs
|
||
(defn mocked-open-dir-path
|
||
"Mocked open DIR path for by-passing open dir in electron during testing. Nil if not given"
|
||
[]
|
||
(when (electron?) (. js/window -__MOCKED_OPEN_DIR_PATH__))))
|
||
|
||
;; #?(:cljs
|
||
;; (defn ci?
|
||
;; []
|
||
;; (boolean (. js/window -__E2E_TESTING__))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(def nfs? (and (not (electron?))
|
||
(not (mobile-util/native-platform?))))
|
||
(def web-platform? nfs?)
|
||
(def plugin-platform? (or (and web-platform? (not common-config/PUBLISHING)) (electron?)))))
|
||
|
||
#?(:cljs
|
||
(def format common-util/format))
|
||
|
||
#?(:clj
|
||
(defn format
|
||
[fmt & args]
|
||
(apply clojure.core/format fmt args)))
|
||
|
||
#?(:cljs
|
||
(defn evalue
|
||
[event]
|
||
(gobj/getValueByKeys event "target" "value")))
|
||
|
||
#?(:cljs
|
||
(defn ekey [event]
|
||
(gobj/getValueByKeys event "key")))
|
||
|
||
#?(:cljs
|
||
(defn echecked? [event]
|
||
(gobj/getValueByKeys event "target" "checked")))
|
||
|
||
#?(:cljs
|
||
(defn set-change-value
|
||
"compatible change event for React"
|
||
[node value]
|
||
(utils/triggerInputChange node value)))
|
||
|
||
#?(:cljs
|
||
(defn p-handle
|
||
([p ok-handler]
|
||
(p-handle p ok-handler (fn [error]
|
||
(js/console.error error))))
|
||
([p ok-handler error-handler]
|
||
(-> p
|
||
(p/then (fn [result]
|
||
(ok-handler result)))
|
||
(p/catch (fn [error]
|
||
(error-handler error)))))))
|
||
|
||
#?(:cljs
|
||
(defn get-width
|
||
[]
|
||
(gobj/get js/window "innerWidth")))
|
||
|
||
#?(:cljs
|
||
(defn set-theme-light
|
||
[]
|
||
(p/do!
|
||
(.setStyle StatusBar (clj->js {:style (.-Light Style)})))))
|
||
|
||
#?(:cljs
|
||
(defn set-theme-dark
|
||
[]
|
||
(p/do!
|
||
(.setStyle StatusBar (clj->js {:style (.-Dark Style)})))))
|
||
|
||
(defn find-first
|
||
[pred coll]
|
||
(first (filter pred coll)))
|
||
|
||
(defn find-index
|
||
"Find first index of an element in list"
|
||
[pred-or-val coll]
|
||
(let [pred (if (fn? pred-or-val) pred-or-val #(= pred-or-val %))]
|
||
(reduce-kv #(if (pred %3) (reduced %2) %1) -1
|
||
(cond-> coll (list? coll) (vec)))))
|
||
|
||
;; ".lg:absolute.lg:inset-y-0.lg:right-0.lg:w-1/2"
|
||
(defn hiccup->class
|
||
[class']
|
||
(some->> (string/split class' #"\.")
|
||
(string/join " ")
|
||
(string/trim)))
|
||
|
||
#?(:cljs
|
||
(defn fetch
|
||
([url on-ok on-failed]
|
||
(fetch url {} on-ok on-failed))
|
||
([url opts on-ok on-failed]
|
||
(-> (js/fetch url (bean/->js opts))
|
||
(.then (fn [resp]
|
||
(if (>= (.-status resp) 400)
|
||
(on-failed resp)
|
||
(if (.-ok resp)
|
||
(-> (.json resp)
|
||
(.then bean/->clj)
|
||
(.then #(on-ok %)))
|
||
(on-failed resp)))))))))
|
||
|
||
#?(:cljs (def zero-pad common-util/zero-pad))
|
||
|
||
#?(:cljs
|
||
(defn safe-parse-int
|
||
"Use if arg could be an int or string. If arg is only a string, use `parse-long`."
|
||
{:malli/schema [:=> [:cat [:or :int :string]] :int]}
|
||
[x]
|
||
(if (string? x)
|
||
(parse-long x)
|
||
x)))
|
||
|
||
#?(:cljs
|
||
(defn safe-parse-float
|
||
"Use if arg could be a float or string. If arg is only a string, use `parse-double`"
|
||
{:malli/schema [:=> [:cat [:or :double :string]] :double]}
|
||
[x]
|
||
(if (string? x)
|
||
(parse-double x)
|
||
x)))
|
||
|
||
#?(:cljs
|
||
(def debounce gfun/debounce))
|
||
|
||
#?(:cljs
|
||
(defn cancelable-debounce
|
||
"Create a stateful debounce function with specified interval
|
||
|
||
Returns [fire-fn, cancel-fn]
|
||
|
||
Use `fire-fn` to call the function(debounced)
|
||
|
||
Use `cancel-fn` to cancel pending callback if there is"
|
||
[f interval]
|
||
(let [debouncer (Debouncer. f interval)]
|
||
[(fn [& args] (.apply (.-fire debouncer) debouncer (to-array args)))
|
||
(fn [] (.stop debouncer))])))
|
||
|
||
(defn nth-safe [c i]
|
||
(if (or (< i 0) (>= i (count c)))
|
||
nil
|
||
(nth c i)))
|
||
|
||
#?(:cljs
|
||
(when-not node-test?
|
||
(extend-type js/NodeList
|
||
ISeqable
|
||
(-seq [arr] (array-seq arr 0)))))
|
||
|
||
;; Caret
|
||
#?(:cljs
|
||
(defn caret-range [node]
|
||
(when-let [doc (or (gobj/get node "ownerDocument")
|
||
(gobj/get node "document"))]
|
||
(let [win (or (gobj/get doc "defaultView")
|
||
(gobj/get doc "parentWindow"))
|
||
selection (.getSelection win)]
|
||
(if selection
|
||
(let [range-count (gobj/get selection "rangeCount")]
|
||
(when (> range-count 0)
|
||
(let [range (-> (.getSelection win)
|
||
(.getRangeAt 0))
|
||
pre-caret-range (.cloneRange range)]
|
||
(.selectNodeContents pre-caret-range node)
|
||
(.setEnd pre-caret-range
|
||
(gobj/get range "endContainer")
|
||
(gobj/get range "endOffset"))
|
||
(let [contents (.cloneContents pre-caret-range)
|
||
;; Remove all `.select-none` nodes
|
||
_ (doseq [el (.querySelectorAll contents ".select-none")]
|
||
(.remove el))
|
||
html (some-> (first (.-childNodes contents))
|
||
(gobj/get "innerHTML")
|
||
str)
|
||
;; FIXME: this depends on the dom structure,
|
||
;; need a converter from html to text includes newlines
|
||
br-ended? (and html
|
||
(or
|
||
;; first line with a new line
|
||
(string/ends-with? html "<div class=\"is-paragraph\"></div></div></span></div></div></div>")
|
||
;; multiple lines with a new line
|
||
(string/ends-with? html "<br></div></div></span></div></div></div>")))
|
||
value (.-textContent contents)]
|
||
(if br-ended?
|
||
(str value "\n")
|
||
value)))))
|
||
(when-let [selection (gobj/get doc "selection")]
|
||
(when (not= "Control" (gobj/get selection "type"))
|
||
(let [text-range (.createRange selection)
|
||
pre-caret-text-range (.createTextRange (gobj/get doc "body"))]
|
||
(.moveToElementText pre-caret-text-range node)
|
||
(.setEndPoint pre-caret-text-range "EndToEnd" text-range)
|
||
(gobj/get pre-caret-text-range "text")))))))))
|
||
|
||
(defn get-selection-start
|
||
[input]
|
||
(when input
|
||
(.-selectionStart input)))
|
||
|
||
(defn get-selection-end
|
||
[input]
|
||
(when input
|
||
(.-selectionEnd input)))
|
||
|
||
(defn input-text-selected?
|
||
[input]
|
||
(not= (get-selection-start input)
|
||
(get-selection-end input)))
|
||
|
||
(defn get-selection-direction
|
||
[input]
|
||
(when input
|
||
(.-selectionDirection input)))
|
||
|
||
#?(:cljs
|
||
(defn split-graphemes
|
||
[s]
|
||
(let [^js splitter (GraphemeSplitter.)]
|
||
(.splitGraphemes splitter s))))
|
||
|
||
#?(:cljs
|
||
(defn get-graphemes-pos
|
||
"Return the length of the substrings in s between start and from-index.
|
||
|
||
multi-char count as 1, like emoji characters"
|
||
[s from-index]
|
||
(let [^js splitter (GraphemeSplitter.)]
|
||
(.countGraphemes splitter (subs s 0 from-index)))))
|
||
|
||
#?(:cljs
|
||
(defn get-line-pos
|
||
"Return the length of the substrings in s between the last index of newline
|
||
in s searching backward from from-newline-index and from-newline-index.
|
||
|
||
multi-char count as 1, like emoji characters"
|
||
[s from-newline-index]
|
||
(let [^js splitter (GraphemeSplitter.)
|
||
last-newline-pos (string/last-index-of s \newline (dec from-newline-index))
|
||
before-last-newline-length (or last-newline-pos -1)
|
||
last-newline-content (subs s (inc before-last-newline-length) from-newline-index)]
|
||
(.countGraphemes splitter last-newline-content))))
|
||
|
||
#?(:cljs
|
||
(defn get-text-range
|
||
"Return the substring of the first grapheme-num characters of s if first-line? is true,
|
||
otherwise return the substring of s before the last \n and the first grapheme-num characters.
|
||
|
||
grapheme-num treats multi-char as 1, like emoji characters"
|
||
[s grapheme-num first-line?]
|
||
(let [newline-pos (if first-line?
|
||
0
|
||
(inc (or (string/last-index-of s \newline) -1)))
|
||
^js splitter (GraphemeSplitter.)
|
||
^js newline-graphemes (.splitGraphemes splitter (subs s newline-pos))
|
||
^js newline-graphemes (.slice newline-graphemes 0 grapheme-num)
|
||
content (.join newline-graphemes "")]
|
||
(subs s 0 (+ newline-pos (count content))))))
|
||
|
||
#?(:cljs
|
||
(defn stop [e]
|
||
(when e (doto e (.preventDefault) (.stopPropagation)))))
|
||
|
||
#?(:cljs
|
||
(defn stop-propagation [e]
|
||
(when e (.stopPropagation e))))
|
||
|
||
#?(:cljs
|
||
(defn nearest-scrollable-container [^js/HTMLElement element]
|
||
(some #(when-let [overflow-y (.-overflowY (js/window.getComputedStyle %))]
|
||
(when (contains? #{"auto" "scroll" "overlay"} overflow-y)
|
||
%))
|
||
(take-while (complement nil?) (iterate #(.-parentElement %) element)))))
|
||
|
||
#?(:cljs
|
||
(defn element-visible?
|
||
[element]
|
||
(when element
|
||
(when-let [r (.getBoundingClientRect element)]
|
||
(and (>= (.-top r) 0)
|
||
(<= (+ (.-bottom r) 64)
|
||
(or (.-innerHeight js/window)
|
||
(js/document.documentElement.clientHeight))))))))
|
||
|
||
#?(:cljs
|
||
(defn element-top [elem top]
|
||
(when elem
|
||
(if (.-offsetParent elem)
|
||
(let [client-top (or (.-clientTop elem) 0)
|
||
offset-top (.-offsetTop elem)]
|
||
(+ top client-top offset-top (element-top (.-offsetParent elem) top)))
|
||
top))))
|
||
|
||
#?(:cljs
|
||
(defn scroll-to-element
|
||
[elem-id]
|
||
(when-not (safe-re-find #"^/\d+$" elem-id)
|
||
(when elem-id
|
||
(when-let [elem (gdom/getElement elem-id)]
|
||
(.scroll (app-scroll-container-node)
|
||
#js {:top (let [top (element-top elem 0)]
|
||
(if (< top 256)
|
||
0
|
||
(- top 80)))
|
||
:behavior "smooth"}))))))
|
||
|
||
#?(:cljs
|
||
(defn scroll-to
|
||
([pos]
|
||
(scroll-to (app-scroll-container-node) pos))
|
||
([node pos]
|
||
(scroll-to node pos true))
|
||
([node pos animate?]
|
||
(when node
|
||
(.scroll node
|
||
#js {:top pos
|
||
:behavior (if animate? "smooth" "auto")})))))
|
||
|
||
#?(:cljs
|
||
(defn scroll-top
|
||
"Returns the scroll top position of the `node`. If `node` is not specified,
|
||
returns the scroll top position of the `app-scroll-container-node`."
|
||
([]
|
||
(scroll-top (app-scroll-container-node)))
|
||
([node]
|
||
(when node (.-scrollTop node)))))
|
||
|
||
#?(:cljs
|
||
(defn scroll-to-top
|
||
([]
|
||
(scroll-to (app-scroll-container-node) 0 false))
|
||
([animate?]
|
||
(scroll-to (app-scroll-container-node) 0 animate?))))
|
||
|
||
#?(:cljs
|
||
(defn scroll-to-block
|
||
"Scroll into the view to vertically align a non-visible block to the centre
|
||
of the visible area"
|
||
([block]
|
||
(scroll-to-block block true))
|
||
([block animate?]
|
||
(when block
|
||
(when-not (element-visible? block)
|
||
(.scrollIntoView block
|
||
#js {:behavior (if animate? "smooth" "auto")
|
||
:block "center"}))))))
|
||
|
||
#?(:cljs
|
||
(defn link?
|
||
[node]
|
||
(contains?
|
||
#{"A" "BUTTON"}
|
||
(gobj/get node "tagName"))))
|
||
|
||
#?(:cljs
|
||
(defn time?
|
||
[node]
|
||
(contains?
|
||
#{"TIME"}
|
||
(gobj/get node "tagName"))))
|
||
|
||
#?(:cljs
|
||
(defn audio?
|
||
[node]
|
||
(contains?
|
||
#{"AUDIO"}
|
||
(gobj/get node "tagName"))))
|
||
|
||
#?(:cljs
|
||
(defn video?
|
||
[node]
|
||
(contains?
|
||
#{"VIDEO"}
|
||
(gobj/get node "tagName"))))
|
||
|
||
#?(:cljs
|
||
(defn sup?
|
||
[node]
|
||
(contains?
|
||
#{"SUP"}
|
||
(gobj/get node "tagName"))))
|
||
|
||
#?(:cljs
|
||
(defn input?
|
||
[node]
|
||
(when node
|
||
(contains?
|
||
#{"INPUT" "TEXTAREA"}
|
||
(gobj/get node "tagName")))))
|
||
|
||
#?(:cljs
|
||
(defn details-or-summary?
|
||
[node]
|
||
(when node
|
||
(contains?
|
||
#{"DETAILS" "SUMMARY"}
|
||
(gobj/get node "tagName")))))
|
||
|
||
;; Debug
|
||
(defn starts-with?
|
||
[s substr]
|
||
(string/starts-with? s substr))
|
||
|
||
#?(:cljs
|
||
(def distinct-by common-util/distinct-by))
|
||
|
||
#?(:cljs
|
||
(def distinct-by-last-wins common-util/distinct-by-last-wins))
|
||
|
||
(defn get-git-owner-and-repo
|
||
[repo-url]
|
||
(take-last 2 (string/split repo-url #"/")))
|
||
|
||
(defn safe-lower-case
|
||
[s]
|
||
(if (string? s)
|
||
(string/lower-case s) s))
|
||
|
||
(defn trim-safe
|
||
[s]
|
||
(if (string? s)
|
||
(string/trim s) s))
|
||
|
||
(defn trimr-without-newlines
|
||
[s]
|
||
(.replace s #"[ \t\r]+$" ""))
|
||
|
||
(defn triml-without-newlines
|
||
[s]
|
||
(.replace s #"^[ \t\r]+" ""))
|
||
|
||
(defn concat-without-spaces
|
||
[left right]
|
||
(when (and (string? left)
|
||
(string? right))
|
||
(let [left (trimr-without-newlines left)
|
||
not-space? (or
|
||
(string/blank? left)
|
||
(= "\n" (last left)))]
|
||
(str left
|
||
(when-not not-space? " ")
|
||
(triml-without-newlines right)))))
|
||
|
||
(defn cjk-string?
|
||
[s]
|
||
(re-find #"[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f]" s))
|
||
|
||
;; Add documentation
|
||
(defn replace-first [pattern s new-value]
|
||
(if-let [first-index (string/index-of s pattern)]
|
||
(str new-value (subs s (+ first-index (count pattern))))
|
||
s))
|
||
|
||
(defn replace-last
|
||
([pattern s new-value]
|
||
(replace-last pattern s new-value true))
|
||
([pattern s new-value space?]
|
||
(if-let [last-index (string/last-index-of s pattern)]
|
||
(let [prefix (subs s 0 last-index)]
|
||
(if space?
|
||
(concat-without-spaces prefix new-value)
|
||
(str prefix new-value)))
|
||
s)))
|
||
|
||
;; copy from https://stackoverflow.com/questions/18735665/how-can-i-get-the-positions-of-regex-matches-in-clojurescript
|
||
#?(:cljs
|
||
(defn re-pos [re s]
|
||
(let [re (js/RegExp. (.-source re) "g")]
|
||
(loop [res []]
|
||
(if-let [m (.exec re s)]
|
||
(recur (conj res [(.-index m) (first m)]))
|
||
res)))))
|
||
|
||
#?(:cljs
|
||
(defn safe-set-range-text!
|
||
([input text start end]
|
||
(try
|
||
(.setRangeText input text start end)
|
||
(catch :default _e
|
||
nil)))
|
||
([input text start end select-mode]
|
||
(try
|
||
(.setRangeText input text start end select-mode)
|
||
(catch :default _e
|
||
nil)))))
|
||
|
||
#?(:cljs
|
||
;; for widen char
|
||
(defn safe-dec-current-pos-from-end
|
||
[input current-pos]
|
||
(if-let [len (and (number? current-pos) (string? input) (.-length input))]
|
||
(if-let [input (and (>= len 2) (<= current-pos len)
|
||
(.substring input (max (- current-pos 20) 0) current-pos))]
|
||
(try
|
||
(let [^js splitter (GraphemeSplitter.)
|
||
^js input' (.splitGraphemes splitter input)]
|
||
(- current-pos (.-length (.pop input'))))
|
||
(catch :default e
|
||
(js/console.error e)
|
||
(dec current-pos)))
|
||
(dec current-pos))
|
||
current-pos)))
|
||
|
||
#?(:cljs
|
||
;; for widen char
|
||
(defn safe-inc-current-pos-from-start
|
||
[input current-pos]
|
||
(if-let [len (and (number? current-pos) (string? input) (.-length input))]
|
||
(if-let [input (and (>= len 2) (<= current-pos len)
|
||
(.substr input current-pos 20))]
|
||
(try
|
||
(let [^js splitter (GraphemeSplitter.)
|
||
^js input (.splitGraphemes splitter input)]
|
||
(+ current-pos (.-length (.shift input))))
|
||
(catch :default e
|
||
(js/console.error e)
|
||
(inc current-pos)))
|
||
(inc current-pos))
|
||
current-pos)))
|
||
|
||
#?(:cljs
|
||
(defn kill-line-before!
|
||
[input]
|
||
(let [val (.-value input)
|
||
end (get-selection-start input)
|
||
n-pos (string/last-index-of val \newline (dec end))
|
||
start (if n-pos (inc n-pos) 0)]
|
||
(safe-set-range-text! input "" start end))))
|
||
|
||
#?(:cljs
|
||
(defn kill-line-after!
|
||
[input]
|
||
(let [val (.-value input)
|
||
start (get-selection-start input)
|
||
end (or (string/index-of val \newline start)
|
||
(count val))]
|
||
(safe-set-range-text! input "" start end))))
|
||
|
||
#?(:cljs
|
||
(defn insert-at-current-position!
|
||
[input text]
|
||
(let [start (get-selection-start input)
|
||
end (get-selection-end input)]
|
||
(safe-set-range-text! input text start end "end"))))
|
||
|
||
(defn safe-subvec [xs start end]
|
||
(if (or (neg? start)
|
||
(> start end)
|
||
(> end (count xs)))
|
||
[]
|
||
(subvec xs start end)))
|
||
|
||
#?(:cljs
|
||
(defn get-nodes-between-two-nodes
|
||
[node-1 node-2 class]
|
||
(when-let [nodes (array-seq (js/document.getElementsByClassName class))]
|
||
(let [idx-1 (.indexOf nodes node-1)
|
||
idx-2 (.indexOf nodes node-2)
|
||
start (min idx-1 idx-2)
|
||
end (inc (max idx-1 idx-2))]
|
||
(safe-subvec (vec nodes) start end)))))
|
||
|
||
#?(:cljs
|
||
(defn get-direction-between-two-nodes
|
||
[node-1 node-2 class]
|
||
(when-let [nodes (array-seq (js/document.getElementsByClassName class))]
|
||
(let [idx-1 (.indexOf nodes node-1)
|
||
idx-2 (.indexOf nodes node-2)]
|
||
(if (>= idx-1 idx-2)
|
||
:up
|
||
:down)))))
|
||
|
||
#?(:cljs
|
||
(defn rec-get-node
|
||
[node class]
|
||
(if (and node (d/has-class? node class))
|
||
node
|
||
(and node
|
||
(rec-get-node (gobj/get node "parentNode") class)))))
|
||
|
||
#?(:cljs
|
||
(defn rec-get-blocks-container
|
||
[node]
|
||
(rec-get-node node "blocks-container")))
|
||
|
||
#?(:cljs
|
||
(defn rec-get-blocks-content-section
|
||
[node]
|
||
(rec-get-node node "content")))
|
||
|
||
#?(:cljs
|
||
(defn get-blocks-noncollapse
|
||
([]
|
||
(->> (d/sel "div .ls-block")
|
||
(filter (fn [b] (some? (gobj/get b "offsetParent"))))))
|
||
([blocks-container]
|
||
(->> (d/sel blocks-container "div .ls-block")
|
||
(filter (fn [b] (some? (gobj/get b "offsetParent"))))))))
|
||
|
||
#?(:cljs
|
||
(defn remove-embedded-blocks [blocks]
|
||
(->> blocks
|
||
(remove (fn [b] (= "true" (d/attr b "data-embed")))))))
|
||
|
||
#?(:cljs
|
||
(defn remove-property-value-blocks [blocks]
|
||
(->> blocks
|
||
(remove (fn [b] (d/has-class? b "property-value-container"))))))
|
||
|
||
#?(:cljs
|
||
(defn get-selected-text
|
||
[]
|
||
(utils/getSelectionText)))
|
||
|
||
#?(:cljs (def clear-selection! selection/clearSelection))
|
||
|
||
#?(:cljs
|
||
(defn write-clipboard
|
||
([data] (write-clipboard data nil))
|
||
([data owner-window]
|
||
(if (.isNativePlatform ^js Capacitor)
|
||
(.write (gobj/get CapacitorClipboard "Clipboard") #js {:string (gobj/get data "text")})
|
||
(utils/writeClipboard data owner-window)))))
|
||
|
||
#?(:cljs
|
||
(defn copy-to-clipboard!
|
||
[text & {:keys [graph html blocks embed-block? owner-window]}]
|
||
(let [blocks (map (fn [block] (if (de/entity? block)
|
||
(-> (into {} block)
|
||
;; FIXME: why :db/id is not included?
|
||
(assoc :db/id (:db/id block)))
|
||
block)) blocks)
|
||
data (clj->js
|
||
(common-util/remove-nils-non-nested
|
||
{:text text
|
||
:html html
|
||
:blocks (when (and graph (seq blocks))
|
||
(pr-str
|
||
{:graph graph
|
||
:embed-block? embed-block?
|
||
:blocks (mapv #(dissoc % :block.temp/load-status %) blocks)}))}))]
|
||
(if owner-window
|
||
(write-clipboard data owner-window)
|
||
(write-clipboard data)))))
|
||
|
||
(defn drop-nth [n coll]
|
||
(keep-indexed #(when (not= %1 n) %2) coll))
|
||
|
||
#?(:cljs
|
||
(defn atom? [v]
|
||
(instance? Atom v)))
|
||
|
||
#?(:cljs
|
||
(defn react
|
||
[ref]
|
||
(when ref
|
||
(if rum/*reactions*
|
||
(rum/react ref)
|
||
@ref))))
|
||
|
||
#?(:cljs
|
||
(def time-ms common-util/time-ms))
|
||
|
||
(defn d
|
||
[k f]
|
||
(let [result (atom nil)]
|
||
(println (str "Debug " k))
|
||
(time (reset! result (doall (f))))
|
||
@result))
|
||
|
||
#?(:cljs
|
||
(def concat-without-nil common-util/concat-without-nil))
|
||
|
||
#?(:cljs
|
||
(defn set-title!
|
||
[title]
|
||
(set! (.-title js/document) title)))
|
||
|
||
#?(:cljs
|
||
(defn get-block-container
|
||
[block-element]
|
||
(when block-element
|
||
(when-let [section (some-> (rec-get-blocks-content-section block-element)
|
||
(d/parent))]
|
||
(when section
|
||
(gdom/getElement section "id"))))))
|
||
|
||
#?(:cljs
|
||
(defn- skip-same-top-blocks
|
||
[blocks block]
|
||
(let [property? (= (d/attr block "data-is-property") "true")
|
||
properties-area (rec-get-node block "ls-properties-area")]
|
||
(remove (fn [b]
|
||
(and
|
||
(not= b block)
|
||
(or (= (when b (.-top (.getBoundingClientRect b)))
|
||
(when block (.-top (.getBoundingClientRect block))))
|
||
(when property?
|
||
(and (not= (d/attr b "data-is-property") "true")
|
||
(gdom/contains properties-area b)))))) blocks))))
|
||
|
||
#?(:cljs
|
||
(defn get-prev-block-non-collapsed
|
||
"Gets previous non-collapsed block. If given a container
|
||
looks up blocks in that container e.g. for embed"
|
||
([block] (get-prev-block-non-collapsed block {}))
|
||
([block {:keys [container up-down? exclude-property?]}]
|
||
(when-let [blocks (if container
|
||
(get-blocks-noncollapse container)
|
||
(get-blocks-noncollapse))]
|
||
(let [blocks (cond->>
|
||
(if up-down?
|
||
(skip-same-top-blocks blocks block)
|
||
blocks)
|
||
exclude-property?
|
||
(remove (fn [node] (d/has-class? node "property-value-container"))))]
|
||
(when-let [index (.indexOf blocks block)]
|
||
(let [idx (dec index)]
|
||
(when (>= idx 0)
|
||
(nth-safe blocks idx)))))))))
|
||
|
||
#?(:cljs
|
||
(defn get-prev-block-non-collapsed-non-embed
|
||
[block]
|
||
(when-let [blocks (->> (get-blocks-noncollapse)
|
||
remove-embedded-blocks
|
||
remove-property-value-blocks)]
|
||
(when-let [index (.indexOf blocks block)]
|
||
(let [idx (dec index)]
|
||
(when (>= idx 0)
|
||
(nth-safe blocks idx)))))))
|
||
|
||
#?(:cljs
|
||
(defn get-next-block-non-collapsed
|
||
[block {:keys [up-down? exclude-property?]}]
|
||
(when-let [blocks (and block (get-blocks-noncollapse))]
|
||
(let [blocks (cond->>
|
||
(if up-down?
|
||
(skip-same-top-blocks blocks block)
|
||
blocks)
|
||
exclude-property?
|
||
(remove (fn [node] (d/has-class? node "property-value-container"))))]
|
||
(when-let [index (.indexOf blocks block)]
|
||
(let [idx (inc index)]
|
||
(when (>= (count blocks) idx)
|
||
(nth-safe blocks idx))))))))
|
||
|
||
#?(:cljs
|
||
(defn get-next-block-non-collapsed-skip
|
||
[block]
|
||
(when-let [blocks (get-blocks-noncollapse)]
|
||
(when-let [index (.indexOf blocks block)]
|
||
(loop [idx (inc index)]
|
||
(when (>= (count blocks) idx)
|
||
(let [block (nth-safe blocks idx)
|
||
nested? (->> (array-seq (gdom/getElementsByClass "selected"))
|
||
(some (fn [dom] (.contains dom block))))]
|
||
(if nested?
|
||
(recur (inc idx))
|
||
block))))))))
|
||
|
||
(defn rand-str
|
||
[n]
|
||
#?(:cljs (-> (.toString (js/Math.random) 36)
|
||
(.substr 2 n))
|
||
:clj (->> (repeatedly #(Integer/toString (rand 36) 36))
|
||
(take n)
|
||
(apply str))))
|
||
|
||
(defn unique-id
|
||
[]
|
||
(str (rand-str 6) (rand-str 3)))
|
||
|
||
(defn pp-str [x]
|
||
#_:clj-kondo/ignore
|
||
(with-out-str (clojure.pprint/pprint x)))
|
||
|
||
(defn hiccup-keywordize
|
||
[hiccup]
|
||
(walk/postwalk
|
||
(fn [f]
|
||
(if (and (vector? f) (string? (first f)))
|
||
(update f 0 keyword)
|
||
f))
|
||
hiccup))
|
||
|
||
#?(:cljs
|
||
(defn chrome?
|
||
[]
|
||
(let [user-agent js/navigator.userAgent
|
||
vendor js/navigator.vendor]
|
||
(boolean (and (safe-re-find #"Chrome" user-agent)
|
||
(safe-re-find #"Google Inc" vendor))))))
|
||
|
||
(defonce mac? #?(:cljs goog.userAgent/MAC
|
||
:clj nil))
|
||
|
||
(defonce win32? #?(:cljs goog.userAgent/WINDOWS
|
||
:clj nil))
|
||
|
||
(defonce linux? #?(:cljs goog.userAgent/LINUX
|
||
:clj nil))
|
||
|
||
#?(:cljs
|
||
(defn get-blocks-by-id
|
||
[block-id]
|
||
(when (uuid-string? (str block-id))
|
||
(d/sel (format "[blockid='%s']" (str block-id))))))
|
||
|
||
#?(:cljs
|
||
(defn get-first-block-by-id
|
||
[block-id]
|
||
(first (get-blocks-by-id block-id))))
|
||
|
||
#?(:cljs
|
||
(defn url-encode
|
||
[string]
|
||
(some-> string str (js/encodeURIComponent) (.replace "+" "%20"))))
|
||
|
||
#?(:cljs
|
||
(def page-name-sanity-lc
|
||
"Delegate to common-util to loosely couple app usages to graph-parser"
|
||
common-util/page-name-sanity-lc))
|
||
|
||
#?(:cljs
|
||
(def safe-page-name-sanity-lc common-util/safe-page-name-sanity-lc))
|
||
|
||
#?(:cljs
|
||
(def get-page-title common-util/get-page-title))
|
||
|
||
#?(:cljs
|
||
(defn add-style!
|
||
[style]
|
||
(when (some? style)
|
||
(let [parent-node (d/sel1 :head)
|
||
id "logseq-custom-theme-id"
|
||
old-link-element (d/sel1 (str "#" id))
|
||
style (if (string/starts-with? style "http")
|
||
style
|
||
(str "data:text/css;charset=utf-8," (js/encodeURIComponent style)))]
|
||
(when old-link-element
|
||
(d/remove! old-link-element))
|
||
(let [link (->
|
||
(d/create-element :link)
|
||
(d/set-attr! :id id)
|
||
(d/set-attr! :rel "stylesheet")
|
||
(d/set-attr! :type "text/css")
|
||
(d/set-attr! :href style)
|
||
(d/set-attr! :media "all"))]
|
||
(d/append! parent-node link))))))
|
||
|
||
(defn remove-common-preceding
|
||
[col1 col2]
|
||
(if (and (= (first col1) (first col2))
|
||
(seq col1))
|
||
(recur (rest col1) (rest col2))
|
||
[col1 col2]))
|
||
|
||
;; fs
|
||
#?(:cljs
|
||
(defn get-file-ext
|
||
[file]
|
||
(and
|
||
(string? file)
|
||
(string/includes? file ".")
|
||
(some-> (common-util/path->file-ext file) string/lower-case))))
|
||
|
||
#?(:cljs
|
||
(defn get-dir-and-basename
|
||
[path]
|
||
(let [parts (string/split path "/")
|
||
basename (last parts)
|
||
dir (->> (butlast parts)
|
||
string-join-path)]
|
||
[dir basename])))
|
||
|
||
#?(:cljs
|
||
(defn get-relative-path
|
||
[current-file-path another-file-path]
|
||
(let [directories-f #(butlast (string/split % "/"))
|
||
parts-1 (directories-f current-file-path)
|
||
parts-2 (directories-f another-file-path)
|
||
[parts-1 parts-2] (remove-common-preceding parts-1 parts-2)
|
||
another-file-name (last (string/split another-file-path "/"))]
|
||
(->> (concat
|
||
(if (seq parts-1)
|
||
(repeat (count parts-1) "..")
|
||
["."])
|
||
parts-2
|
||
[another-file-name])
|
||
string-join-path))))
|
||
|
||
#?(:clj
|
||
(defmacro profile
|
||
[k & body]
|
||
`(if goog.DEBUG
|
||
(let [k# ~k]
|
||
(.time js/console k#)
|
||
(let [res# (do ~@body)]
|
||
(.timeEnd js/console k#)
|
||
res#))
|
||
(do ~@body))))
|
||
|
||
#?(:clj
|
||
(defmacro with-time
|
||
"Evaluates expr and prints the time it took.
|
||
Returns the value of expr and the spent time of float number in msecs."
|
||
[expr]
|
||
`(let [start# (cljs.core/system-time)
|
||
ret# ~expr]
|
||
{:result ret#
|
||
:time (- (cljs.core/system-time) start#)})))
|
||
|
||
;; TODO: profile and profileEnd
|
||
|
||
(comment
|
||
(= (get-relative-path "journals/2020_11_18.org" "pages/grant_ideas.org")
|
||
"../pages/grant_ideas.org")
|
||
|
||
(= (get-relative-path "journals/2020_11_18.org" "journals/2020_11_19.org")
|
||
"./2020_11_19.org")
|
||
|
||
(= (get-relative-path "a/b/c/d/g.org" "a/b/c/e/f.org")
|
||
"../e/f.org"))
|
||
|
||
(defn keyname [key] (str (namespace key) "/" (name key)))
|
||
|
||
;; FIXME: drain-chan was copied from frontend.worker-common.util due to shadow-cljs compile bug
|
||
#?(:cljs
|
||
(defn drain-chan
|
||
"drop all stuffs in CH, and return all of them"
|
||
[ch]
|
||
(->> (repeatedly #(async/poll! ch))
|
||
(take-while identity))))
|
||
|
||
#?(:cljs
|
||
(defn trace!
|
||
[]
|
||
(js/console.trace)))
|
||
|
||
#?(:cljs
|
||
(def remove-first common-util/remove-first))
|
||
|
||
#?(:cljs
|
||
(defn backward-kill-word
|
||
[input]
|
||
(let [val (.-value input)
|
||
current (get-selection-start input)
|
||
prev (or
|
||
(->> [(string/last-index-of val \space (dec current))
|
||
(string/last-index-of val \newline (dec current))]
|
||
(remove nil?)
|
||
(apply max))
|
||
0)
|
||
idx (if (zero? prev)
|
||
0
|
||
(->
|
||
(loop [idx prev]
|
||
(if (#{\space \newline} (nth-safe val idx))
|
||
(recur (dec idx))
|
||
idx))
|
||
inc))]
|
||
(safe-set-range-text! input "" idx current))))
|
||
|
||
#?(:cljs
|
||
(defn forward-kill-word
|
||
[input]
|
||
(let [val (.-value input)
|
||
current (get-selection-start input)
|
||
current (loop [idx current]
|
||
(if (#{\space \newline} (nth-safe val idx))
|
||
(recur (inc idx))
|
||
idx))
|
||
idx (or (->> [(string/index-of val \space current)
|
||
(string/index-of val \newline current)]
|
||
(remove nil?)
|
||
(apply min))
|
||
(count val))]
|
||
(safe-set-range-text! input "" current (inc idx)))))
|
||
|
||
#?(:cljs
|
||
(defn fix-open-external-with-shift!
|
||
[^js/MouseEvent e]
|
||
(when (and (.-shiftKey e) win32? (electron?)
|
||
(= (string/lower-case (.. e -target -nodeName)) "a")
|
||
(string/starts-with? (.. e -target -href) "file:"))
|
||
(.preventDefault e))))
|
||
|
||
(defn classnames
|
||
"Like react classnames utility:
|
||
|
||
```
|
||
[:div {:class (classnames [:a :b {:c true}])}
|
||
```
|
||
"
|
||
[args]
|
||
(into #{} (mapcat
|
||
#(if (map? %)
|
||
(for [[k v] %]
|
||
(when v (name k)))
|
||
(when-not (nil? %) [(name %)]))
|
||
args)))
|
||
|
||
#?(:cljs
|
||
(defn- get-dom-top
|
||
[node]
|
||
(when node
|
||
(gobj/get (.getBoundingClientRect node) "top"))))
|
||
|
||
#?(:cljs
|
||
(defn sort-by-height
|
||
[elements]
|
||
(sort (fn [x y]
|
||
(< (get-dom-top x) (get-dom-top y)))
|
||
(remove nil? elements))))
|
||
|
||
#?(:cljs
|
||
(defn calc-delta-rect-offset
|
||
[^js/HTMLElement target ^js/HTMLElement container]
|
||
(let [target-rect (bean/->clj (.toJSON (.getBoundingClientRect target)))
|
||
viewport-rect {:width (.-clientWidth container)
|
||
:height (.-clientHeight container)}]
|
||
|
||
{:y (- (:height viewport-rect) (:bottom target-rect))
|
||
:x (- (:width viewport-rect) (:right target-rect))})))
|
||
|
||
(def regex-char-esc-smap
|
||
(let [esc-chars "{}[]()&^%$#!?*.+|\\"]
|
||
(zipmap esc-chars
|
||
(map #(str "\\" %) esc-chars))))
|
||
|
||
(defn regex-escape
|
||
"Escape all regex meta chars in text."
|
||
[text]
|
||
(string/join (replace regex-char-esc-smap text)))
|
||
|
||
(comment
|
||
(re-matches (re-pattern (regex-escape "$u^8(d)+w.*[dw]d?")) "$u^8(d)+w.*[dw]d?"))
|
||
|
||
#?(:cljs
|
||
(defn meta-key? [e]
|
||
(if mac?
|
||
(gobj/get e "metaKey")
|
||
(gobj/get e "ctrlKey"))))
|
||
|
||
#?(:cljs
|
||
(defn shift-key? [e]
|
||
(gobj/get e "shiftKey")))
|
||
|
||
#?(:cljs
|
||
(defn right-click?
|
||
[e]
|
||
(let [which (gobj/get e "which")
|
||
button (gobj/get e "button")]
|
||
(or (= which 3)
|
||
(= button 2)))))
|
||
|
||
(def keyboard-height (atom nil))
|
||
|
||
#?(:cljs
|
||
(defn scroll-editor-cursor
|
||
([el] (scroll-editor-cursor el true))
|
||
([^js/HTMLElement el start?]
|
||
(when (and el (mobile?)
|
||
;; start? selection
|
||
(or (not start?) (zero? (get-selection-start el))))
|
||
(when-let [scroll-node (app-scroll-container-node el)]
|
||
(let [scroll-top' (.-scrollTop scroll-node)
|
||
vw-height (if (mobile-util/native-platform?)
|
||
(- (.-height js/window.screen) (or @keyboard-height 312))
|
||
(or (.-height js/window.visualViewport)
|
||
(.-clientHeight js/document.documentElement)))
|
||
^js box-rect (.getBoundingClientRect el)
|
||
box-top (.-top box-rect)
|
||
top-offset 84
|
||
inset (- box-top (- vw-height top-offset))]
|
||
(when (> inset 0)
|
||
(js/setTimeout
|
||
#(set! (.-scrollTop scroll-node)
|
||
(+ scroll-top' inset (if (false? start?) 96 64))) 16))))))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn breakpoint?
|
||
[size]
|
||
(< (.-offsetWidth js/document.documentElement) size))
|
||
|
||
(defn sm-breakpoint?
|
||
[] (breakpoint? 640))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn goog-event?
|
||
[^js e]
|
||
(and e (fn? (gobj/get e "getBrowserEvent"))))
|
||
|
||
(defn goog-event-is-composing?
|
||
"Check if keydown event is a composing (IME) event.
|
||
Ignore the IME process by default."
|
||
([^js e]
|
||
(goog-event-is-composing? e false))
|
||
([^js e include-process?]
|
||
(when (goog-event? e)
|
||
(let [event-composing? (some-> (.getBrowserEvent e) (.-isComposing))]
|
||
(if include-process?
|
||
(or event-composing?
|
||
(= (gobj/get e "keyCode") 229)
|
||
(= (gobj/get e "key") "Process"))
|
||
event-composing?)))))))
|
||
|
||
#?(:cljs
|
||
(defn native-event-is-composing?
|
||
"Check if onchange event of Input is a composing (IME) event.
|
||
Always ignore the IME process."
|
||
[^js e]
|
||
(when-let [^js native-event
|
||
(and e (cond
|
||
(goog-event? e)
|
||
(.getBrowserEvent e)
|
||
|
||
(js-in "_reactName" e)
|
||
(.-nativeEvent e)
|
||
|
||
:else e))]
|
||
(.-isComposing native-event))))
|
||
|
||
#?(:cljs
|
||
(defn open-url
|
||
[url]
|
||
(let [route? (or (string/starts-with? url
|
||
(string/replace js/location.href js/location.hash ""))
|
||
(string/starts-with? url "#"))]
|
||
(if (and (not route?) (electron?))
|
||
(js/window.apis.openExternal url)
|
||
(set! (.-href js/window.location) url)))))
|
||
|
||
(defn collapsed?
|
||
[block]
|
||
(:block/collapsed? block))
|
||
|
||
;; https://stackoverflow.com/questions/32511405/how-would-time-ago-function-implementation-look-like-in-clojure
|
||
#?(:cljs
|
||
(defn human-time
|
||
"time: inst-ms or js/Date"
|
||
[time & {:keys [ago? after?]
|
||
:or {ago? true
|
||
after? false}}]
|
||
(let [ago? (if after? false ago?)
|
||
units [{:name "second" :limit 60 :in-second 1}
|
||
{:name "minute" :limit 3600 :in-second 60}
|
||
{:name "hour" :limit 86400 :in-second 3600}
|
||
{:name "day" :limit 604800 :in-second 86400}
|
||
{:name "week" :limit 2629743 :in-second 604800}
|
||
{:name "month" :limit 31556926 :in-second 2629743}
|
||
{:name "year" :limit js/Number.MAX_SAFE_INTEGER :in-second 31556926}]
|
||
time' (if (instance? js/Date time) time (js/Date. time))
|
||
now (t/now)
|
||
diff (t/in-seconds (if ago? (t/interval time' now) (t/interval now time')))]
|
||
(if (< diff 5)
|
||
(if ago? "just now" (str diff "seconds"))
|
||
(let [unit (first (drop-while #(or (>= diff (:limit %))
|
||
(not (:limit %)))
|
||
units))]
|
||
(-> (/ diff (:in-second unit))
|
||
Math/floor
|
||
int
|
||
(#(str % " " (:name unit) (when (> % 1) "s")
|
||
(when ago? " ago")
|
||
(when after? " later")))))))))
|
||
|
||
#?(:cljs
|
||
(def JS_ROOT
|
||
(when-not node-test?
|
||
"./js")))
|
||
|
||
#?(:cljs
|
||
(defn js-load$
|
||
[url]
|
||
(p/create
|
||
(fn [resolve]
|
||
(load url resolve)))))
|
||
|
||
#?(:cljs
|
||
(defn css-load$
|
||
([url] (css-load$ url nil))
|
||
([url id]
|
||
(p/create
|
||
(fn [resolve reject]
|
||
(let [id (str "css-load-" (or id url))]
|
||
(if-not (gdom/getElement id)
|
||
(let [^js link (js/document.createElement "link")]
|
||
(set! (.-id link) id)
|
||
(set! (.-rel link) "stylesheet")
|
||
(set! (.-href link) url)
|
||
(set! (.-onload link) resolve)
|
||
(set! (.-onerror link) reject)
|
||
(.append (.-head js/document) link))
|
||
(resolve))))))))
|
||
|
||
#?(:cljs
|
||
(defn image-blob->png
|
||
[blob cb]
|
||
(let [image (js/Image.)
|
||
off-canvas (js/document.createElement "canvas")
|
||
data-url (js/URL.createObjectURL blob)
|
||
ctx (.getContext off-canvas "2d")]
|
||
(set! (.-onload image)
|
||
#(let [width (.-width image)
|
||
height (.-height image)]
|
||
(set! (.-width off-canvas) width)
|
||
(set! (.-height off-canvas) height)
|
||
(.drawImage ctx image 0 0 width height)
|
||
(.toBlob off-canvas cb)))
|
||
(set! (.-src image) data-url))))
|
||
|
||
#?(:cljs
|
||
(def native-clipboard (gobj/get CapacitorClipboard "Clipboard")))
|
||
|
||
#?(:cljs
|
||
(do
|
||
;; Helper: Blob -> data URL (returns a JS Promise)
|
||
(defn blob->data-url [blob]
|
||
(js/Promise.
|
||
(fn [resolve reject]
|
||
(let [reader (js/FileReader.)]
|
||
(set! (.-onload reader)
|
||
(fn [_e]
|
||
;; result is a "data:<mime>;base64,..." string
|
||
(resolve (.-result reader))))
|
||
(set! (.-onerror reader)
|
||
(fn [_e]
|
||
(reject (.-error reader))))
|
||
(.readAsDataURL reader blob)))))
|
||
|
||
(defn write-blob-to-clipboard [blob]
|
||
(if native-clipboard
|
||
;; 1) Native (Capacitor) path – expects a data URL
|
||
(-> (blob->data-url blob)
|
||
(.then (fn [data-url]
|
||
;; Your Capacitor plugin signature: { image: <data-url> }
|
||
(.write native-clipboard #js {:image data-url})))
|
||
(.then (fn []
|
||
(js/console.log "Copied via native clipboard")))
|
||
(.catch (fn [err]
|
||
(js/console.error "Native clipboard failed" err))))
|
||
|
||
;; 2) Web Clipboard API path (desktop browsers etc.)
|
||
(let [item (js/ClipboardItem.
|
||
(js-obj (.-type blob) blob))]
|
||
(-> (.write (.-clipboard js/navigator) (array item))
|
||
(.then (fn []
|
||
(js/console.log "Copied via web clipboard")))
|
||
(.catch (fn [err]
|
||
(js/console.error "Web clipboard failed" err)))))))))
|
||
|
||
#?(:cljs
|
||
(defn copy-image-to-clipboard
|
||
[src]
|
||
(-> (js/fetch src)
|
||
(.then (fn [data]
|
||
(-> (.blob data)
|
||
(.then (fn [blob]
|
||
(if (= (.-type blob) "image/png")
|
||
(write-blob-to-clipboard blob)
|
||
(image-blob->png blob write-blob-to-clipboard))))
|
||
(.catch js/console.error)))))))
|
||
|
||
(defn memoize-last
|
||
"Different from core.memoize, it only cache the last result.
|
||
Returns a memoized version of a referentially transparent function. The
|
||
memoized version of the function cache the the last result, and replay when calls
|
||
with the same arguments, or update cache when with different arguments."
|
||
[f]
|
||
(let [last-mem (atom nil)
|
||
last-args (atom nil)]
|
||
(fn [& args]
|
||
(if (or (nil? @last-mem)
|
||
(not= @last-args args))
|
||
(let [ret (apply f args)]
|
||
(reset! last-args args)
|
||
(reset! last-mem ret)
|
||
ret)
|
||
@last-mem))))
|
||
|
||
#?(:cljs
|
||
(do
|
||
(defn <app-wake-up-from-sleep-loop
|
||
"start a async/go-loop to check the app awake from sleep.
|
||
Use (async/tap `pubsub/app-wake-up-from-sleep-mult`) to receive messages.
|
||
Arg *stop: atom, reset to true to stop the loop"
|
||
[*stop]
|
||
(let [*last-activated-at (volatile! (tc/to-epoch (t/now)))]
|
||
(async/go-loop []
|
||
(if @*stop
|
||
(println :<app-wake-up-from-sleep-loop :stop)
|
||
(let [now-epoch (tc/to-epoch (t/now))]
|
||
(when (< @*last-activated-at (- now-epoch 10))
|
||
(async/>! pubsub/app-wake-up-from-sleep-ch {:last-activated-at @*last-activated-at :now now-epoch}))
|
||
(vreset! *last-activated-at now-epoch)
|
||
(async/<! (async/timeout 5000))
|
||
(recur))))))))
|
||
|
||
;; from rum
|
||
#?(:cljs
|
||
(def schedule
|
||
(or (and (exists? js/window)
|
||
(or js/window.requestAnimationFrame
|
||
js/window.webkitRequestAnimationFrame
|
||
js/window.mozRequestAnimationFrame
|
||
js/window.msRequestAnimationFrame))
|
||
#(js/setTimeout % 16))))
|
||
|
||
#?(:cljs
|
||
(defn parse-params
|
||
"Parse URL parameters in hash(fragment) into a hashmap"
|
||
[]
|
||
(if node-test?
|
||
{}
|
||
(when-let [fragment (-> js/window
|
||
(.-location)
|
||
(.-hash)
|
||
not-empty)]
|
||
(when (string/starts-with? fragment "#/?")
|
||
(->> (subs fragment 2)
|
||
(new js/URLSearchParams)
|
||
(seq)
|
||
(js->clj)
|
||
(into {})
|
||
(walk/keywordize-keys)))))))
|
||
|
||
#?(:cljs
|
||
(defn get-cm-instance
|
||
[^js target]
|
||
(when target
|
||
(some-> target (.querySelector ".CodeMirror") (.-CodeMirror)))))
|
||
|
||
#?(:cljs
|
||
(defn get-keep-keyboard-input-el
|
||
([] (get-keep-keyboard-input-el ""))
|
||
([t]
|
||
(js/document.getElementById (str "keep-keyboard-open-input" t)))))
|
||
|
||
#?(:cljs
|
||
(defn mobile-keep-keyboard-open
|
||
([]
|
||
(mobile-keep-keyboard-open true))
|
||
([schedule?]
|
||
(when (mobile?)
|
||
(let [f #(when-let [node (or (get-keep-keyboard-input-el "in-modal")
|
||
(get-keep-keyboard-input-el))]
|
||
(.focus node))]
|
||
(if schedule? (schedule f) (f)))))))
|
||
|
||
#?(:cljs
|
||
(defn rtc-test?
|
||
[]
|
||
(string/includes? js/window.location.search "?rtc-test=true")))
|