Add excalidraw-embed

pull/645/head
Tienson Qin 2020-07-01 23:37:36 +08:00
parent 722abecaaa
commit a3eec9ab63
9 changed files with 3468 additions and 281 deletions

View File

@ -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",

View File

@ -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)))

View File

@ -90,3 +90,5 @@
([format n]
(let [heading-pattern (get-heading-pattern format)]
(apply str (repeat n heading-pattern)))))
(defonce default-draw-directory "draw")

View File

@ -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"})))

View File

@ -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)

View File

@ -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)))

View File

@ -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"}]]])

View File

@ -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

File diff suppressed because it is too large Load Diff