Files
logseq/src/main/frontend/util.cljc
2025-11-27 13:10:10 +08:00

1508 lines
46 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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")))