mirror of
https://github.com/logseq/logseq.git
synced 2026-05-05 03:16:37 +00:00
1818 lines
66 KiB
Clojure
1818 lines
66 KiB
Clojure
(ns frontend.components.block
|
|
(:refer-clojure :exclude [range])
|
|
(:require [frontend.config :as config]
|
|
[cljs.core.match :refer-macros [match]]
|
|
[clojure.string :as string]
|
|
[frontend.util :as util]
|
|
[rum.core :as rum]
|
|
[frontend.state :as state]
|
|
[frontend.db :as db]
|
|
[dommy.core :as d]
|
|
[datascript.core :as dc]
|
|
[goog.dom :as gdom]
|
|
[frontend.handler.expand :as expand]
|
|
[frontend.components.svg :as svg]
|
|
[frontend.components.draw :as draw]
|
|
[frontend.components.datetime :as datetime-comp]
|
|
[frontend.ui :as ui]
|
|
[frontend.handler.editor :as editor-handler]
|
|
[frontend.handler.dnd :as dnd]
|
|
[frontend.handler.repeated :as repeated]
|
|
[goog.object :as gobj]
|
|
[medley.core :as medley]
|
|
[cljs.reader :as reader]
|
|
[frontend.util :as util :refer-macros [profile]]
|
|
[frontend.db-mixins :as db-mixins]
|
|
[frontend.extensions.latex :as latex]
|
|
[frontend.components.lazy-editor :as lazy-editor]
|
|
[frontend.extensions.highlight :as highlight]
|
|
[frontend.extensions.sci :as sci]
|
|
["/frontend/utils" :as utils]
|
|
[frontend.format.block :as block]
|
|
[clojure.walk :as walk]
|
|
[cljs-bean.core :as bean]
|
|
[frontend.handler.image :as image-handler]
|
|
[frontend.format.mldoc :as mldoc]
|
|
[frontend.text :as text]
|
|
[frontend.utf8 :as utf8]
|
|
[frontend.date :as date]
|
|
[frontend.security :as security]
|
|
[reitit.frontend.easy :as rfe]
|
|
[frontend.commands :as commands]))
|
|
|
|
(defn safe-read-string
|
|
[s]
|
|
(try
|
|
(reader/read-string s)
|
|
(catch js/Error e
|
|
(println "read-string error:")
|
|
(js/console.error e)
|
|
[:div.warning {:title "read-string failed"}
|
|
s])))
|
|
|
|
;; local state
|
|
(defonce *block-children
|
|
(atom {}))
|
|
|
|
(defonce *dragging?
|
|
(atom false))
|
|
(defonce *dragging-block
|
|
(atom nil))
|
|
(defonce *move-to-top?
|
|
(atom false))
|
|
|
|
;; TODO: Improve blocks grouped by pages
|
|
(defonce max-blocks-per-page 500)
|
|
(defonce virtual-list-scroll-step 450)
|
|
(defonce virtual-list-previous 50)
|
|
|
|
(defonce container-ids (atom {}))
|
|
(defonce container-idx (atom 0))
|
|
|
|
;; TODO:
|
|
;; add `key`
|
|
|
|
(defn- remove-nils
|
|
[col]
|
|
(remove nil? col))
|
|
|
|
(defn anchor-link
|
|
[s]
|
|
(.anchorLink js/window.Mldoc s))
|
|
|
|
(defn vec-cat
|
|
[& args]
|
|
(->> (apply concat args)
|
|
remove-nils
|
|
vec))
|
|
|
|
(defn ->elem
|
|
([elem items]
|
|
(->elem elem nil items))
|
|
([elem attrs items]
|
|
(let [elem (keyword elem)]
|
|
(if attrs
|
|
(vec
|
|
(cons elem
|
|
(cons attrs
|
|
(seq items))))
|
|
(vec
|
|
(cons elem
|
|
(seq items)))))))
|
|
|
|
(defn- join-lines
|
|
[l]
|
|
(string/trim (apply str l)))
|
|
|
|
(defn- string-of-url
|
|
[url]
|
|
(match url
|
|
(:or ["File" s] ["Search" s])
|
|
s
|
|
["Complex" m]
|
|
(let [{:keys [link protocol]} m]
|
|
(if (= protocol "file")
|
|
link
|
|
(str protocol ":" link)))))
|
|
|
|
(defn- get-file-absolute-path
|
|
[config path]
|
|
(let [path (string/replace path "file:" "")
|
|
block-id (:block/uuid config)
|
|
current-file (and block-id
|
|
(:file/path (:page/file (:block/page (db/entity [:block/uuid block-id])))))]
|
|
(when current-file
|
|
(let [parts (string/split current-file #"/")
|
|
parts-2 (string/split path #"/")
|
|
current-dir (string/join "/" (drop-last 1 parts))]
|
|
(cond
|
|
(util/starts-with? path "/")
|
|
path
|
|
|
|
(and (not (util/starts-with? path ".."))
|
|
(not (util/starts-with? path ".")))
|
|
(str current-dir "/" path)
|
|
|
|
:else
|
|
(let [parts (loop [acc []
|
|
parts (reverse parts)
|
|
col (reverse parts-2)]
|
|
(if (empty? col)
|
|
acc
|
|
(let [[part parts] (case (first col)
|
|
".."
|
|
[(first parts) (rest parts)]
|
|
"."
|
|
["" parts]
|
|
[(first col) (rest parts)])]
|
|
(recur (conj acc part)
|
|
parts
|
|
(rest col)))))
|
|
parts (remove #(string/blank? %) parts)]
|
|
(string/join "/" (reverse parts))))))))
|
|
|
|
;; TODO: safe encoding asciis
|
|
;; TODO: image link to another link
|
|
(defn image-link [config url href label]
|
|
(let [href (if (util/starts-with? href "http")
|
|
href
|
|
(get-file-absolute-path config href))]
|
|
[:img.rounded-sm.shadow-xl.mb-2.mt-2
|
|
{:class "object-contain object-center"
|
|
:loading "lazy"
|
|
:style {:max-height "24rem"}
|
|
;; :on-error (fn [])
|
|
:src href
|
|
:title (second (first label))}]))
|
|
|
|
(defn repetition-to-string
|
|
[[[kind] [duration] n]]
|
|
(let [kind (case kind
|
|
"Dotted" "."
|
|
"Plus" "+"
|
|
"DoublePlus" "++")]
|
|
(str kind n (string/lower-case (str (first duration))))))
|
|
|
|
(defn timestamp-to-string
|
|
[{:keys [active date time repetition wday active]}]
|
|
(let [{:keys [year month day]} date
|
|
{:keys [hour min]} time
|
|
[open close] (if active ["<" ">"] ["[" "]"])
|
|
repetition (if repetition
|
|
(str " " (repetition-to-string repetition))
|
|
"")
|
|
hour (if hour (util/zero-pad hour))
|
|
min (if min (util/zero-pad min))
|
|
time (cond
|
|
(and hour min)
|
|
(util/format " %s:%s" hour min)
|
|
hour
|
|
(util/format " %s" hour)
|
|
:else
|
|
"")]
|
|
(util/format "%s%s-%s-%s %s%s%s%s"
|
|
open
|
|
(str year)
|
|
(util/zero-pad month)
|
|
(util/zero-pad day)
|
|
wday
|
|
time
|
|
repetition
|
|
close)))
|
|
|
|
(defn timestamp [{:keys [active date time repetition wday] :as t} kind]
|
|
(let [prefix (case kind
|
|
"Scheduled"
|
|
[:i {:class "fa fa-calendar"
|
|
:style {:margin-right 3.5}}]
|
|
"Deadline"
|
|
[:i {:class "fa fa-calendar-times-o"
|
|
:style {:margin-right 3.5}}]
|
|
"Date"
|
|
nil
|
|
"Closed"
|
|
nil
|
|
"Started"
|
|
[:i {:class "fa fa-clock-o"
|
|
:style {:margin-right 3.5}}]
|
|
"Start"
|
|
"From: "
|
|
"Stop"
|
|
"To: "
|
|
nil)]
|
|
(let [class (if (= kind "Closed")
|
|
"line-through")]
|
|
[:span.timestamp (cond-> {:active (str active)}
|
|
class
|
|
(assoc :class class))
|
|
prefix
|
|
(timestamp-to-string t)])))
|
|
|
|
(defn range [{:keys [start stop]} stopped?]
|
|
[:div {:class "timestamp-range"
|
|
:stopped stopped?}
|
|
(timestamp start "Start")
|
|
(timestamp stop "Stop")])
|
|
|
|
(declare map-inline)
|
|
(declare markup-element-cp)
|
|
(declare markup-elements-cp)
|
|
|
|
(declare page-reference)
|
|
|
|
(defn page-cp
|
|
[{:keys [html-export? label children] :as config} page]
|
|
(when-let [page-name (:page/name page)]
|
|
(let [original-page-name (get page :page/original-name page-name)
|
|
original-page-name (if (date/valid-journal-title? original-page-name)
|
|
(string/capitalize original-page-name)
|
|
original-page-name)
|
|
page (string/lower-case page-name)
|
|
href (if html-export?
|
|
(util/encode-str page)
|
|
(rfe/href :page {:name page}))]
|
|
[:a.page-ref
|
|
{:href href
|
|
:on-click (fn [e]
|
|
(util/stop e)
|
|
(when (gobj/get e "shiftKey")
|
|
(when-let [page-entity (db/entity [:page/name page])]
|
|
(state/sidebar-add-block!
|
|
(state/get-current-repo)
|
|
(:db/id page-entity)
|
|
:page
|
|
{:page page-entity}))))}
|
|
(if (seq children)
|
|
(for [child children]
|
|
(if (= (first child) "Label")
|
|
[:span (last child)]
|
|
(let [{:keys [content children]} (last child)
|
|
page-name (subs content 2 (- (count content) 2))]
|
|
(page-reference html-export? page-name (assoc config :children children) nil))))
|
|
(if (and label
|
|
(string? label)
|
|
(not (string/blank? label))) ; alias
|
|
label
|
|
original-page-name))])))
|
|
|
|
(defn page-reference
|
|
[html-export? s config label]
|
|
[:span.page-reference
|
|
(when (and (not html-export?)
|
|
(not (= (:id config) "contents"))
|
|
(not (= (:id config) "Contents")))
|
|
[:span.text-gray-500 "[["])
|
|
(if (string/ends-with? s ".excalidraw")
|
|
[:a.page-ref
|
|
{:href (rfe/href :draw nil {:file (string/replace s (str config/default-draw-directory "/") "")})
|
|
:on-click (fn [e] (util/stop e))}
|
|
[:span
|
|
(svg/excalidraw-logo)
|
|
(string/capitalize (draw/get-file-title s))]]
|
|
(page-cp (assoc config
|
|
:label (mldoc/plain->text label)) {:page/name s}))
|
|
(when (and (not html-export?)
|
|
(not (= (:id config) "contents"))
|
|
(not (= (:id config) "Contents")))
|
|
[:span.text-gray-500 "]]"])])
|
|
|
|
(defn- latex-environment-content
|
|
[name option content]
|
|
(if (= (string/lower-case name) "equation")
|
|
content
|
|
(util/format "\\begin%s\n%s\\end{%s}"
|
|
(str "{" name "}" option)
|
|
content
|
|
name)))
|
|
|
|
(declare blocks-container)
|
|
|
|
(rum/defc block-embed < rum/reactive db-mixins/query
|
|
[config id]
|
|
(let [blocks (db/get-block-and-children (state/get-current-repo) id)]
|
|
[:div.embed-block.bg-base-2 {:style {:z-index 2}}
|
|
[:code "Embed block:"]
|
|
[:div.px-2
|
|
(blocks-container blocks (assoc config
|
|
:embed? true
|
|
:ref? false))]]))
|
|
|
|
(rum/defc page-embed < rum/reactive db-mixins/query
|
|
[config page-name]
|
|
(let [page-name (string/lower-case page-name)
|
|
page-original-name (:page/original-name (db/entity [:page/name page-name]))
|
|
blocks (db/get-page-blocks (state/get-current-repo) page-name)]
|
|
[:div.embed-page.py-2.my-2.px-3.bg-base-2
|
|
[:p
|
|
[:code.mr-2 "Embed page:"]
|
|
(page-cp config {:page/name page-name})]
|
|
(blocks-container blocks (assoc config :embed? true))]))
|
|
|
|
(defn- get-label-text
|
|
[label]
|
|
(and (= 1 (count label))
|
|
(let [label (first label)]
|
|
(string? (last label))
|
|
(last label))))
|
|
|
|
(defn- get-page
|
|
[label]
|
|
(when-let [label-text (get-label-text label)]
|
|
(db/entity [:page/name (string/lower-case label-text)])))
|
|
|
|
(defn- macro->text
|
|
[name arguments]
|
|
(if (and (seq arguments)
|
|
(not= arguments ["null"]))
|
|
(util/format "{{{%s %s}}}" name (string/join ", " arguments))
|
|
(util/format "{{{%s}}}" name)))
|
|
|
|
(defn inline
|
|
[{:keys [html-export?] :as config} item]
|
|
(match item
|
|
["Plain" s]
|
|
s
|
|
["Spaces" s]
|
|
s
|
|
["Superscript" l]
|
|
(->elem :sup (map-inline config l))
|
|
["Subscript" l]
|
|
(->elem :sub (map-inline config l))
|
|
["Tag" s]
|
|
(if (and s (util/tag-valid? s))
|
|
[:a.tag.mr-1 {:href (rfe/href :page {:name s})
|
|
:on-click (fn [e]
|
|
(util/stop e)
|
|
(let [repo (state/get-current-repo)
|
|
page (db/pull repo '[*] [:page/name (string/lower-case (util/url-decode s))])]
|
|
(when (gobj/get e "shiftKey")
|
|
(state/sidebar-add-block!
|
|
repo
|
|
(:db/id page)
|
|
:page
|
|
{:page page}))))}
|
|
(str "#" s)]
|
|
[:span.warning.mr-1 {:title "Invalid tag, tags only accept alphanumeric characters, \"-\", \"_\", \"@\" and \"%\"."}
|
|
(str "#" s)])
|
|
["Emphasis" [[kind] data]]
|
|
(let [elem (case kind
|
|
"Bold" :b
|
|
"Italic" :i
|
|
"Underline" :ins
|
|
"Strike_through" :del
|
|
"Highlight" :mark)]
|
|
(->elem elem (map-inline config data)))
|
|
["Entity" e]
|
|
[:span {:dangerouslySetInnerHTML
|
|
{:__html (:html e)}}]
|
|
|
|
["Latex_Fragment" ["Displayed" s]]
|
|
(if html-export?
|
|
(latex/html-export s false true)
|
|
(latex/latex (str (dc/squuid)) s false true))
|
|
|
|
["Latex_Fragment" ["Inline" s]]
|
|
(if html-export?
|
|
(latex/html-export s false true)
|
|
(latex/latex (str (dc/squuid)) s false false))
|
|
|
|
["Target" s]
|
|
[:a {:id s} s]
|
|
|
|
["Radio_Target" s]
|
|
[:a {:id s} s]
|
|
|
|
["Email" address]
|
|
(let [{:keys [local_part domain]} address
|
|
address (str local_part "@" domain)]
|
|
[:a {:href (str "mainto:" address)}
|
|
address])
|
|
|
|
["Block_reference" id]
|
|
;; FIXME: alert when self block reference
|
|
(when-not (string/blank? id)
|
|
(let [block (and (util/uuid-string? id)
|
|
(db/pull-block (uuid id)))]
|
|
(if block
|
|
[:span
|
|
[:span.text-gray-500 "(("]
|
|
[:a {:href (rfe/href :page {:name id})
|
|
:on-click (fn [e]
|
|
(util/stop e)
|
|
(when (gobj/get e "shiftKey")
|
|
(state/sidebar-add-block!
|
|
(state/get-current-repo)
|
|
(:db/id block)
|
|
:block-ref
|
|
{:block block})))}
|
|
(->elem
|
|
:span.block-ref
|
|
(map-inline config (:block/title block)))]
|
|
[:span.text-gray-500 "))"]]
|
|
[:span.warning.mr-1 {:title "Block ref invalid"}
|
|
(util/format "((%s))" id)])))
|
|
|
|
["Nested_link" link]
|
|
(let [{:keys [content children]} link]
|
|
[:span.page-reference
|
|
(when (and (not html-export?)
|
|
(not (= (:id config) "contents")))
|
|
[:span.text-gray-500 "[["])
|
|
(let [page-name (subs content 2 (- (count content) 2))]
|
|
(page-cp (assoc config :children children) {:page/name page-name}))
|
|
(when (and (not html-export?)
|
|
(not (= (:id config) "contents")))
|
|
[:span.text-gray-500 "]]"])])
|
|
|
|
["Link" link]
|
|
(let [{:keys [url label title]} link
|
|
img-formats (set (map name (config/img-formats)))]
|
|
(match url
|
|
["Search" s]
|
|
(cond
|
|
;; image
|
|
(some (fn [fmt] (re-find (re-pattern (str "(?i)\\." fmt)) s)) img-formats)
|
|
(image-link config url s label)
|
|
|
|
(= \# (first s))
|
|
(->elem :a {:href (str "#" (anchor-link (subs s 1)))} (map-inline config label))
|
|
;; FIXME: same headline, see more https://orgmode.org/manual/Internal-Links.html
|
|
(and (= \* (first s))
|
|
(not= \* (last s)))
|
|
(->elem :a {:href (str "#" (anchor-link (subs s 1)))} (map-inline config label))
|
|
(re-find #"^https://" s)
|
|
(->elem :a {:href s}
|
|
(map-inline config label))
|
|
|
|
:else
|
|
(page-reference html-export? s config label))
|
|
|
|
:else
|
|
(let [href (string-of-url url)
|
|
protocol (or
|
|
(and (= "Complex" (first url))
|
|
(:protocol (second url)))
|
|
(and (= "File" (first url))
|
|
"file"))]
|
|
(cond
|
|
(= protocol "file")
|
|
(if (some (fn [fmt] (re-find (re-pattern (str "(?i)\\." fmt)) href)) img-formats)
|
|
(image-link config url href label)
|
|
(let [label-text (get-label-text label)
|
|
page (if (string/blank? label-text)
|
|
{:page/name (db/get-file-page (string/replace href "file:" ""))}
|
|
(get-page label))]
|
|
(if (and page
|
|
(when-let [ext (util/get-file-ext href)]
|
|
(config/mldoc-support? ext)))
|
|
[:span.page-reference
|
|
[:span.text-gray-500 "[["]
|
|
(page-cp config page)
|
|
[:span.text-gray-500 "]]"]]
|
|
|
|
(->elem
|
|
:a
|
|
(cond->
|
|
{:href href}
|
|
title
|
|
(assoc :title title))
|
|
(map-inline config label)))))
|
|
|
|
;; image
|
|
(some (fn [fmt] (re-find (re-pattern (str "(?i)\\." fmt)) href)) img-formats)
|
|
(image-link config url href label)
|
|
|
|
:else
|
|
(->elem
|
|
:a
|
|
(cond->
|
|
{:href href
|
|
:target "_blank"}
|
|
title
|
|
(assoc :title title))
|
|
(map-inline config label))))))
|
|
|
|
["Verbatim" s]
|
|
[:code s]
|
|
|
|
["Code" s]
|
|
[:code s]
|
|
|
|
["Inline_Source_Block" x]
|
|
[:code (:code x)]
|
|
|
|
["Export_Snippet" "html" s]
|
|
(when (not html-export?)
|
|
[:span {:dangerouslySetInnerHTML
|
|
{:__html s}}])
|
|
|
|
;; String to hiccup
|
|
["Inline_Hiccup" s]
|
|
(ui/catch-error
|
|
[:div.warning {:title "Invalid hiccup"} s]
|
|
(-> (safe-read-string s)
|
|
(security/remove-javascript-links-in-href)))
|
|
|
|
["Break_Line"]
|
|
[:br]
|
|
["Hard_Break_Line"]
|
|
[:br]
|
|
|
|
["Timestamp" ["Scheduled" t]]
|
|
(timestamp t "Scheduled")
|
|
["Timestamp" ["Deadline" t]]
|
|
(timestamp t "Deadline")
|
|
["Timestamp" ["Date" t]]
|
|
(timestamp t "Date")
|
|
["Timestamp" ["Closed" t]]
|
|
(timestamp t "Closed")
|
|
["Timestamp" ["Range" t]]
|
|
(range t false)
|
|
["Timestamp" ["Clock" ["Stopped" t]]]
|
|
(range t true)
|
|
["Timestamp" ["Clock" ["Started" t]]]
|
|
(timestamp t "Started")
|
|
|
|
["Cookie" ["Percent" n]]
|
|
[:span {:class "cookie-percent"}
|
|
(util/format "[d%%]" n)]
|
|
|
|
["Cookie" ["Absolute" current total]]
|
|
[:span {:class "cookie-absolute"}
|
|
(util/format "[%d/%d]" current total)]
|
|
|
|
["Footnote_Reference" options]
|
|
(let [{:keys [id name]} options
|
|
encode-name (util/url-encode name)]
|
|
[:sup.fn
|
|
[:a {:id (str "fnr." encode-name)
|
|
:class "footref"
|
|
:href (str "#fn." encode-name)}
|
|
name]])
|
|
|
|
["Macro" options]
|
|
(let [{:keys [name arguments]} options
|
|
arguments (if (and
|
|
(>= (count arguments) 2)
|
|
(and (string/starts-with? (first arguments) "[[")
|
|
(string/ends-with? (last arguments) "]]"))) ; page reference
|
|
(let [title (string/join ", " arguments)]
|
|
[title])
|
|
arguments)]
|
|
(cond
|
|
(= name "youtube")
|
|
(let [url (first arguments)]
|
|
(when-let [youtube-id (cond
|
|
(string/starts-with? url "https://youtu.be/")
|
|
(string/replace url "https://youtu.be/" "")
|
|
|
|
(string? url)
|
|
url
|
|
|
|
:else
|
|
nil)]
|
|
(when-not (string/blank? youtube-id)
|
|
[:iframe
|
|
{:allowfullscreen "allowfullscreen"
|
|
:allow
|
|
"accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
:frameborder "0"
|
|
:src (str "https://www.youtube.com/embed/" youtube-id)
|
|
:height "315"
|
|
:width "560"}])))
|
|
|
|
(= name "embed")
|
|
(let [a (first arguments)]
|
|
(cond
|
|
(and (string/starts-with? a "[[")
|
|
(string/ends-with? a "]]"))
|
|
(let [page-name (-> (string/replace a "[[" "")
|
|
(string/replace "]]" "")
|
|
string/trim)]
|
|
(when-not (string/blank? page-name)
|
|
(page-embed config page-name)))
|
|
|
|
(and (string/starts-with? a "((")
|
|
(string/ends-with? a "))"))
|
|
(when-let [s (-> (string/replace a "((" "")
|
|
(string/replace "))" "")
|
|
string/trim)]
|
|
(when-let [id (and s
|
|
(let [s (string/trim s)]
|
|
(and (util/uuid-string? s)
|
|
(uuid s))))]
|
|
(block-embed config id)))
|
|
|
|
:else ;TODO: maybe collections?
|
|
nil))
|
|
|
|
:else
|
|
(if-let [block-uuid (:block/uuid config)]
|
|
(let [macro-content (or
|
|
(-> (db/entity [:block/uuid block-uuid])
|
|
(:block/page)
|
|
(:db/id)
|
|
(db/entity)
|
|
:page/properties
|
|
:macros
|
|
(get name))
|
|
(get-in (state/get-config) [:macros name])
|
|
(get-in (state/get-config) [:macros (keyword name)]))]
|
|
[:span
|
|
(if (and (seq arguments) macro-content)
|
|
(block/macro-subs macro-content arguments)
|
|
(or
|
|
macro-content
|
|
[:span.warning {:title (str "Unsupported macro name: " name)}
|
|
(macro->text name arguments)]))])
|
|
|
|
[:span
|
|
(macro->text name arguments)])))
|
|
|
|
:else
|
|
""))
|
|
|
|
(declare blocks-cp)
|
|
|
|
(rum/defc block-child
|
|
[block]
|
|
block)
|
|
|
|
(defonce *control-show? (atom {}))
|
|
|
|
(rum/defcs block-control < rum/reactive
|
|
{:will-mount (fn [state]
|
|
(let [block (nth (:rum/args state) 1)
|
|
collapsed? (:block/collapsed? block)]
|
|
(state/set-collapsed-state! (:block/uuid block)
|
|
collapsed?))
|
|
state)}
|
|
[state config block uuid block-id level start-level body children dummy?]
|
|
(let [has-child? (and
|
|
(not (:pre-block? block))
|
|
(or (seq children)
|
|
(seq body)))
|
|
collapsed? (state/sub [:ui/collapsed-blocks uuid])
|
|
collapsed? (and has-child? collapsed?)
|
|
control-show (util/react (rum/cursor *control-show? block-id))
|
|
dark? (= "dark" (state/sub :ui/theme))
|
|
heading? (= (get (:block/properties block) "heading") "true")]
|
|
[:div.mr-2.flex.flex-row.items-center
|
|
{:style {:height 24
|
|
:margin-top (if (and heading? (<= level 6))
|
|
(case level
|
|
1
|
|
32
|
|
2
|
|
22
|
|
18)
|
|
0)
|
|
:float "left"}}
|
|
|
|
[:a.block-control.opacity-50.hover:opacity-100
|
|
{:id (str "control-" uuid)
|
|
:style {:width 14
|
|
:height 16
|
|
:margin-right 2}
|
|
:on-click (fn [e]
|
|
(util/stop e)
|
|
(if collapsed?
|
|
(expand/expand! block)
|
|
(expand/collapse! block))
|
|
|
|
(state/set-collapsed-state! uuid (not collapsed?)))}
|
|
(cond
|
|
(and control-show collapsed?)
|
|
(svg/caret-right)
|
|
|
|
(and control-show has-child?)
|
|
(svg/caret-down)
|
|
|
|
:else
|
|
[:span ""])]
|
|
[:a (if (not dummy?)
|
|
{:href (rfe/href :page {:name uuid})
|
|
:on-click (fn [e]
|
|
(util/stop e)
|
|
(when (gobj/get e "shiftKey")
|
|
(state/sidebar-add-block!
|
|
(state/get-current-repo)
|
|
(:db/id block)
|
|
:block
|
|
block)))})
|
|
[:span.bullet-container.cursor
|
|
{:id (str "dot-" uuid)
|
|
:draggable true
|
|
:on-drag-start (fn [event]
|
|
(editor-handler/highlight-block! uuid)
|
|
(.setData (gobj/get event "dataTransfer")
|
|
"block-uuid"
|
|
uuid)
|
|
(.setData (gobj/get event "dataTransfer")
|
|
"block-dom-id"
|
|
block-id)
|
|
(state/clear-selection!)
|
|
(reset! *dragging? true)
|
|
(reset! *dragging-block block))
|
|
:blockid (str uuid)
|
|
:class (str (when collapsed? "bullet-closed")
|
|
" "
|
|
(when (and (:document/mode? config)
|
|
(not collapsed?))
|
|
"hide-inner-bullet"))}
|
|
[:span.bullet {:blockid (str uuid)
|
|
:class (if heading? "bullet-heading" "")}]]]]))
|
|
|
|
(defn- build-id
|
|
[config ref? sidebar? embed?]
|
|
(let [k (pr-str config)
|
|
n (or
|
|
(get @container-ids k)
|
|
(let [n' (swap! container-idx inc)]
|
|
(swap! container-ids assoc k n')
|
|
n'))]
|
|
(str n "-")))
|
|
|
|
(rum/defc dnd-separator
|
|
[block margin-left bottom top? nested?]
|
|
(let [id (str (:block/uuid block)
|
|
(cond nested?
|
|
"-nested"
|
|
top?
|
|
"-top"
|
|
:else
|
|
nil))]
|
|
[:div.dnd-separator
|
|
{:id id
|
|
:style (merge
|
|
{:position "absolute"
|
|
:left margin-left
|
|
:width "100%"
|
|
:z-index 3}
|
|
(if top?
|
|
{:top 0}
|
|
{:bottom 0}))}]))
|
|
|
|
(declare block-container)
|
|
(defn block-checkbox
|
|
[block class]
|
|
(let [marker (:block/marker block)
|
|
[class checked?] (cond
|
|
(nil? marker)
|
|
nil
|
|
(contains? #{"NOW" "LATER" "DOING" "IN-PROGRESS" "TODO" "WAIT" "WAITING"} marker)
|
|
[class false]
|
|
(= "DONE" marker)
|
|
[(str class " checked") true])]
|
|
(when class
|
|
(ui/checkbox {:class class
|
|
:style {:margin-top -2
|
|
:margin-right 5}
|
|
:checked checked?
|
|
:on-change (fn [_e]
|
|
;; FIXME: Log timestamp
|
|
(if checked?
|
|
(editor-handler/uncheck block)
|
|
(editor-handler/check block)))}))))
|
|
|
|
(defn list-checkbox
|
|
[checked?]
|
|
(ui/checkbox {:style {:margin-right 6
|
|
:margin-top -1}
|
|
:checked checked?}))
|
|
|
|
(defn marker-switch
|
|
[{:block/keys [pre-block? marker] :as block}]
|
|
(when (contains? #{"NOW" "LATER" "TODO" "DOING"} marker)
|
|
(let [set-marker-fn (fn [marker]
|
|
(fn [e]
|
|
(util/stop e)
|
|
(editor-handler/set-marker block marker)))]
|
|
(case marker
|
|
"NOW"
|
|
[:a.marker-switch
|
|
{:title "Change from NOW to LATER"
|
|
:on-click (set-marker-fn "LATER")}
|
|
[:span "N"]]
|
|
"LATER"
|
|
[:a.marker-switch
|
|
{:title "Change from LATER to NOW"
|
|
:on-click (set-marker-fn "NOW")}
|
|
"L"]
|
|
|
|
"TODO"
|
|
[:a.marker-switch
|
|
{:title "Change from TODO to DOING"
|
|
:on-click (set-marker-fn "DOING")}
|
|
"T"]
|
|
"DOING"
|
|
[:a.marker-switch
|
|
{:title "Change from DOING to TODO"
|
|
:on-click (set-marker-fn "TODO")}
|
|
"D"]
|
|
nil))))
|
|
|
|
(defn marker-cp
|
|
[{:block/keys [pre-block? marker] :as block}]
|
|
(when-not pre-block?
|
|
(if (contains? #{"IN-PROGRESS" "WAIT" "WAITING"} marker)
|
|
[:span {:class (str "task-status " (string/lower-case marker))
|
|
:style {:margin-right 3.5}}
|
|
(string/upper-case marker)])))
|
|
|
|
(defn priority-cp
|
|
[{:block/keys [pre-block? priority] :as block}]
|
|
|
|
(when (and (not pre-block?) priority)
|
|
(ui/tooltip
|
|
[:ul
|
|
(for [p (remove #(= priority %) ["A" "B" "C"])]
|
|
[:a.mr-2.text-base.tooltip-priority {:priority p
|
|
:on-click (fn [] (editor-handler/set-priority block p))}])]
|
|
[:a.opacity-50.hover:opacity-100
|
|
{:class "priority"
|
|
:href (rfe/href :page {:name priority})
|
|
:style {:margin-right 3.5}}
|
|
(util/format "[#%s]" (str priority))])))
|
|
|
|
(defn block-tags-cp
|
|
[{:block/keys [pre-block? tags] :as block}]
|
|
(when (and (not pre-block?)
|
|
(seq tags))
|
|
(->elem
|
|
:span
|
|
{:class "block-tags"}
|
|
(mapv (fn [{:keys [db/id tag/name]}]
|
|
(if (util/tag-valid? name)
|
|
[:a.tag.mx-1 {:key (str "tag-" id)
|
|
:href (rfe/href :page {:name name})}
|
|
(str "#" name)]
|
|
[:span.warning.mx-1 {:title "Invalid tag, tags only accept alphanumeric characters, \"-\", \"_\", \"@\" and \"%\"."}
|
|
(str "#" name)]))
|
|
tags))))
|
|
|
|
(defn build-block-part
|
|
[{:keys [slide?] :as config} {:block/keys [uuid title tags marker level priority anchor meta format content pre-block? dummy? block-refs-count page properties]
|
|
:as t}]
|
|
(let [config (assoc config :block t)
|
|
slide? (boolean (:slide? config))
|
|
html-export? (:html-export? config)
|
|
checkbox (when (and (not pre-block?)
|
|
(not html-export?))
|
|
(block-checkbox t (str "mr-1 cursor")))
|
|
marker-switch (when (and (not pre-block?)
|
|
(not html-export?))
|
|
(marker-switch t))
|
|
marker-cp (marker-cp t)
|
|
priority (priority-cp t)
|
|
tags (block-tags-cp t)
|
|
contents? (= (:id config) "contents")
|
|
heading? (= (get properties "heading") "true")
|
|
bg-color (get properties "background_color")]
|
|
(when level
|
|
(let [element (if (and (<= level 6) heading?)
|
|
(keyword (str "h" level))
|
|
:div)]
|
|
(->elem
|
|
element
|
|
(merge
|
|
{:id anchor}
|
|
(when (and marker
|
|
(not (string/blank? marker))
|
|
(not= "nil" marker))
|
|
{:class (str (string/lower-case marker)
|
|
"flex flex-row items-center")})
|
|
(when bg-color
|
|
{:style {:background-color bg-color
|
|
:padding-left 6
|
|
:padding-right 6
|
|
:color "#FFFFFF"}}))
|
|
(remove-nils
|
|
(concat
|
|
[(when-not slide? checkbox)
|
|
(when-not slide? marker-switch)
|
|
marker-cp
|
|
priority]
|
|
(cond
|
|
dummy?
|
|
[[:span.opacity-50 "Click here to start writing"]]
|
|
|
|
;; empty item
|
|
(and contents? (or
|
|
(empty? title)
|
|
(= title [["Plain" "[[]]"]])))
|
|
[[:span.opacity-50 "Click here to add a page, e.g. [[favorite-page]]"]]
|
|
|
|
:else
|
|
(map-inline config title))
|
|
[tags])))))))
|
|
|
|
(defn dnd-same-block?
|
|
[uuid]
|
|
(= (:block/uuid @*dragging-block) uuid))
|
|
|
|
(defn show-dnd-separator
|
|
[element-id]
|
|
(when-let [element (gdom/getElement element-id)]
|
|
(when (d/has-class? element "dnd-separator")
|
|
(d/remove-class! element "dnd-separator")
|
|
(d/add-class! element "dnd-separator-cur"))))
|
|
|
|
(defn hide-dnd-separator
|
|
[element-id]
|
|
(when-let [element (gdom/getElement element-id)]
|
|
(when (d/has-class? element "dnd-separator-cur")
|
|
(d/remove-class! element "dnd-separator-cur")
|
|
(d/add-class! element "dnd-separator"))))
|
|
|
|
(defn- get-data-transfer-attr
|
|
[event attr]
|
|
(.getData (gobj/get event "dataTransfer") attr))
|
|
|
|
(defn- pre-block-cp
|
|
[config content format]
|
|
(let [ast (mldoc/->edn content (mldoc/default-config format))
|
|
ast (map first ast)]
|
|
[:div.pre-block.bg-base-2.p-2
|
|
(markup-elements-cp (assoc config :block/format format) ast)]))
|
|
|
|
(defn property-value
|
|
[format v]
|
|
(when (string? v)
|
|
(let [inline-list (mldoc/inline->edn v (mldoc/default-config format))]
|
|
[:div.inline.mr-1 (map-inline {} inline-list)])))
|
|
|
|
(rum/defc properties-cp
|
|
[block]
|
|
(let [properties (apply dissoc (:block/properties block) text/hidden-properties)]
|
|
(when (seq properties)
|
|
[:div.blocks__properties.text-sm.opacity-80.my-1.p-2
|
|
(for [[k v] properties]
|
|
[:div.my-1
|
|
[:b k]
|
|
[:span.mr-1 ":"]
|
|
(property-value (:block/format block) v)])])))
|
|
|
|
(rum/defcs timestamp-cp < rum/reactive
|
|
(rum/local false ::show?)
|
|
(rum/local {} ::pos)
|
|
{:will-unmount (fn [state]
|
|
(when-let [show? (::show? state)]
|
|
(reset! show? false))
|
|
state)}
|
|
[state block typ ast]
|
|
(let [show? (get state ::show?)]
|
|
[:div.flex.flex-col
|
|
[:div.text-sm.mt-1.flex.flex-row
|
|
[:div.opacity-50.font-medium {:style {:width 95}}
|
|
(str typ ": ")]
|
|
[:a.opacity-80.hover:opacity-100
|
|
{:on-click (fn []
|
|
(if @show?
|
|
(do
|
|
(reset! show? false)
|
|
(reset! commands/*current-command nil)
|
|
(state/set-editor-show-date-picker false)
|
|
(state/set-timestamp-block! nil))
|
|
(do
|
|
(reset! show? true)
|
|
(reset! commands/*current-command typ)
|
|
(state/set-editor-show-date-picker true)
|
|
(state/set-timestamp-block! {:block block
|
|
:typ typ
|
|
:show? show?}))))}
|
|
(repeated/timestamp->text ast)]]
|
|
(when (true? @show?)
|
|
(let [ts (repeated/timestamp->map ast)]
|
|
[:div.my-4
|
|
(datetime-comp/date-picker nil nil ts)]))]))
|
|
|
|
(rum/defc block-content < rum/reactive
|
|
[config {:block/keys [uuid title level body meta content marker dummy? page format repo children pre-block? properties collapsed? idx block-refs-count scheduled scheduled-ast deadline deadline-ast repeated?] :as block} edit-input-id block-id slide?]
|
|
(let [dragging? (rum/react *dragging?)
|
|
attrs {:blockid (str uuid)
|
|
;; FIXME: Click to copy a selection instead of click first and then copy
|
|
;; It seems that `util/caret-range` can't get the correct range
|
|
:on-click (fn [e]
|
|
(let [target (gobj/get e "target")]
|
|
(when-not (or (util/link? target)
|
|
(util/input? target)
|
|
(util/details-or-summary? target)
|
|
(and (util/sup? target)
|
|
(d/has-class? target "fn")))
|
|
(editor-handler/clear-selection! nil)
|
|
(editor-handler/unhighlight-block!)
|
|
(let [cursor-range (util/caret-range (gdom/getElement block-id))
|
|
properties-hidden? (text/properties-hidden? properties)
|
|
content (text/remove-level-spaces content format)
|
|
content (if properties-hidden? (text/remove-properties! content) content)]
|
|
(state/set-editing!
|
|
edit-input-id
|
|
content
|
|
block
|
|
cursor-range))
|
|
(util/stop e))))
|
|
:on-drag-over (fn [event]
|
|
(util/stop event)
|
|
(when-not (dnd-same-block? uuid)
|
|
(show-dnd-separator (str uuid "-nested"))))
|
|
:on-drag-leave (fn [event]
|
|
(hide-dnd-separator (str uuid))
|
|
(hide-dnd-separator (str uuid "-nested"))
|
|
(hide-dnd-separator (str uuid "-top")))
|
|
:on-drop (fn [event]
|
|
(util/stop event)
|
|
(when-not (dnd-same-block? uuid)
|
|
(let [from-dom-id (get-data-transfer-attr event "block-dom-id")]
|
|
(dnd/move-block @*dragging-block
|
|
block
|
|
from-dom-id
|
|
false
|
|
true)))
|
|
(reset! *dragging? false)
|
|
(reset! *dragging-block nil)
|
|
(editor-handler/unhighlight-block!))}]
|
|
[:div.flex.overflow-x-auto.overflow-y-hidden.relative
|
|
[:div.flex-1.flex-col.relative.block-content
|
|
(cond-> {:id (str "block-content-" uuid)
|
|
:style {:cursor "text"
|
|
:min-height 24}}
|
|
(not slide?)
|
|
(merge attrs))
|
|
|
|
(if pre-block?
|
|
(pre-block-cp config (string/trim content) format)
|
|
(build-block-part config block))
|
|
|
|
(when (and dragging? (not slide?))
|
|
(dnd-separator block 0 -4 false true))
|
|
|
|
(when (and deadline deadline-ast)
|
|
(timestamp-cp block "DEADLINE" deadline-ast))
|
|
|
|
(when (and scheduled scheduled-ast)
|
|
(timestamp-cp block "SCHEDULED" scheduled-ast))
|
|
|
|
(when (and (seq properties)
|
|
(let [hidden? (text/properties-hidden? properties)]
|
|
(not hidden?)))
|
|
(properties-cp block))
|
|
|
|
(when (and (not pre-block?) (seq body))
|
|
[:div.block-body {:style {:display (if collapsed? "none" "")}}
|
|
;; TODO: consistent id instead of the idx (since it could be changed later)
|
|
(let [body (block/trim-break-lines! (:block/body block))]
|
|
(for [[idx child] (medley/indexed body)]
|
|
(when-let [block (markup-element-cp config child)]
|
|
(rum/with-key (block-child block)
|
|
(str uuid "-" idx)))))])]
|
|
(when (and block-refs-count (> block-refs-count 0))
|
|
[:div
|
|
[:a.block.py-0.px-2.rounded.bg-base-2.opacity-50.hover:opacity-100
|
|
{:title "Open block references"
|
|
:style {:margin-top -1}
|
|
:on-click (fn []
|
|
(state/sidebar-add-block!
|
|
(state/get-current-repo)
|
|
(:db/id block)
|
|
:block-ref
|
|
{:block block}))}
|
|
block-refs-count]])
|
|
|
|
(when (and (= marker "DONE")
|
|
(state/enable-timetracking?))
|
|
(let [start-time (or
|
|
(get properties "now")
|
|
(get properties "doing")
|
|
(get properties "in-progress")
|
|
(get properties "later")
|
|
(get properties "todo"))
|
|
finish-time (get properties "done")]
|
|
(when (and start-time finish-time (> finish-time start-time))
|
|
[:div.text-sm.absolute.time-spent {:style {:top 0
|
|
:right 0
|
|
:padding-left 2
|
|
:z-index 4}
|
|
:title (str (date/int->local-time start-time) " ~ " (date/int->local-time finish-time))}
|
|
[:span.opacity-70
|
|
(utils/timeConversion (- finish-time start-time))]])))]))
|
|
|
|
(rum/defc block-content-or-editor < rum/reactive
|
|
[config {:block/keys [uuid title level body meta content dummy? page format repo children pre-block? collapsed? idx] :as block} edit-input-id block-id slide?]
|
|
(let [edit? (state/sub [:editor/editing? edit-input-id])
|
|
editor-box (get config :editor-box)]
|
|
(if (and edit? editor-box)
|
|
[:div.editor-wrapper {:id (str "editor-" edit-input-id)}
|
|
(editor-box {:block block
|
|
:block-id uuid
|
|
:block-parent-id block-id
|
|
:format format
|
|
:dummy? dummy?
|
|
:on-hide (fn [value event]
|
|
(when (= event :esc)
|
|
(editor-handler/highlight-block! uuid)))}
|
|
edit-input-id
|
|
config)]
|
|
(block-content config block edit-input-id block-id slide?))))
|
|
|
|
(rum/defc dnd-separator-wrapper < rum/reactive
|
|
[block slide? top?]
|
|
(let [dragging? (rum/react *dragging?)]
|
|
(cond
|
|
(and dragging? (not slide?))
|
|
(dnd-separator block 20 0 top? false)
|
|
|
|
:else
|
|
nil)))
|
|
|
|
(defn non-dragging?
|
|
[e]
|
|
(and (= (gobj/get e "buttons") 1)
|
|
(not (d/has-class? (gobj/get e "target") "bullet-container"))
|
|
(not (d/has-class? (gobj/get e "target") "bullet"))
|
|
(not @*dragging?)))
|
|
|
|
(defn block-parents
|
|
([repo block-id format]
|
|
(block-parents repo block-id format true))
|
|
([repo block-id format show-page?]
|
|
(let [parents (db/get-block-parents repo block-id 3)
|
|
page (db/get-block-page repo block-id)
|
|
page-name (:page/name page)]
|
|
(when (or (seq parents)
|
|
show-page?
|
|
page-name)
|
|
(let [parents-atom (atom parents)
|
|
component [:div.block-parents.flex-row.flex-1
|
|
(when show-page?
|
|
[:a {:href (rfe/href :page {:name page-name})}
|
|
(or (:page/original-name page)
|
|
(:page/name page))])
|
|
|
|
(when (and show-page? (seq parents))
|
|
[:span.mx-2.opacity-50 "➤"])
|
|
|
|
(when (seq parents)
|
|
(let [parents (for [{:block/keys [uuid content]} parents]
|
|
(let [title (->> (take 24
|
|
(-> (string/split content #"\n")
|
|
first
|
|
(text/remove-level-spaces format)))
|
|
(apply str))]
|
|
(when (and (not (string/blank? title))
|
|
(not= (string/lower-case page-name) (string/lower-case title)))
|
|
[:a {:href (rfe/href :page {:name uuid})}
|
|
title])))
|
|
parents (remove nil? parents)]
|
|
(reset! parents-atom parents)
|
|
(when (seq parents)
|
|
(interpose [:span.mx-2.opacity-50 "➤"]
|
|
parents))))]]
|
|
(when (or (seq @parents-atom) show-page?)
|
|
component))))))
|
|
|
|
(rum/defc block-container < rum/static
|
|
{:did-mount (fn [state]
|
|
(let [block (nth (:rum/args state) 1)
|
|
collapsed? (:block/collapsed? block)]
|
|
(when collapsed?
|
|
(expand/collapse! block))
|
|
state))
|
|
:should-update (fn [old-state new-state]
|
|
(not= (:block/content (second (:rum/args old-state)))
|
|
(:block/content (second (:rum/args new-state)))))}
|
|
[config {:block/keys [uuid title level body meta content dummy? page format repo children collapsed? pre-block? idx properties] :as block}]
|
|
(let [ref? (boolean (:ref? config))
|
|
breadcrumb-show? (:breadcrumb-show? config)
|
|
sidebar? (boolean (:sidebar? config))
|
|
slide? (boolean (:slide? config))
|
|
doc-mode? (:document/mode? config)
|
|
embed? (:embed? config)
|
|
unique-dom-id (build-id (dissoc config :block/uuid) ref? sidebar? embed?)
|
|
edit-input-id (str "edit-block-" unique-dom-id uuid)
|
|
block-id (str "ls-block-" unique-dom-id uuid)
|
|
has-child? (boolean
|
|
(and
|
|
(not pre-block?)
|
|
(or (seq children)
|
|
(seq body))))
|
|
start-level (or (:start-level config) 1)
|
|
attrs {:on-drag-over (fn [event]
|
|
(util/stop event)
|
|
(when-not (dnd-same-block? uuid)
|
|
(if (zero? idx)
|
|
(let [element-top (gobj/get (utils/getOffsetRect (gdom/getElement block-id)) "top")
|
|
cursor-top (gobj/get event "clientY")]
|
|
(if (<= (js/Math.abs (- cursor-top element-top)) 16)
|
|
;; top
|
|
(do
|
|
(hide-dnd-separator (str uuid))
|
|
(show-dnd-separator (str uuid "-top"))
|
|
(reset! *move-to-top? true))
|
|
(do
|
|
(hide-dnd-separator (str uuid "-top"))
|
|
(show-dnd-separator (str uuid)))))
|
|
(show-dnd-separator (str uuid)))))
|
|
:on-drag-leave (fn [event]
|
|
(hide-dnd-separator (str uuid))
|
|
(hide-dnd-separator (str uuid "-nested"))
|
|
(hide-dnd-separator (str uuid "-top"))
|
|
(reset! *move-to-top? false))
|
|
:on-drop (fn [event]
|
|
(when-not (dnd-same-block? uuid)
|
|
(let [from-dom-id (get-data-transfer-attr event "block-dom-id")]
|
|
(dnd/move-block @*dragging-block
|
|
block
|
|
from-dom-id
|
|
@*move-to-top?
|
|
false)))
|
|
(reset! *dragging? false)
|
|
(reset! *dragging-block nil)
|
|
(editor-handler/unhighlight-block!))
|
|
:on-mouse-move (fn [e]
|
|
(when (non-dragging? e)
|
|
(state/into-selection-mode!)))
|
|
:on-mouse-down (fn [e]
|
|
(when (and
|
|
(not (state/get-selection-start-block))
|
|
(= (gobj/get e "buttons") 1))
|
|
(when block-id (state/set-selection-start-block! block-id))))
|
|
:on-mouse-over (fn [e]
|
|
(util/stop e)
|
|
(when has-child?
|
|
(swap! *control-show? assoc block-id true))
|
|
(when-let [parent (gdom/getElement block-id)]
|
|
(let [node (.querySelector parent ".bullet-container")
|
|
closed? (d/has-class? node "bullet-closed")]
|
|
(if closed?
|
|
(state/collapse-block! uuid)
|
|
(state/expand-block! uuid))
|
|
(when doc-mode?
|
|
(d/remove-class! node "hide-inner-bullet"))))
|
|
(when (and
|
|
(state/in-selection-mode?)
|
|
(non-dragging? e))
|
|
(util/stop e)
|
|
(editor-handler/highlight-selection-area! block-id)))
|
|
:on-mouse-out (fn [e]
|
|
(util/stop e)
|
|
(when has-child?
|
|
(swap! *control-show?
|
|
assoc block-id false))
|
|
(when doc-mode?
|
|
(when-let [parent (gdom/getElement block-id)]
|
|
(when-let [node (.querySelector parent ".bullet-container")]
|
|
(d/add-class! node "hide-inner-bullet")))))}]
|
|
[:div.ls-block.flex.flex-col.pt-1
|
|
(cond->
|
|
{:id block-id
|
|
:style {:position "relative"}
|
|
:class (str uuid
|
|
(when dummy? " dummy")
|
|
(when (and collapsed? has-child?) " collapsed")
|
|
(when pre-block? " pre-block"))
|
|
:blockid (str uuid)
|
|
:repo repo
|
|
:level level
|
|
:haschild (str has-child?)}
|
|
(not slide?)
|
|
(merge attrs))
|
|
|
|
(when (and ref? breadcrumb-show?)
|
|
(when-let [comp (block-parents repo uuid format false)]
|
|
[:div.my-2.opacity-50.ml-4 comp]))
|
|
|
|
(dnd-separator-wrapper block slide? (zero? idx))
|
|
|
|
[:div.flex-1.flex-row
|
|
(when (not slide?)
|
|
(block-control config block uuid block-id level start-level body children dummy?))
|
|
|
|
(block-content-or-editor config block edit-input-id block-id slide?)]
|
|
|
|
(when (seq children)
|
|
[:div.block-children {:style {:margin-left (if doc-mode? 12 22)
|
|
:display (if collapsed? "none" "")}}
|
|
(for [child children]
|
|
(when (map? child)
|
|
(let [child (dissoc child :block/meta)]
|
|
(rum/with-key (block-container (assoc config :block/uuid (:block/uuid child)) child)
|
|
(:block/uuid child)))))])
|
|
|
|
(when ref?
|
|
(let [children (-> (db/get-block-immediate-children repo uuid)
|
|
db/sort-by-pos)]
|
|
(when (seq children)
|
|
[:div.ref-children.ml-12
|
|
(blocks-container children (assoc config
|
|
:breadcrumb-show? false
|
|
:ref? true))])))
|
|
|
|
(dnd-separator-wrapper block slide? false)]))
|
|
|
|
(defn divide-lists
|
|
[[f & l]]
|
|
(loop [l l
|
|
ordered? (:ordered f)
|
|
result [[f]]]
|
|
(if (seq l)
|
|
(let [cur (first l)
|
|
cur-ordered? (:ordered cur)]
|
|
(if (= ordered? cur-ordered?)
|
|
(recur
|
|
(rest l)
|
|
cur-ordered?
|
|
(update result (dec (count result)) conj cur))
|
|
(recur
|
|
(rest l)
|
|
cur-ordered?
|
|
(conj result [cur]))))
|
|
result)))
|
|
|
|
(defn list-element
|
|
[l]
|
|
(match l
|
|
[l1 & tl]
|
|
(let [{:keys [ordered name]} l1]
|
|
(cond
|
|
(seq name)
|
|
:dl
|
|
ordered
|
|
:ol
|
|
:else
|
|
:ul))
|
|
|
|
:else
|
|
:ul))
|
|
|
|
(defn list-item
|
|
[config {:keys [name content checkbox items number] :as l}]
|
|
(let [content (when-not (empty? content)
|
|
(match content
|
|
[["Paragraph" i] & rest]
|
|
(vec-cat
|
|
(map-inline config i)
|
|
(markup-elements-cp config rest))
|
|
:else
|
|
(markup-elements-cp config content)))
|
|
checked? (some? checkbox)
|
|
items (if (seq items)
|
|
(->elem
|
|
(list-element items)
|
|
(for [item items]
|
|
(list-item config item))))]
|
|
(cond
|
|
(seq name)
|
|
[:dl {:checked checked?}
|
|
[:dt (map-inline config name)]
|
|
(->elem :dd
|
|
(vec-cat content [items]))]
|
|
|
|
:else
|
|
(if (nil? checkbox)
|
|
(->elem
|
|
:li
|
|
{:checked checked?}
|
|
(vec-cat
|
|
[(->elem
|
|
:p
|
|
content)]
|
|
[items]))
|
|
(->elem
|
|
:li
|
|
{:checked checked?}
|
|
(vec-cat
|
|
[(->elem
|
|
:p
|
|
(list-checkbox checkbox)
|
|
content)]
|
|
[items]))))))
|
|
|
|
(defn table
|
|
[config {:keys [header groups col_groups]}]
|
|
(let [tr (fn [elm cols]
|
|
(->elem
|
|
:tr
|
|
(mapv (fn [col]
|
|
(->elem
|
|
elm
|
|
{:scope "col"
|
|
:class "org-left"}
|
|
(map-inline config col)))
|
|
cols)))
|
|
tb-col-groups (try
|
|
(mapv (fn [number]
|
|
(let [col-elem [:col {:class "org-left"}]]
|
|
(->elem
|
|
:colgroup
|
|
(repeat number col-elem))))
|
|
col_groups)
|
|
(catch js/Error e
|
|
[]))
|
|
head (if header
|
|
[:thead (tr :th header)])
|
|
groups (mapv (fn [group]
|
|
(->elem
|
|
:tbody
|
|
(mapv #(tr :td %) group)))
|
|
groups)]
|
|
[:div.table-wrapper {:style {:max-width (min 700
|
|
(gobj/get js/window "innerWidth"))}}
|
|
(->elem
|
|
:table
|
|
{:class "table-auto"
|
|
:border 2
|
|
:cell-spacing 0
|
|
:cell-padding 6
|
|
:rules "groups"
|
|
:frame "hsides"}
|
|
(vec-cat
|
|
tb-col-groups
|
|
(cons head groups)))]))
|
|
|
|
(defn map-inline
|
|
[config col]
|
|
(map #(inline config %) col))
|
|
|
|
(declare ->hiccup)
|
|
|
|
(defn built-in-custom-query?
|
|
[title]
|
|
(contains? #{"🔨 NOW" "📅 NEXT"}
|
|
title))
|
|
|
|
(rum/defcs custom-query < rum/reactive
|
|
{:will-mount (fn [state]
|
|
(let [[config query] (:rum/args state)]
|
|
(let [query-atom (db/custom-query query)]
|
|
(assoc state :query-atom query-atom))))
|
|
:did-mount (fn [state]
|
|
(when-let [query (last (:rum/args state))]
|
|
(state/add-custom-query-component! query (:rum/react-component state)))
|
|
state)
|
|
:will-unmount (fn [state]
|
|
(when-let [query (last (:rum/args state))]
|
|
(state/remove-custom-query-component! query)
|
|
(db/remove-custom-query! (state/get-current-repo) query))
|
|
state)}
|
|
[state config {:keys [title query inputs view collapsed? children?] :as q}]
|
|
(let [query-atom (:query-atom state)]
|
|
(let [current-block-uuid (or (:block/uuid (:block config))
|
|
(:block/uuid config))
|
|
;; exclude the current one, otherwise it'll loop forever
|
|
remove-blocks (if current-block-uuid [current-block-uuid] nil)
|
|
query-result (and query-atom (rum/react query-atom))
|
|
result (if query-result
|
|
(db/custom-query-result-transform query-result remove-blocks q))
|
|
view-f (sci/eval-string (pr-str view))
|
|
only-blocks? (:block/uuid (first result))
|
|
blocks-grouped-by-page? (and (seq result)
|
|
(coll? (first result))
|
|
(:page/name (ffirst result))
|
|
(:block/uuid (first (second (first result))))
|
|
true)
|
|
built-in? (built-in-custom-query? title)]
|
|
[:div.custom-query.mt-2 (get config :attr {})
|
|
(when-not (and built-in? (empty? result))
|
|
(ui/foldable
|
|
[:div.opacity-70
|
|
title]
|
|
(cond
|
|
(and (seq result) view-f)
|
|
(let [result (sci/call-fn view-f result)]
|
|
(util/hiccup-keywordize result))
|
|
|
|
(and (seq result)
|
|
(or only-blocks? blocks-grouped-by-page?))
|
|
(->hiccup result (cond-> (assoc config
|
|
:custom-query? true
|
|
:group-by-page? blocks-grouped-by-page?)
|
|
children?
|
|
(assoc :ref? true))
|
|
{:style {:margin-top "0.25rem"
|
|
:margin-left "0.25rem"}})
|
|
|
|
(seq result) ;TODO: table
|
|
[:pre
|
|
(for [record result]
|
|
(if (map? record)
|
|
(str (util/pp-str record) "\n")
|
|
record))]
|
|
|
|
:else
|
|
[:div.text-sm.mt-2.ml-2.font-medium.opacity-50 "Empty"])
|
|
collapsed?))])))
|
|
|
|
(defn admonition
|
|
[config type options result]
|
|
(when-let [icon (case (string/lower-case (name type))
|
|
"note" svg/note
|
|
"tip" svg/tip
|
|
"important" svg/important
|
|
"caution" svg/caution
|
|
"warning" svg/warning
|
|
nil)]
|
|
[:div.flex.flex-row.admonitionblock.align-items {:class type}
|
|
[:div.pr-4.admonition-icon.flex.flex-col.justify-center
|
|
{:title (string/upper-case type)} (icon)]
|
|
[:div.ml-4.text-lg
|
|
(markup-elements-cp config result)]]))
|
|
|
|
(defn markup-element-cp
|
|
[{:keys [html-export?] :as config} item]
|
|
(try
|
|
(match item
|
|
["Properties" m]
|
|
[:div.properties
|
|
(let [format (:block/format config)]
|
|
(for [[k v] m]
|
|
(when (and (not (and (= k :macros) (empty? v))) ; empty macros
|
|
(not (= k :title)))
|
|
[:div.property
|
|
[:span.font-medium.mr-1 (string/upper-case (str (name k) ": "))]
|
|
(if (coll? v)
|
|
(for [item v]
|
|
(if (or (= k :tags)
|
|
(= k :alias))
|
|
(if (string/includes? item "[[")
|
|
(property-value format item)
|
|
(let [tag (-> item
|
|
(string/replace "[" "")
|
|
(string/replace "]" "")
|
|
(string/replace "#" ""))]
|
|
[:a.tag.mr-1 {:href (rfe/href :page {:name tag})}
|
|
tag]))
|
|
(property-value format item)))
|
|
(property-value format v))])))]
|
|
|
|
["Paragraph" l]
|
|
;; TODO: speedup
|
|
(if (re-find #"\"Export_Snippet\" \"embed\"" (str l))
|
|
(->elem :div (map-inline config l))
|
|
(->elem :p (map-inline config l)))
|
|
|
|
["Horizontal_Rule"]
|
|
(when-not (:slide? config)
|
|
[:hr])
|
|
["Heading" h]
|
|
(block-container config h)
|
|
["List" l]
|
|
(let [lists (divide-lists l)]
|
|
(if (= 1 (count lists))
|
|
(let [l (first lists)]
|
|
(->elem
|
|
(list-element l)
|
|
(map #(list-item config %) l)))
|
|
[:div.list-group
|
|
(for [l lists]
|
|
(->elem
|
|
(list-element l)
|
|
(map #(list-item config %) l)))]))
|
|
["Table" t]
|
|
(table config t)
|
|
["Math" s]
|
|
(if html-export?
|
|
(latex/html-export s true true)
|
|
(latex/latex (str (dc/squuid)) s true true))
|
|
["Example" l]
|
|
[:pre.pre-wrap-white-space
|
|
(join-lines l)]
|
|
["Src" options]
|
|
(let [{:keys [language options lines pos_meta]} options
|
|
attr (if language
|
|
{:data-lang language})
|
|
code (join-lines lines)]
|
|
(cond
|
|
html-export?
|
|
(highlight/html-export attr code)
|
|
|
|
:else
|
|
(let [language (if (contains? #{"edn" "clj" "cljc" "cljs" "clojure"} language) "text/x-clojure" language)]
|
|
[:div
|
|
(lazy-editor/editor config (str (dc/squuid)) attr code pos_meta)
|
|
(when (and (= language "clojure") (contains? (set options) ":results"))
|
|
(sci/eval-result code))])))
|
|
["Quote" l]
|
|
(->elem
|
|
:blockquote
|
|
(markup-elements-cp config l))
|
|
["Raw_Html" content]
|
|
(when (not html-export?)
|
|
[:div.raw_html {:dangerouslySetInnerHTML
|
|
{:__html content}}])
|
|
["Export" "html" options content]
|
|
(when (not html-export?)
|
|
[:div.export_html {:dangerouslySetInnerHTML
|
|
{:__html content}}])
|
|
["Hiccup" content]
|
|
(ui/catch-error
|
|
[:div.warning {:title "Invalid hiccup"}
|
|
content]
|
|
(-> (safe-read-string content)
|
|
(security/remove-javascript-links-in-href)))
|
|
|
|
["Export" "latex" options content]
|
|
(if html-export?
|
|
(latex/html-export content true false)
|
|
(latex/latex (str (dc/squuid)) content true false))
|
|
|
|
["Custom" "query" _options result content]
|
|
(try
|
|
(let [query (reader/read-string content)]
|
|
(custom-query config query))
|
|
(catch js/Error e
|
|
(println "read-string error:")
|
|
(js/console.error e)
|
|
[:div.warning {:title "Invalid query"}
|
|
content]))
|
|
|
|
["Custom" "note" options result content]
|
|
(admonition config "note" options result)
|
|
|
|
["Custom" "tip" options result content]
|
|
(admonition config "tip" options result)
|
|
|
|
["Custom" "important" options result content]
|
|
(admonition config "important" options result)
|
|
|
|
["Custom" "caution" options result content]
|
|
(admonition config "caution" options result)
|
|
|
|
["Custom" "warning" options result content]
|
|
(admonition config "warning" options result)
|
|
|
|
["Custom" name options l content]
|
|
(->elem
|
|
:div
|
|
{:class name}
|
|
(markup-elements-cp config l))
|
|
|
|
["Latex_Fragment" l]
|
|
[:p.latex-fragment
|
|
(inline config ["Latex_Fragment" l])]
|
|
|
|
["Latex_Environment" name option content]
|
|
(let [content (latex-environment-content name option content)]
|
|
(if html-export?
|
|
(latex/html-export content true true)
|
|
(latex/latex (str (dc/squuid)) content true true)))
|
|
|
|
["Displayed_Math" content]
|
|
(if html-export?
|
|
(latex/html-export content true true)
|
|
(latex/latex (str (dc/squuid)) content true true))
|
|
|
|
["Footnote_Definition" name definition]
|
|
(let [id (util/url-encode name)]
|
|
[:div.footdef
|
|
[:div.footpara
|
|
(conj
|
|
(markup-element-cp config ["Paragraph" definition])
|
|
[:a.ml-1 {:id (str "fn." id)
|
|
:style {:font-size 14}
|
|
:class "footnum"
|
|
:href (str "#fnr." id)}
|
|
[:sup.fn (str name "↩︎")]])]])
|
|
|
|
:else
|
|
"")
|
|
(catch js/Error e
|
|
(println "Convert to html failed, error: " e)
|
|
"")))
|
|
|
|
(defn markup-elements-cp
|
|
[config col]
|
|
(map #(markup-element-cp config %) col))
|
|
|
|
(defn build-slide-sections
|
|
([blocks config]
|
|
(build-slide-sections blocks config nil))
|
|
([blocks config build-block-fn]
|
|
(when (seq blocks)
|
|
(let [blocks (map #(dissoc % :block/children) blocks)
|
|
first-block-level (:block/level (first blocks))
|
|
sections (reduce
|
|
(fn [acc block]
|
|
(let [block (dissoc block :block/meta)
|
|
level (:block/level block)
|
|
block-cp (if build-block-fn
|
|
(build-block-fn config block)
|
|
(rum/with-key
|
|
(block-container config block)
|
|
(str "slide-" (:block/uuid block))))]
|
|
(if (= first-block-level level)
|
|
;; new slide
|
|
(conj acc [[block block-cp]])
|
|
(update acc (dec (count acc))
|
|
(fn [sections]
|
|
(conj sections [block block-cp]))))))
|
|
[]
|
|
blocks)]
|
|
sections))))
|
|
|
|
(rum/defc add-button < rum/reactive
|
|
[config ref? custom-query? blocks]
|
|
(let [editing (state/sub [:editor/editing?])]
|
|
(when-not (or ref? custom-query?
|
|
(:block/dummy? (last blocks))
|
|
(second (first editing)))
|
|
(when-let [last-block (last blocks)]
|
|
[:a.add-button-link {:on-click (fn []
|
|
(editor-handler/insert-new-block-without-save-previous! config last-block))}
|
|
svg/plus-circle]))))
|
|
|
|
(defn blocks-container
|
|
[blocks config]
|
|
(let [blocks (map #(dissoc % :block/children) blocks)
|
|
sidebar? (:sidebar? config)
|
|
ref? (:ref? config)
|
|
custom-query? (:custom-query? config)
|
|
blocks->vec-tree #(if (or custom-query? ref?) % (db/blocks->vec-tree %))
|
|
blocks (blocks->vec-tree blocks)]
|
|
(when (seq blocks)
|
|
[:div.blocks-container.flex-1
|
|
{:style {:margin-left (cond
|
|
sidebar?
|
|
0
|
|
:else
|
|
-18)}}
|
|
(let [first-block (first blocks)
|
|
blocks' (if (and (:block/pre-block? first-block)
|
|
(db/pre-block-with-only-title? (:block/repo first-block) (:block/uuid first-block)))
|
|
(rest blocks)
|
|
blocks)
|
|
first-id (:block/uuid (first blocks'))]
|
|
(for [item blocks']
|
|
(let [item (-> (if (:block/dummy? item)
|
|
item
|
|
(dissoc item :block/meta)))
|
|
item (if (= first-id (:block/uuid item))
|
|
(assoc item :block/idx 0)
|
|
item)
|
|
config (assoc config :block/uuid (:block/uuid item))]
|
|
(rum/with-key
|
|
(block-container config item)
|
|
(:block/uuid item)))))
|
|
;; (add-button config ref? custom-query? blocks)
|
|
])))
|
|
|
|
;; headers to hiccup
|
|
(defn ->hiccup
|
|
[blocks config option]
|
|
[:div.content
|
|
(cond-> option
|
|
(:document/mode? config)
|
|
(assoc :class "doc-mode"))
|
|
(if (:group-by-page? config)
|
|
[:div.flex.flex-col
|
|
(for [[page blocks] blocks]
|
|
(let [page (db/entity (:db/id page))]
|
|
[:div.my-2 (cond-> {:key (str "page-" (:db/id page))}
|
|
(:ref? config)
|
|
(assoc :class "bg-base-2 px-7 py-2 rounded"))
|
|
(ui/foldable
|
|
(page-cp config page)
|
|
(blocks-container blocks config))]))]
|
|
(blocks-container blocks config))])
|
|
|
|
(comment
|
|
;; timestamps
|
|
;; [2020-02-10 Mon 13:22]
|
|
;; repetition
|
|
(def r1 "<2005-10-01 Sat +1m>")
|
|
;; TODO: mldoc add supports
|
|
(def r2 "<2005-10-01 Sat +1m -3d>")
|
|
|
|
(def l
|
|
"1. First item
|
|
hello world
|
|
2. Second item
|
|
nice
|
|
3. Third item")
|
|
|
|
(def t
|
|
"| Name | Phone | Age |
|
|
|-------+-------+-----|
|
|
| Peter | 1234 | 17 |
|
|
| Anna | 4321 | 25 |"))
|