mirror of https://github.com/logseq/logseq
Add excalidraw-embed
parent
722abecaaa
commit
a3eec9ab63
|
@ -19,6 +19,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"diff": "^4.0.2",
|
||||
"excalidraw": "^0.3.1",
|
||||
"localforage": "^1.7.3",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
|
|
|
@ -1,14 +1,158 @@
|
|||
(ns frontend.components.draw
|
||||
(:require [rum.core :as rum]
|
||||
[goog.object :as gobj]))
|
||||
[goog.object :as gobj]
|
||||
["excalidraw" :as Excalidraw]
|
||||
[frontend.rum :as r]
|
||||
[frontend.util :as util]
|
||||
[frontend.mixins :as mixins]
|
||||
[frontend.storage :as storage]
|
||||
[frontend.components.svg :as svg]
|
||||
[cljs-bean.core :as bean]
|
||||
[dommy.core :as d]
|
||||
[clojure.string :as string]
|
||||
[frontend.date :as date]
|
||||
[frontend.handler :as handler]
|
||||
[frontend.ui :as ui]))
|
||||
|
||||
(rum/defc draw
|
||||
(defonce draw-title-key :draw/latest-title)
|
||||
(defonce draw-data-key :draw/latest-data)
|
||||
|
||||
(defonce *files (atom nil))
|
||||
(defonce *current-file (atom nil))
|
||||
(defonce *current-title (atom (storage/get draw-title-key)))
|
||||
(defonce *file-loading? (atom nil))
|
||||
(defonce *data (atom nil))
|
||||
|
||||
;; TODO: lazy loading
|
||||
(defonce excalidraw (r/adapt-class (gobj/get Excalidraw "default")))
|
||||
|
||||
(defn save-excalidraw!
|
||||
[state event]
|
||||
(when-let [data (storage/get-json draw-data-key)]
|
||||
(let [[option] (:rum/args state)
|
||||
file (get option :file
|
||||
(let [title (storage/get draw-title-key)
|
||||
title (if (not (string/blank? title))
|
||||
(string/lower-case (string/replace title " " "-"))
|
||||
"untitled")]
|
||||
(str "excalidraw-" (date/get-date-time-string-2) "-" title "-" (util/rand-str 4) ".json")))]
|
||||
(handler/save-excalidraw! file data))))
|
||||
|
||||
(defn- clear-canvas!
|
||||
[]
|
||||
[:div#draw.relative
|
||||
[:iframe {:title "Excalidraw"
|
||||
:src "https://excalidraw.com"}]
|
||||
[:div.absolute.bottom-15.left-2.hidden.md:block
|
||||
[:a {:on-click (fn [] (.back (gobj/get js/window "history")))}
|
||||
[:img.h-8.w-auto
|
||||
{:alt "Logseq"
|
||||
:src "/static/img/white_logo.png"}]]]])
|
||||
(when-let [canvas (d/by-id "canvas")]
|
||||
(let [context (.getContext canvas "2d")]
|
||||
(.clearRect context 0 0 (gobj/get canvas "width") (gobj/get canvas "height"))
|
||||
(set! (.-fillStyle context) "#FFF")
|
||||
(.fillRect context 0 0 (gobj/get canvas "width") (gobj/get canvas "height")))))
|
||||
|
||||
(rum/defc files < rum/reactive
|
||||
{:init (fn [state]
|
||||
(handler/get-all-excalidraw-files
|
||||
(fn [files]
|
||||
(reset! *files files)))
|
||||
state)}
|
||||
[]
|
||||
(let [files (rum/react *files)
|
||||
current-file (rum/react *current-file)]
|
||||
(when (seq files)
|
||||
(ui/dropdown-with-links
|
||||
(fn [{:keys [toggle-fn]}]
|
||||
[:a#file-switch.mr-3 {:on-click toggle-fn}
|
||||
[:span.text-sm "Change file"]
|
||||
[:span.dropdown-caret.ml-1 {:style {:border-top-color "#6b7280"}}]])
|
||||
(mapv
|
||||
(fn [file]
|
||||
{:title (-> file
|
||||
(string/replace-first "excalidraw-" ""))
|
||||
:options {:on-click
|
||||
(fn []
|
||||
(reset! *current-file file)
|
||||
(reset! *current-title file))}})
|
||||
files)
|
||||
(util/hiccup->class
|
||||
"origin-top-right.absolute.left-0.mt-2.rounded-md.shadow-lg.whitespace-no-wrap")))))
|
||||
|
||||
;; TODO: how to prevent default save action on excalidraw?
|
||||
(rum/defcs draw-inner < rum/reactive
|
||||
(mixins/keyboard-mixin "Ctrl+s" save-excalidraw!)
|
||||
{:init (fn [state]
|
||||
(let [[option] (:rum/args state)
|
||||
file (or @*current-file (:file option))]
|
||||
(when file
|
||||
(reset! *current-title file))
|
||||
(if file
|
||||
(do
|
||||
(reset! *file-loading? true)
|
||||
(handler/load-excalidraw-file
|
||||
file
|
||||
(fn [data]
|
||||
(reset! *data (js/JSON.parse data))
|
||||
(reset! *file-loading? false))))
|
||||
(when-let [data (storage/get-json draw-data-key)]
|
||||
;; TODO: keep this for history undo
|
||||
(reset! *data (remove #(gobj/get % "isDeleted") data))))
|
||||
(assoc state
|
||||
::layout (atom [js/window.innerWidth js/window.innerHeight]))))
|
||||
:did-mount (fn [state]
|
||||
(when-let [section (first (d/by-tag "section"))]
|
||||
(when (= "canvasActions-title" (d/attr section "aria-labelledby"))
|
||||
(d/set-style! section "margin-top" "48px")))
|
||||
state)
|
||||
:will-unmount (fn [state]
|
||||
(reset! *data nil)
|
||||
(clear-canvas!)
|
||||
state)}
|
||||
[state option]
|
||||
(let [data (rum/react *data)
|
||||
loading? (rum/react *file-loading?)
|
||||
current-title (rum/react *current-title)
|
||||
layout (get state ::layout)
|
||||
[width height] (rum/react layout)
|
||||
options (bean/->js {:zenModeEnabled true
|
||||
:viewBackgroundColor "#FFF"})]
|
||||
[:div.draw.relative
|
||||
(excalidraw
|
||||
(cond->
|
||||
{:width (get option :width width)
|
||||
:height (get option :height width)
|
||||
:on-resize (fn []
|
||||
(reset! layout [js/window.innerWidth js/window.innerHeight]))
|
||||
|
||||
:on-change (get option :on-change
|
||||
(fn [elements _state]
|
||||
(storage/set-json draw-data-key elements)))
|
||||
:options options
|
||||
:user (bean/->js {:name (get option :user-name (util/unique-id))})
|
||||
:on-username-change (fn []
|
||||
(prn "username changed"))}
|
||||
data
|
||||
(assoc :initial-data data)))
|
||||
[:div.absolute.top-4.left-4.hidden.md:block
|
||||
[:div.flex.flex-row.items-center
|
||||
[:a.mr-3 {:on-click (fn [] (.back (gobj/get js/window "history")))
|
||||
:title "Back to logseq"}
|
||||
(svg/logo)]
|
||||
|
||||
(files)
|
||||
|
||||
[:input#draw-title.focus:outline-none.ml-1.font-medium
|
||||
{:style {:border "none"
|
||||
:max-width 300}
|
||||
:placeholder "Untitled"
|
||||
:auto-complete "off"
|
||||
:on-change (fn [e]
|
||||
(when-let [value (util/evalue e)]
|
||||
(storage/set draw-title-key value)
|
||||
(reset! *current-title value)))
|
||||
:value (or current-title "")}]
|
||||
|
||||
(when loading?
|
||||
[:span.lds-dual-ring.ml-3])]]]))
|
||||
|
||||
(rum/defc draw < rum/reactive
|
||||
[option]
|
||||
(let [current-file (rum/react *current-file)
|
||||
key (or (and current-file (str "draw-" current-file))
|
||||
"draw-with-no-file")]
|
||||
(rum/with-key (draw-inner option) key)))
|
||||
|
|
|
@ -90,3 +90,5 @@
|
|||
([format n]
|
||||
(let [heading-pattern (get-heading-pattern format)]
|
||||
(apply str (repeat n heading-pattern)))))
|
||||
|
||||
(defonce default-draw-directory "draw")
|
||||
|
|
|
@ -38,6 +38,11 @@
|
|||
(defn get-date-time-string [date-time]
|
||||
(tf/unparse custom-formatter date-time))
|
||||
|
||||
(def custom-formatter-2 (tf/formatter "yyyy-MM-dd-HH:mm:ss"))
|
||||
|
||||
(defn get-date-time-string-2 []
|
||||
(tf/unparse custom-formatter-2 (tl/local-now)))
|
||||
|
||||
(defn get-weekday
|
||||
[date]
|
||||
(.toLocaleString date "en-us" (clj->js {:weekday "long"})))
|
||||
|
|
|
@ -1401,8 +1401,57 @@
|
|||
(history/redo! k re-render-root!))
|
||||
(default-redo))))
|
||||
|
||||
(comment
|
||||
;; excalidraw
|
||||
(defn save-excalidraw!
|
||||
[file data]
|
||||
(let [path (str config/default-draw-directory "/" file)
|
||||
repo (state/get-current-repo)]
|
||||
(when repo
|
||||
(let [repo-dir (util/get-repo-dir repo)]
|
||||
(p/let [_ (-> (fs/mkdir (str repo-dir (str "/" config/default-draw-directory)))
|
||||
(p/catch (fn [e])))]
|
||||
(util/p-handle
|
||||
(fs/write-file repo-dir path (js/JSON.stringify data))
|
||||
(fn [_]
|
||||
(util/p-handle
|
||||
(git-add repo path)
|
||||
(fn [_]
|
||||
(git/commit repo (str "Save " file)))))
|
||||
(fn [error]
|
||||
(prn "Write file failed, path: " path ", data: " data)
|
||||
(js/console.dir error))))))))
|
||||
|
||||
(defn get-all-excalidraw-files
|
||||
[ok-handler]
|
||||
(when-let [repo (state/get-current-repo)]
|
||||
(let [dir (str "/"
|
||||
(util/get-repo-dir repo)
|
||||
"/"
|
||||
config/default-draw-directory)]
|
||||
(util/p-handle
|
||||
(fs/readdir dir)
|
||||
(fn [files]
|
||||
(let [files (-> (filter #(and (string/starts-with? % "excalidraw-")
|
||||
(string/ends-with? % ".json")) files)
|
||||
(distinct)
|
||||
(sort)
|
||||
(reverse))]
|
||||
(ok-handler files)))
|
||||
(fn [_error]
|
||||
nil)))))
|
||||
|
||||
(defn load-excalidraw-file
|
||||
[file ok-handler]
|
||||
(when-let [repo (state/get-current-repo)]
|
||||
(util/p-handle
|
||||
(load-file repo (str config/default-draw-directory "/" file))
|
||||
(fn [content]
|
||||
(ok-handler content))
|
||||
(fn [error]
|
||||
(prn "Error loading " file ": "
|
||||
error)))))
|
||||
|
||||
(comment
|
||||
(defn debug-latest-commits
|
||||
[]
|
||||
(get-latest-commit (state/get-current-repo)
|
||||
|
|
|
@ -11,6 +11,15 @@
|
|||
[key value]
|
||||
(.setItem ^js js/localStorage (name key) (pr-str value)))
|
||||
|
||||
(defn get-json
|
||||
[key]
|
||||
(when-let [value (.getItem js/localStorage (name key))]
|
||||
(js/JSON.parse value)))
|
||||
|
||||
(defn set-json
|
||||
[key value]
|
||||
(.setItem ^js js/localStorage (name key) (js/JSON.stringify value)))
|
||||
|
||||
(defn remove
|
||||
[key]
|
||||
(.removeItem ^js js/localStorage (name key)))
|
||||
|
|
|
@ -1,253 +0,0 @@
|
|||
(ns frontend.ui
|
||||
(:require [rum.core :as rum]
|
||||
[frontend.rum :as r]
|
||||
["react-transition-group" :refer [TransitionGroup CSSTransition]]
|
||||
["react-textarea-autosize" :as TextareaAutosize]
|
||||
[frontend.util :as util]
|
||||
[frontend.mixins :as mixins]
|
||||
[frontend.state :as state]
|
||||
[clojure.string :as string]
|
||||
[goog.object :as gobj]
|
||||
[goog.dom :as gdom]
|
||||
[medley.core :as medley]
|
||||
[frontend.ui.date-picker]))
|
||||
|
||||
(defonce transition-group (r/adapt-class TransitionGroup))
|
||||
(defonce css-transition (r/adapt-class CSSTransition))
|
||||
(defonce textarea (r/adapt-class (gobj/get TextareaAutosize "default")))
|
||||
(rum/defc dropdown-content-wrapper [state content class]
|
||||
(let [class (or class
|
||||
(util/hiccup->class "origin-top-right.absolute.right-0.mt-2.w-48.rounded-md.shadow-lg"))]
|
||||
[:div
|
||||
{:class (str class " "
|
||||
(case state
|
||||
"entering" "transition ease-out duration-100 transform opacity-0 scale-95"
|
||||
"entered" "transition ease-out duration-100 transform opacity-100 scale-100"
|
||||
"exiting" "transition ease-in duration-75 transform opacity-100 scale-100"
|
||||
"exited" "transition ease-in duration-75 transform opacity-0 scale-95"))}
|
||||
content]))
|
||||
|
||||
;; public exports
|
||||
(rum/defcs dropdown < (mixins/modal)
|
||||
[state content-fn modal-content-fn modal-class]
|
||||
(let [{:keys [open? toggle-fn]} state
|
||||
modal-content (modal-content-fn state)]
|
||||
[:div.ml-1.relative {:style {:z-index 999}}
|
||||
(content-fn state)
|
||||
(css-transition
|
||||
{:in @open? :timeout 0}
|
||||
(fn [dropdown-state]
|
||||
(when @open?
|
||||
(dropdown-content-wrapper dropdown-state modal-content modal-class))))]))
|
||||
|
||||
(rum/defc menu-link
|
||||
[options child]
|
||||
[:a.block.px-4.py-2.text-sm.text-gray-700.transition.ease-in-out.duration-150.cursor.menu-link.overflow-hidden
|
||||
options
|
||||
child])
|
||||
|
||||
(rum/defc dropdown-with-links
|
||||
([content-fn links]
|
||||
(dropdown-with-links content-fn links nil))
|
||||
([content-fn links modal-class]
|
||||
(dropdown
|
||||
content-fn
|
||||
(fn [{:keys [close-fn] :as state}]
|
||||
[:div.py-1.rounded-md.shadow-xs.bg-base-3
|
||||
(for [{:keys [options title]} links]
|
||||
(let [new-options
|
||||
(assoc options
|
||||
:on-click (fn []
|
||||
(when-let [on-click-fn (:on-click options)]
|
||||
(on-click-fn))
|
||||
(close-fn)
|
||||
))]
|
||||
(menu-link
|
||||
(merge {:key (cljs.core/random-uuid)}
|
||||
new-options)
|
||||
title)))])
|
||||
modal-class)))
|
||||
|
||||
(rum/defc button
|
||||
[text & {:keys [background on-click href]
|
||||
:as option}]
|
||||
(let [class "inline-flex.items-center.px-3.py-2.border.border-transparent.text-sm.leading-4.font-medium.rounded-md.text-white.bg-indigo-600.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.active:bg-indigo-700.transition.ease-in-out.duration-150.mt-1"
|
||||
class (if background (string/replace class "indigo" background) class)]
|
||||
[:button
|
||||
(merge
|
||||
{:type "button"
|
||||
:class (util/hiccup->class class)}
|
||||
(dissoc option :background))
|
||||
text]))
|
||||
|
||||
(rum/defc notification-content
|
||||
[state content status]
|
||||
(when (and content status)
|
||||
(let [[color-class svg]
|
||||
(case status
|
||||
:success
|
||||
["text-gray-900"
|
||||
[:svg.h-6.w-6.text-green-400
|
||||
{:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"}
|
||||
[:path
|
||||
{:d "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
:stroke-width "2",
|
||||
:stroke-linejoin "round",
|
||||
:stroke-linecap "round"}]]]
|
||||
["text-red-500"
|
||||
[:svg.h-6.w-6.text-red-500
|
||||
{:viewBox "0 0 20 20", :fill "currentColor"}
|
||||
[:path
|
||||
{:clip-rule "evenodd",
|
||||
:d
|
||||
"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z",
|
||||
:fill-rule "evenodd"}]]])]
|
||||
[:div.fixed.inset-0.flex.items-end.justify-center.px-4.py-6.pointer-events-none.sm:p-6.sm:items-start.sm:justify-end {:style {:top "3.2em"}}
|
||||
[:div.max-w-sm.w-full.bg-base-3.shadow-lg.rounded-lg.pointer-events-auto
|
||||
{:class (case state
|
||||
"entering" "transition ease-out duration-300 transform opacity-0 translate-y-2 sm:translate-x-0"
|
||||
"entered" "transition ease-out duration-300 transform translate-y-0 opacity-100 sm:translate-x-0"
|
||||
"exiting" "transition ease-in duration-100 opacity-100"
|
||||
"exited" "transition ease-in duration-100 opacity-0")}
|
||||
[:div.rounded-lg.shadow-xs.overflow-hidden
|
||||
[:div.p-4
|
||||
[:div.flex.items-start
|
||||
[:div.flex-shrink-0
|
||||
svg]
|
||||
[:div.ml-3.w-0.flex-1.pt-0.5
|
||||
[:div.text-sm.leading-5.font-medium {:style {:margin 0}
|
||||
:class color-class}
|
||||
content]]
|
||||
[:div.ml-4.flex-shrink-0.flex
|
||||
[:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150
|
||||
{:on-click (fn []
|
||||
(swap! state/state assoc :notification/show? false))}
|
||||
[:svg.h-5.w-5
|
||||
{:fill "currentColor", :viewBox "0 0 20 20"}
|
||||
[:path
|
||||
{:clip-rule "evenodd",
|
||||
:d
|
||||
"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z",
|
||||
:fill-rule "evenodd"}]]]]]]]]])))
|
||||
|
||||
(rum/defc notification < rum/reactive
|
||||
[]
|
||||
(let [show? (state/sub :notification/show?)
|
||||
status (state/sub :notification/status)
|
||||
content (state/sub :notification/content)]
|
||||
(css-transition
|
||||
{:in show? :timeout 100}
|
||||
(fn [state]
|
||||
(notification-content state content status)))))
|
||||
|
||||
(rum/defc checkbox
|
||||
[option]
|
||||
[:input.form-checkbox.h-4.w-4.transition.duration-150.ease-in-out
|
||||
(merge {:type "checkbox"} option)])
|
||||
|
||||
(rum/defc badge
|
||||
[text option]
|
||||
[:span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.leading-4.bg-purple-100.text-purple-800
|
||||
option
|
||||
text])
|
||||
|
||||
;; scroll
|
||||
(defn main-node
|
||||
[]
|
||||
(gdom/getElement "main-content"))
|
||||
|
||||
(defn get-scroll-top []
|
||||
(.-scrollTop (main-node)))
|
||||
|
||||
(defn on-scroll
|
||||
[on-load]
|
||||
(let [node (main-node)
|
||||
full-height (gobj/get node "scrollHeight")
|
||||
scroll-top (gobj/get node "scrollTop")
|
||||
client-height (gobj/get node "clientHeight")
|
||||
bottom-reached? (<= (- full-height scroll-top client-height) 700)]
|
||||
(when bottom-reached?
|
||||
(on-load))))
|
||||
|
||||
(defn attach-listeners
|
||||
"Attach scroll and resize listeners."
|
||||
[state]
|
||||
(let [opts (-> state :rum/args second)
|
||||
debounced-on-scroll (util/debounce 500 #(on-scroll (:on-load opts)))]
|
||||
(mixins/listen state (main-node) :scroll debounced-on-scroll)))
|
||||
|
||||
(rum/defcs infinite-list <
|
||||
(mixins/event-mixin attach-listeners)
|
||||
"Render an infinite list."
|
||||
[state body {:keys [on-load]
|
||||
:as opts}]
|
||||
body)
|
||||
|
||||
(rum/defcs auto-complete <
|
||||
(rum/local 0 ::current-idx)
|
||||
(mixins/event-mixin
|
||||
(fn [state]
|
||||
(mixins/on-key-down
|
||||
state
|
||||
{
|
||||
;; up
|
||||
38 (fn [_ e]
|
||||
(let [current-idx (get state ::current-idx)]
|
||||
(util/stop e)
|
||||
(when (>= @current-idx 1)
|
||||
(swap! current-idx dec))))
|
||||
;; down
|
||||
40 (fn [state e]
|
||||
(let [current-idx (get state ::current-idx)
|
||||
matched (first (:rum/args state))]
|
||||
(util/stop e)
|
||||
(let [total (count matched)]
|
||||
(if (>= @current-idx (dec total))
|
||||
(reset! current-idx 0)
|
||||
(swap! current-idx inc)))))
|
||||
|
||||
;; enter
|
||||
13 (fn [state e]
|
||||
(util/stop e)
|
||||
(let [[matched {:keys [on-chosen on-enter]}] (:rum/args state)]
|
||||
(let [current-idx (get state ::current-idx)]
|
||||
(if (and (seq matched)
|
||||
(> (count matched)
|
||||
@current-idx))
|
||||
(on-chosen (nth matched @current-idx) false)
|
||||
(and on-enter (on-enter state))))))}
|
||||
nil)))
|
||||
[state matched {:keys [on-chosen
|
||||
on-enter
|
||||
empty-div
|
||||
item-render
|
||||
class]}]
|
||||
(let [current-idx (get state ::current-idx)]
|
||||
[:div.py-1.rounded-md.shadow-xs.bg-base-3 {:class class}
|
||||
(if (seq matched)
|
||||
(for [[idx item] (medley/indexed matched)]
|
||||
(rum/with-key
|
||||
(menu-link
|
||||
{:style {:padding "6px"}
|
||||
:class (when (= @current-idx idx)
|
||||
"bg-base-2")
|
||||
:tab-index 0
|
||||
:on-click (fn [e]
|
||||
(util/stop e)
|
||||
(on-chosen item))}
|
||||
(if item-render (item-render item) item))
|
||||
idx))
|
||||
(when empty-div
|
||||
empty-div))]))
|
||||
|
||||
(def datepicker frontend.ui.date-picker/date-picker)
|
||||
|
||||
(rum/defc toggle
|
||||
[on? on-click]
|
||||
[:a {:on-click on-click}
|
||||
[:span.relative.inline-block.flex-shrink-0.h-6.w-11.border-2.border-transparent.rounded-full.cursor-pointer.transition-colors.ease-in-out.duration-200.focus:outline-none.focus:shadow-outline
|
||||
{:aria-checked "false", :tabindex "0", :role "checkbox"
|
||||
:class (if on? "bg-indigo-600" "bg-gray-200")}
|
||||
[:span.inline-block.h-5.w-5.rounded-full.bg-white.shadow.transform.transition.ease-in-out.duration-200
|
||||
{:class (if on? "translate-x-5" "translate-x-0")
|
||||
:aria-hidden "true"}]]])
|
|
@ -655,13 +655,14 @@
|
|||
(compare [v1 k1] [v2 k2])))))
|
||||
m))
|
||||
|
||||
(defn rand-str
|
||||
[n]
|
||||
(-> (.toString (js/Math.random) 36)
|
||||
(.substr 2 n)))
|
||||
|
||||
(defn unique-id
|
||||
[]
|
||||
(str
|
||||
(-> (.toString (js/Math.random) 36)
|
||||
(.substr 2 6))
|
||||
(-> (.toString (js/Math.random) 36)
|
||||
(.substr 2 3))))
|
||||
(str (rand-str 6) (rand-str 3)))
|
||||
|
||||
;; Get this from XRegExp("^\\pL+$")
|
||||
(def valid-tag-pattern
|
||||
|
|
3253
web/yarn.lock
3253
web/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue