refactor: separate og and db version (#12276)

separate og and new version apps

remove file sync, tldraw, excalidraw and zotero
This commit is contained in:
Tienson Qin
2025-12-29 15:39:32 +08:00
committed by GitHub
parent 5ff22217d6
commit bcc478b5f7
677 changed files with 2418 additions and 61831 deletions

View File

@@ -11,29 +11,15 @@ frontend.debug/defn
frontend.debug/print
;; Lazily loaded
frontend.extensions.code/editor
;; Lazily loaded
frontend.extensions.age-encryption/keygen
frontend.extensions.age-encryption/encrypt-with-x25519
frontend.extensions.age-encryption/decrypt-with-x25519
frontend.extensions.age-encryption/encrypt-with-user-passphrase
frontend.extensions.age-encryption/decrypt-with-user-passphrase
;; Lazily loaded
frontend.extensions.excalidraw/draw
;; Lazily loaded
frontend.extensions.tldraw/tldraw-app
frontend.extensions.tldraw/generate-preview
;; Referenced in commented TODO
frontend.extensions.pdf.utils/get-page-bounding
;; For repl
frontend.extensions.zotero.api/item
;; For repl
frontend.external.roam/reset-state!
;; For repl
logseq.graph-parser.mldoc/ast-export-markdown
;; Protocol fn wrapper that could be used
frontend.fs/readdir
;; Referenced in TODO
frontend.handler.metadata/update-properties!
frontend.handler.user/<ensure-id&access-token
;; Referenced in comment
frontend.image/get-orientation
;; For debugging

View File

@@ -361,8 +361,7 @@
(is (= (get tag1 ":logseq.property.class/extends") [id2]) "tag1 extends tag2 with db id")
(let [_ (ls-api-call! :editor.addTagExtends id1 id3)
tag1 (ls-api-call! :editor.getTag id1)]
(is (= (get tag1 ":logseq.property.class/extends") [id2 id3]) "tag1 extends tag2,tag3 with db ids"))
)))
(is (= (get tag1 ":logseq.property.class/extends") [id2 id3]) "tag1 extends tag2,tag3 with db ids")))))
(deftest get-tags-by-name-test
(testing "get tags by exact name"

View File

@@ -26,7 +26,6 @@
(->> (common-graph/read-directories dir)
(remove (fn [s] (= s common-config/unlinked-graphs-dir)))
(map graph-name->path)
(map (fn [s]
(if (string/starts-with? s common-config/file-version-prefix)
s
(str common-config/db-version-prefix s)))))))
(keep (fn [s]
(when-not (string/starts-with? s common-config/file-version-prefix)
(str common-config/db-version-prefix s)))))))

View File

@@ -116,11 +116,6 @@
;; :enabled-in-timestamped-blocks false ;don't display logbook at all
;; }
;; File sync options
;; Ignore these files when syncing, regexp is supported.
;; This is _only_ for file graphs.
;; :file-sync/ignore-files []
;; Configure the escaping method for special characters in page titles.
;; This is _only_ for file graphs.
;; Warning:
@@ -391,17 +386,4 @@
;; :redirect-page? false ;; Default value: false
;; :default-page "quick capture"} ;; Default page: "quick capture"
;; Configure the Enter key behavior for
;; context-aware editing with DWIM (Do What I Mean).
;; context-aware Enter key behavior implies that pressing Enter will
;; have different outcomes based on the context.
;; For instance, pressing Enter within a list generates a new list item,
;; whereas pressing Enter in a block reference opens the referenced block.
;; :dwim/settings
;; {:admonition&src? true ;; Default value: true
;; :markup? false ;; Default value: false
;; :block-ref? true ;; Default value: true
;; :page-ref? true ;; Default value: true
;; :properties? true ;; Default value: true
;; :list? false} ;; Default value: false
}

View File

@@ -91,7 +91,7 @@
(defn text-formats
[]
#{:json :org :md :yml :dat :asciidoc :rst :txt :markdown :adoc :html :js :ts :edn :clj :ml :rb :ex :erl :java :php :c :css
:excalidraw :tldr :sh})
:tldr :sh})
(defn img-formats
[]

View File

@@ -82,7 +82,7 @@ Rules:
(re-find #"^\.[^.]+" rpath))))))
(def ^:private allowed-formats
#{:org :markdown :md :edn :json :js :css :excalidraw :tldr})
#{:org :markdown :md :edn :json :js :css})
(defn- get-ext
[p]

View File

@@ -290,16 +290,12 @@
(map :e)
set))
(defn- get-entities-for-all-pages [db sorting property-ident {:keys [db-based?]}]
(defn- get-entities-for-all-pages [db sorting property-ident]
(let [refs-count? (and (coll? sorting) (some (fn [m] (= (:id m) :block.temp/refs-count)) sorting))
exclude-ids (when db-based? (get-exclude-page-ids db))]
exclude-ids (get-exclude-page-ids db)]
(keep (fn [d]
(let [e (entity-plus/unsafe->Entity db (:e d))]
(when-not (if db-based?
(exclude-ids (:db/id e))
(or (ldb/hidden-or-internal-tag? e)
(entity-util/property? e)
(entity-util/built-in? e)))
(when-not (exclude-ids (:db/id e))
(cond-> e
refs-count?
(assoc :block.temp/refs-count (common-initial-data/get-block-refs-count db (:e d)))))))
@@ -311,11 +307,10 @@
view-for-id (or (:db/id view-for) view-for-id*)
non-hidden-e (fn [id] (let [e (d/entity db id)]
(when-not (entity-util/hidden? e)
e)))
db-based? (entity-plus/db-based-graph? db)]
e)))]
(case feat-type
:all-pages
(get-entities-for-all-pages db sorting property-ident {:db-based? db-based?})
(get-entities-for-all-pages db sorting property-ident)
:class-objects
(db-class/get-class-objects db view-for-id)
@@ -443,10 +438,7 @@
:else
(let [view (d/entity db view-id)
group-by-property (:logseq.property.view/group-by-property view)
db-based? (entity-plus/db-based-graph? db)
list-view? (or (= :logseq.property.view/type.list (:db/ident (:logseq.property.view/type view)))
(and (not db-based?)
(contains? #{:linked-references :unlinked-references} view-feature-type)))
list-view? (= :logseq.property.view/type.list (:db/ident (:logseq.property.view/type view)))
group-by-property-ident (or (:db/ident group-by-property) group-by-property-ident)
group-by-closed-values? (some? (:property/closed-values group-by-property))
ref-property? (= (:db/valueType group-by-property) :db.type/ref)

View File

@@ -12,14 +12,12 @@
[logseq.db :as ldb]
[logseq.db.common.entity-plus :as entity-plus]
[logseq.db.common.order :as db-order]
[logseq.db.file-based.schema :as file-schema]
[logseq.db.frontend.class :as db-class]
[logseq.db.frontend.schema :as db-schema]
[logseq.db.sqlite.create-graph :as sqlite-create-graph]
[logseq.db.sqlite.util :as sqlite-util]
[logseq.graph-parser.block :as gp-block]
[logseq.graph-parser.db :as gp-db]
[logseq.graph-parser.property :as gp-property]
[logseq.outliner.batch-tx :include-macros true :as batch-tx]
[logseq.outliner.datascript :as ds]
[logseq.outliner.pipeline :as outliner-pipeline]
@@ -28,8 +26,6 @@
[malli.core :as m]
[malli.util :as mu]))
;; TODO: remove `repo` usage, use db to check `entity-plus/db-based-graph?`
(def ^:private block-map
(mu/optional-keys
[:map
@@ -299,25 +295,23 @@
(extend-type Entity
otree/INode
(-save [this *txs-state db repo _date-formatter {:keys [retract-attributes? retract-attributes outliner-op]
:or {retract-attributes? true}}]
(-save [this *txs-state db {:keys [retract-attributes? retract-attributes outliner-op]
:or {retract-attributes? true}}]
(assert (ds/outliner-txs-state? *txs-state)
"db should be satisfied outliner-tx-state?")
(let [db-based? (sqlite-util/db-based-graph? repo)
(let [db-graph? (entity-plus/db-based-graph? db)
data (if (de/entity? this)
(assoc (.-kv ^js this) :db/id (:db/id this))
this)
data' (if db-based?
(->> (dissoc data :block/properties)
(remove-disallowed-inline-classes db))
data)
data' (->> (dissoc data :block/properties)
(remove-disallowed-inline-classes db))
collapse-or-expand? (= outliner-op :collapse-expand-blocks)
m* (cond->
(-> data'
(dissoc :block/children :block/meta :block/unordered
:block.temp/ast-title :block.temp/ast-body :block/level :block.temp/load-status
:block.temp/has-children?)
(fix-tag-ids db {:db-graph? db-based?}))
(fix-tag-ids db {:db-graph? db-graph?}))
(not collapse-or-expand?)
block-with-updated-at)
db-id (:db/id this)
@@ -325,28 +319,27 @@
eid (or db-id (when block-uuid [:block/uuid block-uuid]))
block-entity (d/entity db eid)
page? (ldb/page? block-entity)
m* (if (and db-based? (:block/title m*)
m* (if (and (:block/title m*)
(not (:logseq.property.node/display-type block-entity)))
(update m* :block/title common-util/clear-markdown-heading)
m*)
block-title (:block/title m*)
page-title-changed? (and page? block-title
(not= block-title (:block/title block-entity)))
_ (when (and db-based? page? block-title)
_ (when (and page? block-title)
(outliner-validate/validate-page-title-characters block-title {:node m*}))
m* (if (and db-based? page-title-changed?)
m* (if page-title-changed?
(let [_ (outliner-validate/validate-page-title (:block/title m*) {:node m*})
page-name (common-util/page-name-sanity-lc (:block/title m*))]
(assoc m* :block/name page-name))
m*)
_ (when (and db-based?
;; page or object changed?
(or (ldb/page? block-entity) (ldb/object? block-entity))
(:block/title m*)
(not= (:block/title m*) (:block/title block-entity)))
_ (when (and ;; page or object changed?
(or (ldb/page? block-entity) (ldb/object? block-entity))
(:block/title m*)
(not= (:block/title m*) (:block/title block-entity)))
(outliner-validate/validate-block-title db (:block/title m*) block-entity))
m (cond-> m*
db-based?
true
(dissoc :block/format :block/pre-block? :block/priority :block/marker :block/properties-order))]
;; Ensure block UUID never changes
(let [e (d/entity db db-id)]
@@ -361,9 +354,7 @@
(when (or (and retract-attributes? (:block/title m))
(seq retract-attributes))
(let [retract-attributes (concat
(if db-based?
db-schema/retract-attributes
file-schema/retract-attributes)
db-schema/retract-attributes
retract-attributes)]
(swap! *txs-state (fn [txs]
(vec
@@ -377,7 +368,7 @@
(update-page-when-save-block *txs-state block-entity m))
;; Remove orphaned refs from block
(when (and (:block/title m) (not= (:block/title m) (:block/title block-entity)))
(remove-orphaned-refs-when-save db *txs-state block-entity m {:db-graph? db-based?})))
(remove-orphaned-refs-when-save db *txs-state block-entity m {:db-graph? db-graph?})))
;; handle others txs
(let [other-tx (:db/other-tx m)]
@@ -387,16 +378,15 @@
(swap! *txs-state conj
(dissoc m :db/other-tx)))
(when (and db-based? (:block/tags block-entity) block-entity)
(when (and (:block/tags block-entity) block-entity)
(let [;; delete tags when title changed
tx-data (remove-tags-when-title-changed block-entity (:block/title m))]
(when (seq tx-data)
(swap! *txs-state (fn [txs] (concat txs tx-data))))))
(when db-based?
(let [tx-data (add-missing-tag-idents db (:block/tags m))]
(when (seq tx-data)
(swap! *txs-state (fn [txs] (concat txs tx-data))))))
(let [tx-data (add-missing-tag-idents db (:block/tags m))]
(when (seq tx-data)
(swap! *txs-state (fn [txs] (concat txs tx-data)))))
this))
@@ -519,7 +509,7 @@
(defn ^:api save-block
"Save the `block`."
[repo db date-formatter block opts]
[db block opts]
{:pre [(map? block)]}
(let [*txs-state (atom [])
block' (if (de/entity? block)
@@ -530,7 +520,7 @@
(let [ent (d/entity db eid)]
(assert (some? ent) "save-block entity not exists")
(merge ent block)))))]
(otree/-save block' *txs-state db repo date-formatter opts)
(otree/-save block' *txs-state db opts)
{:tx-data @*txs-state}))
(defn- get-right-siblings
@@ -543,28 +533,19 @@
rest))))
(defn- blocks-with-ordered-list-props
[repo blocks target-block sibling?]
[blocks target-block sibling?]
(let [target-block (if sibling? target-block (when target-block (ldb/get-down target-block)))
list-type-fn (fn [block]
(if (sqlite-util/db-based-graph? repo)
;; Get raw id since insert-blocks doesn't auto-handle raw property values
(:db/id (:logseq.property/order-list-type block))
(get (:block/properties block) :logseq.order-list-type)))
db-based? (sqlite-util/db-based-graph? repo)]
(:db/id (:logseq.property/order-list-type block)))]
(if-let [list-type (and target-block (list-type-fn target-block))]
(mapv
(fn [{:block/keys [title format] :as block}]
(fn [block]
(let [list?' (and (some? (:block/uuid block))
(nil? (list-type-fn block)))]
(cond-> block
list?'
((fn [b]
(if db-based?
(assoc b :logseq.property/order-list-type list-type)
(update b :block/properties assoc :logseq.order-list-type list-type))))
(not db-based?)
(assoc :block/title (gp-property/insert-property repo format title :logseq.order-list-type list-type)))))
(assoc b :logseq.property/order-list-type list-type))))))
blocks)
blocks)))
@@ -615,13 +596,12 @@
(defn- build-insert-blocks-tx
[db target-block blocks uuids get-new-id {:keys [sibling? outliner-op replace-empty-target? insert-template? keep-block-order?]}]
(let [db-based? (entity-plus/db-based-graph? db)
block-ids (set (map :block/uuid blocks))
(let [block-ids (set (map :block/uuid blocks))
target-page (get-target-block-page target-block sibling?)
orders (get-block-orders blocks target-block sibling? keep-block-order?)]
(map-indexed (fn [idx {:block/keys [parent] :as block}]
(when-let [uuid' (get uuids (:block/uuid block))]
(let [block (if db-based? (remove-disallowed-inline-classes db block) block)
(let [block (remove-disallowed-inline-classes db block)
top-level? (= (:block/level block) 1)
parent (compute-block-parent block parent target-block top-level? sibling? get-new-id outliner-op replace-empty-target? idx)
@@ -790,11 +770,11 @@
to replace it, it defaults to be `false`.
`update-timestamps?`: whether to update `blocks` timestamps.
``"
[repo db blocks target-block {:keys [_sibling? keep-uuid? keep-block-order?
outliner-op outliner-real-op replace-empty-target? update-timestamps?
insert-template?]
:as opts
:or {update-timestamps? true}}]
[db blocks target-block {:keys [_sibling? keep-uuid? keep-block-order?
outliner-op outliner-real-op replace-empty-target? update-timestamps?
insert-template?]
:as opts
:or {update-timestamps? true}}]
{:pre [(seq blocks)
(m/validate block-map-or-entity target-block)]}
(let [blocks (cond->>
@@ -828,16 +808,15 @@
(and sibling?
(:block/title target-block)
(string/blank? (:block/title target-block))
(> (count blocks) 1)))
db-based? (sqlite-util/db-based-graph? repo)]
(> (count blocks) 1)))]
(when (seq blocks)
(let [blocks' (let [blocks' (blocks-with-level blocks)]
(cond->> (blocks-with-ordered-list-props repo blocks' target-block sibling?)
(cond->> (blocks-with-ordered-list-props blocks' target-block sibling?)
update-timestamps?
(mapv #(dissoc % :block/created-at :block/updated-at))
true
(mapv block-with-timestamps)
db-based?
true
(mapv #(-> % (dissoc :block/properties)))))
insert-opts {:sibling? sibling?
:replace-empty-target? replace-empty-target?
@@ -999,8 +978,8 @@
(defn- move-blocks
"Move `blocks` to `target-block` as siblings or children."
[_repo conn blocks target-block {:keys [_sibling? _top? _bottom? _up? outliner-op _indent?]
:as opts}]
[conn blocks target-block {:keys [_sibling? _top? _bottom? _up? outliner-op _indent?]
:as opts}]
{:pre [(seq blocks)
(m/validate block-map-or-entity target-block)]}
(let [db @conn
@@ -1039,7 +1018,7 @@
(defn- move-blocks-up-down
"Move blocks up/down."
[repo conn blocks up?]
[conn blocks up?]
{:pre [(seq blocks) (boolean? up?)]}
(let [db @conn
top-level-blocks (filter-top-level-blocks db blocks)
@@ -1058,8 +1037,8 @@
(:db/id left-left))
(not (and (:logseq.property/created-from-property first-block)
(nil? first-block-left-sibling))))
(move-blocks repo conn top-level-blocks left-left (merge opts {:sibling? sibling?
:up? up?}))))
(move-blocks conn top-level-blocks left-left (merge opts {:sibling? sibling?
:up? up?}))))
(let [last-top-block (last top-level-blocks)
last-top-block-right (ldb/get-right-sibling last-top-block)
@@ -1072,12 +1051,12 @@
(when (and right
(not (and (:logseq.property/created-from-property last-top-block)
(nil? last-top-block-right))))
(move-blocks repo conn blocks right (merge opts {:sibling? sibling?
:up? up?})))))))
(move-blocks conn blocks right (merge opts {:sibling? sibling?
:up? up?})))))))
(defn- ^:large-vars/cleanup-todo indent-outdent-blocks
"Indent or outdent `blocks`."
[repo conn blocks indent? & {:keys [parent-original logical-outdenting?]}]
[conn blocks indent? & {:keys [parent-original logical-outdenting?]}]
{:pre [(seq blocks) (boolean? indent?)]}
(let [db @conn
top-level-blocks (filter-top-level-blocks db blocks)
@@ -1105,30 +1084,30 @@
(when (seq blocks')
(if last-direct-child-id
(let [last-direct-child (d/entity db last-direct-child-id)
result (move-blocks repo conn blocks' last-direct-child (merge opts {:sibling? true
:indent? true}))
result (move-blocks conn blocks' last-direct-child (merge opts {:sibling? true
:indent? true}))
;; expand `left` if it's collapsed
collapsed-tx (when (:block/collapsed? left)
{:tx-data [{:db/id (:db/id left)
:block/collapsed? false}]})]
(concat-tx-fn result collapsed-tx))
(move-blocks repo conn blocks' left (merge opts {:sibling? false
:indent? true}))))))
(move-blocks conn blocks' left (merge opts {:sibling? false
:indent? true}))))))
(if parent-original
(let [blocks' (take-while (fn [b]
(not= (:db/id (:block/parent b))
(:db/id (:block/parent parent))))
top-level-blocks)]
(move-blocks repo conn blocks' parent-original (merge opts {:outliner-op :indent-outdent-blocks
:sibling? true
:indent? false})))
(move-blocks conn blocks' parent-original (merge opts {:outliner-op :indent-outdent-blocks
:sibling? true
:indent? false})))
(when parent
(let [blocks' (take-while (fn [b]
(not= (:db/id (:block/parent b))
(:db/id (:block/parent parent))))
top-level-blocks)
result (move-blocks repo conn blocks' parent (merge opts {:sibling? true}))]
result (move-blocks conn blocks' parent (merge opts {:sibling? true}))]
(if logical-outdenting?
result
;; direct outdenting (default behavior)
@@ -1136,8 +1115,8 @@
right-siblings (get-right-siblings last-top-block)]
(if (seq right-siblings)
(if-let [last-direct-child-id (ldb/get-block-last-direct-child-id db (:db/id last-top-block))]
(move-blocks repo conn right-siblings (d/entity db last-direct-child-id) (merge opts {:sibling? true}))
(move-blocks repo conn right-siblings last-top-block (merge opts {:sibling? false})))
(move-blocks conn right-siblings (d/entity db last-direct-child-id) (merge opts {:sibling? true}))
(move-blocks conn right-siblings last-top-block (merge opts {:sibling? false})))
result)))))))))))
;;; ### write-operations have side-effects (do transactions) ;;;;;;;;;;;;;;;;
@@ -1150,44 +1129,45 @@
(when result
(let [tx-meta (assoc (:tx-meta result)
:outliner-op outliner-op)]
(ldb/transact! (second args) (:tx-data result) tx-meta)))
(ldb/transact! (first args) (:tx-data result) tx-meta)))
result)
(catch :default e
(js/console.error e)
(when-not (= "not-allowed-move-block-page" (ex-message e))
(throw e)))))
(let [f (fn [repo conn date-formatter block opts]
(save-block repo @conn date-formatter block opts))]
(let [f (fn [conn block opts]
(save-block @conn block opts))]
(defn save-block!
[repo conn date-formatter block & {:as opts}]
(op-transact! :save-block f repo conn date-formatter block opts)))
[conn block & {:as opts}]
(op-transact! :save-block f conn block opts)))
(let [f (fn [repo conn blocks target-block opts]
(insert-blocks repo @conn blocks target-block opts))]
(let [f (fn [conn blocks target-block opts]
(insert-blocks @conn blocks target-block opts))]
(defn insert-blocks!
[repo conn blocks target-block opts]
(op-transact! :insert-blocks f repo conn blocks target-block
[conn blocks target-block opts]
(op-transact! :insert-blocks f conn blocks target-block
(if (:outliner-op opts)
opts
(assoc opts :outliner-op :insert-blocks)))))
(let [f (fn [_repo conn blocks _opts]
(let [f (fn [conn blocks _opts]
(delete-blocks @conn blocks))]
(defn delete-blocks!
[repo conn _date-formatter blocks opts]
(op-transact! :delete-blocks f repo conn blocks opts)))
[conn blocks opts]
(op-transact! :delete-blocks f conn blocks opts)))
(defn move-blocks!
[repo conn blocks target-block opts]
(op-transact! :move-blocks move-blocks repo conn blocks target-block
[conn blocks target-block opts]
(op-transact! :move-blocks move-blocks conn blocks target-block
(if (:outliner-op opts)
opts
(assoc opts :outliner-op :move-blocks))))
(defn move-blocks-up-down!
[repo conn blocks up?]
(op-transact! :move-blocks-up-down move-blocks-up-down repo conn blocks up?))
[conn blocks up?]
(op-transact! :move-blocks-up-down move-blocks-up-down conn blocks up?))
(defn indent-outdent-blocks!
[repo conn blocks indent? & {:as opts}]
(op-transact! :indent-outdent-blocks indent-outdent-blocks repo conn blocks indent? opts))
[conn blocks indent? & {:as opts}]
(op-transact! :indent-outdent-blocks indent-outdent-blocks conn blocks indent? opts))

View File

@@ -1,7 +1,6 @@
(ns logseq.outliner.op
"Transact outliner ops"
(:require [clojure.string :as string]
[datascript.core :as d]
(:require [datascript.core :as d]
[logseq.db :as ldb]
[logseq.db.sqlite.export :as sqlite-export]
[logseq.outliner.core :as outliner-core]
@@ -169,53 +168,49 @@
(reset! *result {:error (str "Unexpected Import EDN error: " (pr-str (ex-message e)))}))))))
(defn ^:large-vars/cleanup-todo apply-ops!
[repo conn ops date-formatter opts]
[conn ops opts]
(assert (ops-validator ops) ops)
(let [opts' (assoc opts
:transact-opts {:conn conn}
:local-tx? true)
*result (atom nil)
db-based? (ldb/db-based-graph? @conn)]
*result (atom nil)]
(outliner-tx/transact!
opts'
(doseq [[op args] ops]
(when-not db-based?
(assert (not (or (string/includes? (name op) "property") (string/includes? (name op) "closed-value")))
(str "Property related ops are only for db based graphs, ops: " ops)))
(case op
;; blocks
:save-block
(apply outliner-core/save-block! repo conn date-formatter args)
(apply outliner-core/save-block! conn args)
:insert-blocks
(let [[blocks target-block-id opts] args]
(when-let [target-block (d/entity @conn target-block-id)]
(let [result (outliner-core/insert-blocks! repo conn blocks target-block opts)]
(let [result (outliner-core/insert-blocks! conn blocks target-block opts)]
(reset! *result result))))
:delete-blocks
(let [[block-ids opts] args
blocks (keep #(d/entity @conn %) block-ids)]
(outliner-core/delete-blocks! repo conn date-formatter blocks (merge opts opts')))
(outliner-core/delete-blocks! conn blocks (merge opts opts')))
:move-blocks
(let [[block-ids target-block-id opts] args
blocks (keep #(d/entity @conn %) block-ids)
target-block (d/entity @conn target-block-id)]
(when (and target-block (seq blocks))
(outliner-core/move-blocks! repo conn blocks target-block opts)))
(outliner-core/move-blocks! conn blocks target-block opts)))
:move-blocks-up-down
(let [[block-ids up?] args
blocks (keep #(d/entity @conn %) block-ids)]
(when (seq blocks)
(outliner-core/move-blocks-up-down! repo conn blocks up?)))
(outliner-core/move-blocks-up-down! conn blocks up?)))
:indent-outdent-blocks
(let [[block-ids indent? opts] args
blocks (keep #(d/entity @conn %) block-ids)]
(when (seq blocks)
(outliner-core/indent-outdent-blocks! repo conn blocks indent? opts)))
(outliner-core/indent-outdent-blocks! conn blocks indent? opts)))
;; properties
:upsert-property
@@ -264,6 +259,6 @@
(apply ldb/transact! conn args)
(when-let [handler (get @*op-handlers op)]
(reset! *result (handler repo conn args))))))
(reset! *result (handler conn args))))))
@*result))

View File

@@ -313,9 +313,9 @@
page-txs)
tx-meta (cond-> {:persist-op? persist-op?
:outliner-op :create-page}
today-journal?
(assoc :create-today-journal? true
:today-journal-name title))]
today-journal?
(assoc :create-today-journal? true
:today-journal-name title))]
{:tx-meta tx-meta
:tx-data txs
:title title

View File

@@ -6,7 +6,7 @@
[logseq.db.common.property-util :as db-property-util]))
(defprotocol INode
(-save [this *txs-state conn repo date-formatter opts])
(-save [this *txs-state conn opts])
(-del [this *txs-state db]))
(defn- blocks->vec-tree-aux

View File

@@ -13,7 +13,7 @@
property-value (:user.property/default (db-test/find-block-by-content @conn "b1"))
_ (assert (:db/id property-value))
block (db-test/find-block-by-content @conn "b1")]
(outliner-core/delete-blocks! nil conn nil [block] {})
(outliner-core/delete-blocks! conn [block] {})
(is (nil? (db-test/find-block-by-content @conn "b1")))
(is (nil? (db-test/find-block-by-content @conn "test block"))))))
@@ -32,7 +32,7 @@
:block/parent (:db/id page1)}])
b3 (db-test/find-block-by-content @conn "b3")
b4 (db-test/find-block-by-content @conn "b4")]
(outliner-core/delete-blocks! nil conn nil [b3 b4 page2] {})
(outliner-core/delete-blocks! conn [b3 b4 page2] {})
(is (some? (db-test/find-block-by-content @conn "b3")))
(is (some? (db-test/find-block-by-content @conn "b4")))
(let [page2' (ldb/get-page @conn "page2")]

View File

@@ -8,7 +8,7 @@
(def ^:api js-files
"js files from publishing release build"
(->> ["main.js" "code-editor.js" "excalidraw.js" "tldraw.js"]
(->> ["main.js" "code-editor.js"]
;; Add source maps for all js files as it doesn't affect initial load time
(mapcat #(vector % (str % ".map")))
vec))

View File

@@ -65,10 +65,6 @@ const common = {
// NOTE: All assets from node_modules are copied to the output directory
syncAssetFiles (...params) {
return gulp.series(
() => gulp.src([
'./node_modules/@excalidraw/excalidraw/dist/excalidraw-assets/**',
'!**/*/i18n-*.js',
]).pipe(gulp.dest(path.join(outputPath, 'js', 'excalidraw-assets'))),
() => gulp.src([
'node_modules/katex/dist/katex.min.js',
'node_modules/katex/dist/contrib/mhchem.min.js',

View File

@@ -98,7 +98,7 @@ extension URL {
}
func shouldNotifyWithContent() -> Bool {
let allowedPathExtensions: Set = ["md", "markdown", "org", "js", "edn", "css", "excalidraw"]
let allowedPathExtensions: Set = ["md", "markdown", "org", "js", "edn", "css"]
if allowedPathExtensions.contains(self.pathExtension.lowercased()) {
return true
}

View File

@@ -283,8 +283,6 @@ export type ExternalCommandType =
| 'logseq.editor/up'
| 'logseq.editor/expand-block-children'
| 'logseq.editor/collapse-block-children'
| 'logseq.editor/open-file-in-default-app'
| 'logseq.editor/open-file-in-directory'
| 'logseq.editor/select-all-blocks'
| 'logseq.editor/toggle-open-blocks'
| 'logseq.editor/zoom-in'

View File

@@ -102,10 +102,9 @@
"cljs:lint": "clojure -M:clj-kondo --parallel --lint src --cache false",
"ios:dev": "cross-env PLATFORM=ios gulp cap",
"android:dev": "cross-env PLATFORM=android gulp cap",
"tldraw:build": "yarn --cwd packages/tldraw install",
"amplify:build": "yarn --cwd packages/amplify install",
"ui:build": "yarn --cwd packages/ui install",
"postinstall": "yarn tldraw:build && yarn ui:build"
"postinstall": "yarn ui:build"
},
"dependencies": {
"@aparajita/capacitor-secure-storage": "^7.1.6",
@@ -131,7 +130,6 @@
"@dnd-kit/sortable": "^7.0.2",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.16.1",
"@glidejs/glide": "^3.6.0",
"@highlightjs/cdn-assets": "10.4.1",
"@huggingface/transformers": "^3.6.3",
@@ -139,7 +137,6 @@
"@js-joda/core": "3.2.0",
"@js-joda/locale_en-us": "3.1.1",
"@js-joda/timezone": "2.5.0",
"@logseq/diff-merge": "^0.2.2",
"@logseq/react-tweet-embed": "1.3.1-1",
"@logseq/simple-wave-record": "^0.0.3",
"@radix-ui/colors": "^0.1.8",
@@ -150,13 +147,11 @@
"@tabler/icons-webfont": "^2.47.0",
"@tippyjs/react": "4.2.5",
"bignumber.js": "^9.0.2",
"check-password-strength": "2.0.7",
"chokidar": "3.5.1",
"chrono-node": "2.2.4",
"codemirror": "5.65.18",
"comlink": "^4.4.1",
"d3-force": "3.0.0",
"diff": "5.0.0",
"dompurify": "2.4.0",
"emoji-mart": "^5.5.2",
"fs": "0.0.1-security",

View File

@@ -1,19 +0,0 @@
# https://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
trim_trailing_whitespace = true
[*.md]
max_line_length = 0
trim_trailing_whitespace = false
[COMMIT_EDITMSG]
max_line_length = 0

View File

@@ -1,3 +0,0 @@
**/node_modules/*
**/out/*
**/.next/*

View File

@@ -1,19 +0,0 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
"ignorePatterns": ["*.js", "*.jsx"],
"overrides": [
{
// enable the rule specifically for TypeScript files
"files": ["*.ts", "*.tsx"],
"rules": {
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/camelcase": "off"
}
}
]
}

View File

@@ -1,2 +0,0 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

@@ -1,17 +0,0 @@
node_modules/
build/
dist/
docs/
.idea/*
.DS_Store
coverage
*.log
.vercel
.next
apps/www/public/workbox-*
apps/www/public/worker-*
apps/www/public/sw.js
apps/www/public/sw.js.map
.env

View File

@@ -1,17 +0,0 @@
/.github/
/.vscode/
/node_modules/
/build/
/tmp/
.idea/*
/docs/
coverage
*.log
.gitlab-ci.yml
package-lock.json
/*.tgz
/tmp*
/mnt/
/package/

View File

@@ -1,11 +0,0 @@
{
"trailingComma": "es5",
"singleQuote": true,
"semi": false,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"jsxBracketSameLine": false,
"arrowParens": "avoid"
}

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Stephen Ruiz Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,29 +0,0 @@
# Developer Notes
## Background
This folder contains the JS codes for a custom build of Tldraw to fit the needs of Logseq, which originates from an abandoned next branch from the author of Tldraw.
## Development
### Prerequisites
Modern JS eco tools like Node.js and yarn.
### Run in dev mode
- install dependencies with `yarn`
- run dev mode with `yarn dev`, which will start a Vite server at http://127.0.0.1:3031/
Note, the dev mode is a standalone web app running a demo Tldraw app in `tldraw/demo/src/App.jsx`. The Logseq component renderers and handlers are all mocked to make sure Tldraw only functions can be isolatedly developed.
## Other useful commands
- fixing styles: `yarn fix:style`
- build: `yarn build`
## How it works
### Data flow between Tldraw & Logseq
The data flow between Tldraw & Logseq can be found here: https://whimsical.com/9sdt5j7MabK6DVrxgTZw25

View File

@@ -1,3 +0,0 @@
# @tldraw/core Simple Example
A (relatively) simple example project for `@tldraw/core`.

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env zx
/* eslint-disable no-undef */
import 'zx/globals'
import fs from 'fs'
import path from 'path'
if (process.platform === 'win32') {
defaults.shell = "cmd.exe";
defaults.prefix = "";
}
// Build with [tsup](https://tsup.egoist.sh)
await $`npx tsup`
// Prepare package.json file
const packageJson = fs.readFileSync('package.json', 'utf8')
const glob = JSON.parse(packageJson)
Object.assign(glob, {
main: './index.js',
module: './index.mjs',
})
fs.writeFileSync('dist/package.json', JSON.stringify(glob, null, 2))
const dest = path.join(__dirname, '/../../../../src/main/frontend/tldraw-logseq.js')
if (fs.existsSync(dest)) fs.unlinkSync(dest)
fs.linkSync(path.join(__dirname, '/dist/index.js'), dest)

View File

@@ -1,42 +0,0 @@
{
"version": "0.0.0-dev",
"name": "@tldraw/logseq",
"license": "MIT",
"module": "./src/index.ts",
"scripts": {
"build": "zx build.mjs",
"build:packages": "yarn build",
"dev": "tsup --watch",
"dev:vite": "tsup --watch --sourcemap inline"
},
"devDependencies": {
"@radix-ui/react-context-menu": "^2.1.0",
"@tldraw/core": "2.0.0-alpha.1",
"@tldraw/react": "2.0.0-alpha.1",
"@tldraw/vec": "2.0.0-alpha.1",
"@types/node": "^18.13.0",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"autoprefixer": "^10.4.13",
"concurrently": "^7.5.0",
"esbuild": "^0.15.14",
"mobx": "^6.7.0",
"mobx-react-lite": "^3.4.0",
"perfect-freehand": "^1.2.0",
"polished": "^4.0.0",
"postcss": "^8.4.19",
"lucide-react": "^0.292.0",
"react": "^17.0.0",
"react-dom": "^17.0.0",
"react-virtuoso": "^3.1.3",
"rimraf": "3.0.2",
"shadow-cljs": "^2.20.11",
"tsup": "^6.5.0",
"typescript": "^4.9.3",
"zx": "^7.2.2"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
}

View File

@@ -1,3 +0,0 @@
module.exports = ctx => ({
plugins: [require('autoprefixer')()],
})

View File

@@ -1,148 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { TLDocumentModel } from '@tldraw/core'
import {
AppCanvas,
AppProvider,
TLReactCallbacks,
TLReactToolConstructor,
useApp,
} from '@tldraw/react'
import * as React from 'react'
import { AppUI } from './components/AppUI'
import { ContextBar } from './components/ContextBar'
import { ContextMenu } from './components/ContextMenu'
import { QuickLinks } from './components/QuickLinks'
import { useDrop } from './hooks/useDrop'
import { usePaste } from './hooks/usePaste'
import { useCopy } from './hooks/useCopy'
import { useQuickAdd } from './hooks/useQuickAdd'
import {
BoxTool,
EllipseTool,
HighlighterTool,
HTMLTool,
IFrameTool,
LineTool,
LogseqPortalTool,
NuEraseTool,
PencilTool,
PolygonTool,
shapes,
TextTool,
YouTubeTool,
type Shape,
} from './lib'
import { LogseqContext, type LogseqContextValue } from './lib/logseq-context'
const tools: TLReactToolConstructor<Shape>[] = [
BoxTool,
EllipseTool,
PolygonTool,
NuEraseTool,
HighlighterTool,
LineTool,
PencilTool,
TextTool,
YouTubeTool,
IFrameTool,
HTMLTool,
LogseqPortalTool,
]
interface LogseqTldrawProps {
renderers: LogseqContextValue['renderers']
handlers: LogseqContextValue['handlers']
readOnly: boolean
model?: TLDocumentModel<Shape>
onMount?: TLReactCallbacks<Shape>['onMount']
onPersist?: TLReactCallbacks<Shape>['onPersist']
}
const BacklinksCount: LogseqContextValue['renderers']['BacklinksCount'] = props => {
const { renderers } = React.useContext(LogseqContext)
const options = { 'portal?': false }
return <renderers.BacklinksCount {...props} options={options} />
}
const AppImpl = () => {
const ref = React.useRef<HTMLDivElement>(null)
const app = useApp()
const components = React.useMemo(
() => ({
ContextBar,
BacklinksCount,
QuickLinks,
}),
[]
)
return (
<ContextMenu collisionRef={ref}>
<div ref={ref} className="logseq-tldraw logseq-tldraw-wrapper" data-tlapp={app.uuid}>
<AppCanvas components={components}>
<AppUI />
</AppCanvas>
</div>
</ContextMenu>
)
}
const AppInner = ({
onPersist,
readOnly,
model,
...rest
}: Omit<LogseqTldrawProps, 'renderers' | 'handlers'>) => {
const onDrop = useDrop()
const onPaste = usePaste()
const onCopy = useCopy()
const onQuickAdd = readOnly ? null : useQuickAdd()
const onPersistOnDiff: TLReactCallbacks<Shape>['onPersist'] = React.useCallback(
(app, info) => {
onPersist?.(app, info)
},
[model]
)
return (
<AppProvider
Shapes={shapes}
Tools={tools}
onDrop={onDrop}
onPaste={onPaste}
onCopy={onCopy}
readOnly={readOnly}
onCanvasDBClick={onQuickAdd}
onPersist={onPersistOnDiff}
model={model}
{...rest}
>
<AppImpl />
</AppProvider>
)
}
export const App = function App({ renderers, handlers, ...rest }: LogseqTldrawProps): JSX.Element {
const memoRenders: any = React.useMemo(() => {
return Object.fromEntries(
Object.entries(renderers).map(([key, comp]) => {
return [key, React.memo(comp)]
})
)
}, [])
const contextValue = {
renderers: memoRenders,
handlers: handlers,
}
return (
<LogseqContext.Provider value={contextValue}>
<AppInner {...rest} />
</LogseqContext.Provider>
)
}

View File

@@ -1,113 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import type { Shape } from '../../lib'
import { TablerIcon } from '../icons'
import { Button } from '../Button'
import { ToggleInput } from '../inputs/ToggleInput'
import { ZoomMenu } from '../ZoomMenu'
import { LogseqContext } from '../../lib/logseq-context'
// @ts-ignore
const LSUI = window.LSUI
export const ActionBar = observer(function ActionBar(): JSX.Element {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const undo = React.useCallback(() => {
app.api.undo()
}, [app])
const redo = React.useCallback(() => {
app.api.redo()
}, [app])
const zoomIn = React.useCallback(() => {
app.api.zoomIn()
}, [app])
const zoomOut = React.useCallback(() => {
app.api.zoomOut()
}, [app])
const toggleGrid = React.useCallback(() => {
app.api.toggleGrid()
}, [app])
const toggleSnapToGrid = React.useCallback(() => {
app.api.toggleSnapToGrid()
}, [app])
const togglePenMode = React.useCallback(() => {
app.api.togglePenMode()
}, [app])
return (
<div className="tl-action-bar" data-html2canvas-ignore="true">
{!app.readOnly && (
<div className="tl-toolbar tl-history-bar mr-2 mb-2">
<Button tooltip={t('whiteboard/undo')} onClick={undo}>
<TablerIcon name="arrow-back-up" />
</Button>
<Button tooltip={t('whiteboard/redo')} onClick={redo}>
<TablerIcon name="arrow-forward-up" />
</Button>
</div>
)}
<div className={'tl-toolbar tl-zoom-bar mr-2 mb-2'}>
<Button tooltip={t('whiteboard/zoom-in')} onClick={zoomIn} id="tl-zoom-in">
<TablerIcon name="plus" />
</Button>
<Button tooltip={t('whiteboard/zoom-out')} onClick={zoomOut} id="tl-zoom-out">
<TablerIcon name="minus" />
</Button>
<LSUI.Separator orientation="vertical" />
<ZoomMenu />
</div>
<div className={'tl-toolbar tl-grid-bar mr-2 mb-2'}>
<ToggleInput
tooltip={t('whiteboard/toggle-grid')}
className="tl-button"
pressed={app.settings.showGrid}
id="tl-show-grid"
onPressedChange={toggleGrid}
>
<TablerIcon name="grid-dots" />
</ToggleInput>
{!app.readOnly && (
<ToggleInput
tooltip={t('whiteboard/snap-to-grid')}
className="tl-button"
pressed={app.settings.snapToGrid}
id="tl-snap-to-grid"
onPressedChange={toggleSnapToGrid}
>
<TablerIcon name={app.settings.snapToGrid ? "magnet" : "magnet-off"} />
</ToggleInput>
)}
</div>
{!app.readOnly && (
<div className="tl-toolbar tl-pen-mode-bar mb-2">
<ToggleInput
tooltip={t('whiteboard/toggle-pen-mode')}
className="tl-button"
pressed={app.settings.penMode}
id="tl-toggle-pen-mode"
onPressedChange={togglePenMode}
>
<TablerIcon name={app.settings.penMode ? "pencil" : "pencil-off"} />
</ToggleInput>
</div>
)}
</div>
)
})

View File

@@ -1 +0,0 @@
export * from './ActionBar'

View File

@@ -1,20 +0,0 @@
import { observer } from 'mobx-react-lite'
import { ActionBar } from './ActionBar'
import { DevTools } from './Devtools'
import { PrimaryTools } from './PrimaryTools'
import { StatusBar } from './StatusBar'
import { isDev } from '@tldraw/core'
import { useApp } from '@tldraw/react'
export const AppUI = observer(function AppUI() {
const app = useApp()
return (
<>
{isDev() && <StatusBar />}
{isDev() && <DevTools />}
{!app.readOnly && <PrimaryTools />}
<ActionBar />
</>
)
})

View File

@@ -1,71 +0,0 @@
import { validUUID } from '@tldraw/core'
import React from 'react'
import { LogseqContext } from '../../lib/logseq-context'
import { TablerIcon } from '../icons'
export const BlockLink = ({
id,
showReferenceContent = false,
}: {
id: string
showReferenceContent?: boolean
}) => {
const {
handlers: { isWhiteboardPage, redirectToPage, sidebarAddBlock, queryBlockByUUID },
renderers: { Breadcrumb, PageName },
} = React.useContext(LogseqContext)
let iconName = ''
let linkType = validUUID(id) ? 'B' : 'P'
let blockContent = ''
if (validUUID(id)) {
const block = queryBlockByUUID(id)
if (!block) {
return <span className="p-2">Invalid reference. Did you remove it?</span>
}
blockContent = block.title
if (block.properties?.['ls-type'] === 'whiteboard-shape') {
iconName = 'link-to-whiteboard'
} else {
iconName = 'link-to-block'
}
} else {
if (isWhiteboardPage(id)) {
iconName = 'link-to-whiteboard'
} else {
iconName = 'link-to-page'
}
}
const slicedContent =
blockContent && blockContent.length > 23 ? blockContent.slice(0, 20) + '...' : blockContent
return (
<button
className="inline-flex gap-1 items-center w-full"
onPointerDown={e => {
e.stopPropagation()
if (e.shiftKey) {
sidebarAddBlock(id, linkType === 'B' ? 'block' : 'page')
} else {
redirectToPage(id)
}
}}
>
<TablerIcon name={iconName} />
<span className="pointer-events-none block-link-reference-row">
{linkType === 'P' ? (
<PageName pageName={id} />
) : (
<>
<Breadcrumb levelLimit={1} blockId={id} endSeparator={showReferenceContent} />
{showReferenceContent && slicedContent}
</>
)}
</span>
</button>
)
}

View File

@@ -1 +0,0 @@
export * from './BlockLink'

View File

@@ -1,15 +0,0 @@
import { Tooltip } from '../Tooltip'
import type { Side } from '@radix-ui/react-popper'
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode
tooltip?: React.ReactNode
tooltipSide?: Side
}
export function Button({ className, tooltip, tooltipSide, ...rest }: ButtonProps) {
return (
<Tooltip content={tooltip} side={tooltipSide}>
<button className={'tl-button ' + (className ?? '')} {...rest} />
</Tooltip>
)
}

View File

@@ -1,26 +0,0 @@
import { TablerIcon } from '../icons'
export const CircleButton = ({
style,
icon,
onClick,
}: {
active?: boolean
style?: React.CSSProperties
icon: string
otherIcon?: string
onClick: () => void
}) => {
return (
<button
data-html2canvas-ignore="true"
style={style}
className="tl-circle-button"
onPointerDown={onClick}
>
<div className="tl-circle-button-icons-wrapper">
<TablerIcon name={icon} />
</div>
</button>
)
}

View File

@@ -1,2 +0,0 @@
export * from './Button'
export * from './CircleButton'

View File

@@ -1,67 +0,0 @@
import {
getContextBarTranslation,
HTMLContainer,
TLContextBarComponent,
useApp,
} from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import type { Shape } from '../../lib'
import { getContextBarActionsForShapes } from './contextBarActionFactory'
// @ts-ignore
const LSUI = window.LSUI
const _ContextBar: TLContextBarComponent<Shape> = ({ shapes, offsets, hidden }) => {
const app = useApp()
const rSize = React.useRef<[number, number] | null>(null)
const rContextBar = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => {
setTimeout(() => {
const elm = rContextBar.current
if (!elm) return
const { offsetWidth, offsetHeight } = elm
rSize.current = [offsetWidth, offsetHeight]
})
})
React.useLayoutEffect(() => {
const elm = rContextBar.current
if (!elm) return
const size = rSize.current ?? [0, 0]
const [x, y] = getContextBarTranslation(size, offsets)
elm.style.transform = `translateX(${x}px) translateY(${y}px)`
}, [offsets])
if (!app) return null
const Actions = getContextBarActionsForShapes(shapes)
return (
<HTMLContainer centered>
{Actions.length > 0 && (
<div
ref={rContextBar}
className="tl-toolbar tl-context-bar"
style={{
visibility: hidden ? 'hidden' : 'visible',
pointerEvents: hidden ? 'none' : 'all',
}}
>
{Actions.map((Action, idx) => (
<React.Fragment key={idx}>
<Action />
{idx < Actions.length - 1 && (
<LSUI.Separator className="tl-toolbar-separator" orientation="vertical" />
)}
</React.Fragment>
))}
</div>
)}
</HTMLContainer>
)
}
export const ContextBar = observer(_ContextBar)

View File

@@ -1,560 +0,0 @@
import { Decoration, isNonNullable } from '@tldraw/core'
import { useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import React from 'react'
import type {
BoxShape,
EllipseShape,
HTMLShape,
IFrameShape,
LineShape,
LogseqPortalShape,
PencilShape,
PolygonShape,
Shape,
TextShape,
YouTubeShape,
TweetShape,
} from '../../lib'
import { Button } from '../Button'
import { TablerIcon } from '../icons'
import { ColorInput } from '../inputs/ColorInput'
import { ScaleInput } from '../inputs/ScaleInput'
import { ShapeLinksInput } from '../inputs/ShapeLinksInput'
import { TextInput } from '../inputs/TextInput'
import {
ToggleGroupInput,
ToggleGroupMultipleInput,
type ToggleGroupInputOption,
} from '../inputs/ToggleGroupInput'
import { ToggleInput } from '../inputs/ToggleInput'
import { GeometryTools } from '../GeometryTools'
import { LogseqContext } from '../../lib/logseq-context'
import { KeyboardShortcut } from '../KeyboardShortcut'
export const contextBarActionTypes = [
// Order matters
'EditPdf',
'LogseqPortalViewMode',
'Geometry',
'AutoResizing',
'Swatch',
'NoFill',
'StrokeType',
'ScaleLevel',
'TextStyle',
'YoutubeLink',
'TwitterLink',
'IFrameSource',
'ArrowMode',
'Links',
] as const
type ContextBarActionType = typeof contextBarActionTypes[number]
const singleShapeActions: ContextBarActionType[] = [
'YoutubeLink',
'TwitterLink',
'IFrameSource',
'Links',
'EditPdf',
]
const contextBarActionMapping = new Map<ContextBarActionType, React.FC>()
type ShapeType = Shape['props']['type']
export const shapeMapping: Record<ShapeType, ContextBarActionType[]> = {
'logseq-portal': ['Swatch', 'LogseqPortalViewMode', 'ScaleLevel', 'AutoResizing', 'Links'],
youtube: ['YoutubeLink', 'Links'],
tweet: ['TwitterLink', 'Links'],
iframe: ['IFrameSource', 'Links'],
box: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
ellipse: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
polygon: ['Geometry', 'TextStyle', 'Swatch', 'ScaleLevel', 'NoFill', 'StrokeType', 'Links'],
line: ['TextStyle', 'Swatch', 'ScaleLevel', 'ArrowMode', 'Links'],
pencil: ['Swatch', 'Links', 'ScaleLevel'],
highlighter: ['Swatch', 'Links', 'ScaleLevel'],
text: ['TextStyle', 'Swatch', 'ScaleLevel', 'AutoResizing', 'Links'],
html: ['ScaleLevel', 'AutoResizing', 'Links'],
image: ['Links'],
video: ['Links'],
pdf: ['EditPdf', 'Links'],
}
export const withFillShapes = Object.entries(shapeMapping)
.filter(([key, types]) => {
return types.includes('NoFill') && types.includes('Swatch')
})
.map(([key]) => key) as ShapeType[]
function filterShapeByAction<S extends Shape>(type: ContextBarActionType) {
const app = useApp<Shape>()
const unlockedSelectedShapes = app.selectedShapesArray.filter(s => !s.props.isLocked)
return unlockedSelectedShapes.filter(shape => shapeMapping[shape.props.type]?.includes(type))
}
const AutoResizingAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<LogseqPortalShape | TextShape | HTMLShape>('AutoResizing')
const pressed = shapes.every(s => s.props.isAutoResizing)
return (
<ToggleInput
tooltip={t('whiteboard/auto-resize')}
toggle={shapes.every(s => s.props.type === 'logseq-portal')}
className="tl-button"
pressed={pressed}
onPressedChange={v => {
shapes.forEach(s => {
if (s.props.type === 'logseq-portal') {
s.update({
isAutoResizing: v,
})
} else {
s.onResetBounds({ zoom: app.viewport.camera.zoom })
}
})
app.persist()
}}
>
<TablerIcon name="dimensions" />
</ToggleInput>
)
})
const LogseqPortalViewModeAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<LogseqPortalShape>('LogseqPortalViewMode')
const collapsed = shapes.every(s => s.collapsed)
if (!collapsed && !shapes.every(s => !s.collapsed)) {
return null
}
const tooltip = (
<div className="flex">
{collapsed ? t('whiteboard/expand') : t('whiteboard/collapse')}
<KeyboardShortcut
action={collapsed ? 'editor/expand-block-children' : 'editor/collapse-block-children'}
/>
</div>
)
return (
<ToggleInput
tooltip={tooltip}
toggle={shapes.every(s => s.props.type === 'logseq-portal')}
className="tl-button"
pressed={collapsed}
onPressedChange={() => app.api.setCollapsed(!collapsed)}
>
<TablerIcon name={collapsed ? 'object-expanded' : 'object-compact'} />
</ToggleInput>
)
})
const ScaleLevelAction = observer(() => {
const {
handlers: { isMobile },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<LogseqPortalShape>('ScaleLevel')
const scaleLevel = new Set(shapes.map(s => s.scaleLevel)).size > 1 ? '' : shapes[0].scaleLevel
return <ScaleInput scaleLevel={scaleLevel} compact={isMobile()} />
})
const IFrameSourceAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shape = filterShapeByAction<IFrameShape>('IFrameSource')[0]
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
shape.onIFrameSourceChange(e.target.value.trim().toLowerCase())
app.persist()
}, [])
const handleReload = React.useCallback(() => {
shape.reload()
}, [])
return (
<span className="flex gap-3">
<Button tooltip={t('whiteboard/reload')} type="button" onClick={handleReload}>
<TablerIcon name="refresh" />
</Button>
<TextInput
title={t('whiteboard/website-url')}
className="tl-iframe-src"
value={`${shape.props.url}`}
onChange={handleChange}
/>
<Button
tooltip={t('whiteboard/open-website-url')}
type="button"
onClick={() => window.open(shape.props.url)}
>
<TablerIcon name="external-link" />
</Button>
</span>
)
})
const YoutubeLinkAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shape = filterShapeByAction<YouTubeShape>('YoutubeLink')[0]
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
shape.onYoutubeLinkChange(e.target.value)
app.persist()
}, [])
return (
<span className="flex gap-3">
<TextInput
title={t('whiteboard/youtube-url')}
className="tl-youtube-link"
value={`${shape.props.url}`}
onChange={handleChange}
/>
<Button
tooltip={t('whiteboard/open-youtube-url')}
type="button"
onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
>
<TablerIcon name="external-link" />
</Button>
</span>
)
})
const TwitterLinkAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shape = filterShapeByAction<TweetShape>('TwitterLink')[0]
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
shape.onTwitterLinkChange(e.target.value)
app.persist()
}, [])
return (
<span className="flex gap-3">
<TextInput
title={t('whiteboard/twitter-url')}
className="tl-twitter-link"
value={`${shape.props.url}`}
onChange={handleChange}
/>
<Button
tooltip={t('whiteboard/open-twitter-url')}
type="button"
onClick={() => window.logseq?.api?.open_external_link?.(shape.props.url)}
>
<TablerIcon name="external-link" />
</Button>
</span>
)
})
const EditPdfAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t, setCurrentPdf },
} = React.useContext(LogseqContext)
const shape = app.selectedShapesArray[0]
return (
<Button
tooltip={t('whiteboard/edit-pdf')}
type="button"
onClick={() => setCurrentPdf(app.assets[shape.props.assetId].src)}
>
<TablerIcon name="edit" />
</Button>
)
})
const NoFillAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<BoxShape | PolygonShape | EllipseShape>('NoFill')
const handleChange = React.useCallback((v: boolean) => {
app.selectedShapesArray.forEach(s => s.update({ noFill: v }))
app.persist()
}, [])
const noFill = shapes.every(s => s.props.noFill)
return (
<ToggleInput
tooltip={t('whiteboard/fill')}
className="tl-button"
pressed={noFill}
onPressedChange={handleChange}
>
<TablerIcon name={noFill ? 'droplet-off' : 'droplet'} />
</ToggleInput>
)
})
const SwatchAction = observer(() => {
const app = useApp<Shape>()
// Placeholder
const shapes = filterShapeByAction<
BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape | TextShape
>('Swatch')
const handleSetColor = React.useCallback((color: string) => {
app.selectedShapesArray.forEach(s => {
s.update({ fill: color, stroke: color })
})
app.persist()
}, [])
const handleSetOpacity = React.useCallback((opacity: number) => {
app.selectedShapesArray.forEach(s => {
s.update({ opacity: opacity })
})
app.persist()
}, [])
const color = shapes[0].props.noFill ? shapes[0].props.stroke : shapes[0].props.fill
return (
<ColorInput
popoverSide="top"
color={color}
opacity={shapes[0].props.opacity}
setOpacity={handleSetOpacity}
setColor={handleSetColor}
/>
)
})
const GeometryAction = observer(() => {
const app = useApp<Shape>()
const handleSetGeometry = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const type = e.currentTarget.dataset.tool
app.api.convertShapes(type)
}, [])
return <GeometryTools popoverSide="top" chevron={false} setGeometry={handleSetGeometry} />
})
const StrokeTypeAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<
BoxShape | PolygonShape | EllipseShape | LineShape | PencilShape
>('StrokeType')
const StrokeTypeOptions: ToggleGroupInputOption[] = [
{
value: 'line',
icon: 'circle',
tooltip: 'Solid',
},
{
value: 'dashed',
icon: 'circle-dashed',
tooltip: 'Dashed',
},
]
const value = shapes.every(s => s.props.strokeType === 'dashed')
? 'dashed'
: shapes.every(s => s.props.strokeType === 'line')
? 'line'
: 'mixed'
return (
<ToggleGroupInput
title={t('whiteboard/stroke-type')}
options={StrokeTypeOptions}
value={value}
onValueChange={v => {
shapes.forEach(shape => {
shape.update({
strokeType: v,
})
})
app.persist()
}}
/>
)
})
const ArrowModeAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<LineShape>('ArrowMode')
const StrokeTypeOptions: ToggleGroupInputOption[] = [
{
value: 'start',
icon: 'arrow-narrow-left',
},
{
value: 'end',
icon: 'arrow-narrow-right',
},
]
const startValue = shapes.every(s => s.props.decorations?.start === Decoration.Arrow)
const endValue = shapes.every(s => s.props.decorations?.end === Decoration.Arrow)
const value = [startValue ? 'start' : null, endValue ? 'end' : null].filter(isNonNullable)
const valueToDecorations = (value: string[]) => {
return {
start: value.includes('start') ? Decoration.Arrow : null,
end: value.includes('end') ? Decoration.Arrow : null,
}
}
return (
<ToggleGroupMultipleInput
title={t('whiteboard/arrow-head')}
options={StrokeTypeOptions}
value={value}
onValueChange={v => {
shapes.forEach(shape => {
shape.update({
decorations: valueToDecorations(v),
})
})
app.persist()
}}
/>
)
})
const TextStyleAction = observer(() => {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const shapes = filterShapeByAction<TextShape>('TextStyle')
const bold = shapes.every(s => s.props.fontWeight > 500)
const italic = shapes.every(s => s.props.italic)
return (
<span className="flex gap-1">
<ToggleInput
tooltip={t('whiteboard/bold')}
className="tl-button"
pressed={bold}
onPressedChange={v => {
shapes.forEach(shape => {
shape.update({
fontWeight: v ? 700 : 400,
})
shape.onResetBounds()
})
app.persist()
}}
>
<TablerIcon name="bold" />
</ToggleInput>
<ToggleInput
tooltip={t('whiteboard/italic')}
className="tl-button"
pressed={italic}
onPressedChange={v => {
shapes.forEach(shape => {
shape.update({
italic: v,
})
shape.onResetBounds()
})
app.persist()
}}
>
<TablerIcon name="italic" />
</ToggleInput>
</span>
)
})
const LinksAction = observer(() => {
const app = useApp<Shape>()
const shape = app.selectedShapesArray[0]
const handleChange = (refs: string[]) => {
shape.update({ refs: refs })
app.persist()
}
return (
<ShapeLinksInput
onRefsChange={handleChange}
refs={shape.props.refs ?? []}
shapeType={shape.props.type}
side="right"
pageId={shape.props.type === 'logseq-portal' ? shape.props.pageId : undefined}
portalType={shape.props.type === 'logseq-portal' ? shape.props.blockType : undefined}
/>
)
})
contextBarActionMapping.set('Geometry', GeometryAction)
contextBarActionMapping.set('AutoResizing', AutoResizingAction)
contextBarActionMapping.set('LogseqPortalViewMode', LogseqPortalViewModeAction)
contextBarActionMapping.set('ScaleLevel', ScaleLevelAction)
contextBarActionMapping.set('YoutubeLink', YoutubeLinkAction)
contextBarActionMapping.set('TwitterLink', TwitterLinkAction)
contextBarActionMapping.set('IFrameSource', IFrameSourceAction)
contextBarActionMapping.set('NoFill', NoFillAction)
contextBarActionMapping.set('Swatch', SwatchAction)
contextBarActionMapping.set('StrokeType', StrokeTypeAction)
contextBarActionMapping.set('ArrowMode', ArrowModeAction)
contextBarActionMapping.set('TextStyle', TextStyleAction)
contextBarActionMapping.set('Links', LinksAction)
contextBarActionMapping.set('EditPdf', EditPdfAction)
const getContextBarActionTypes = (type: ShapeType) => {
return (shapeMapping[type] ?? []).filter(isNonNullable)
}
export const getContextBarActionsForShapes = (shapes: Shape[]) => {
const types = shapes.map(s => s.props.type)
const actionTypes = new Set(shapes.length > 0 ? getContextBarActionTypes(types[0]) : [])
for (let i = 1; i < types.length && actionTypes.size > 0; i++) {
const otherActionTypes = getContextBarActionTypes(types[i])
actionTypes.forEach(action => {
if (!otherActionTypes.includes(action)) {
actionTypes.delete(action)
}
})
}
if (shapes.length > 1) {
singleShapeActions.forEach(action => {
if (actionTypes.has(action)) {
actionTypes.delete(action)
}
})
}
return Array.from(actionTypes)
.sort((a, b) => contextBarActionTypes.indexOf(a) - contextBarActionTypes.indexOf(b))
.map(action => contextBarActionMapping.get(action)!)
}

View File

@@ -1,2 +0,0 @@
export * from './ContextBar'
export * from './contextBarActionFactory'

View File

@@ -1,369 +0,0 @@
import { useApp } from '@tldraw/react'
import { LogseqContext } from '../../lib/logseq-context'
import {
MOD_KEY,
AlignType,
DistributeType,
isDev,
EXPORT_PADDING
} from '@tldraw/core'
import { observer } from 'mobx-react-lite'
import { TablerIcon } from '../icons'
import { Button } from '../Button'
import { KeyboardShortcut } from '../KeyboardShortcut'
import * as React from 'react'
import { toJS } from 'mobx'
// @ts-ignore
const LSUI = window.LSUI
interface ContextMenuProps {
children: React.ReactNode
collisionRef: React.RefObject<HTMLDivElement>
}
export const ContextMenu = observer(function ContextMenu({
children,
collisionRef,
}: ContextMenuProps) {
const app = useApp()
const { handlers } = React.useContext(LogseqContext)
const t = handlers.t
const rContent = React.useRef<HTMLDivElement>(null)
const runAndTransition = (f: Function) => {
f()
app.transition('select')
}
const developerMode = React.useMemo(() => {
return isDev()
}, [])
return (
<LSUI.ContextMenu
onOpenChange={(open: boolean) => {
if (open && !app.isIn('select.contextMenu')) {
app.transition('select').selectedTool.transition('contextMenu')
} else if (!open && app.isIn('select.contextMenu')) {
app.selectedTool.transition('idle')
}
}}
>
<LSUI.ContextMenuTrigger
disabled={app.editingShape && Object.keys(app.editingShape).length !== 0}
>
{children}
</LSUI.ContextMenuTrigger>
<LSUI.ContextMenuContent
className="tl-menu tl-context-menu"
ref={rContent}
onEscapeKeyDown={() => app.transition('select')}
collisionBoundary={collisionRef.current}
asChild
tabIndex={-1}
>
<div>
{app.selectedShapes?.size > 1 &&
!app.readOnly &&
app.selectedShapesArray?.some(s => !s.props.isLocked) && (
<>
<LSUI.ContextMenuItem className={'tl-menu-button-row-wrap'}>
<div className="tl-menu-button-row pb-0">
<Button
tooltip={t('whiteboard/align-left')}
onClick={() => runAndTransition(() => app.align(AlignType.Left))}
>
<TablerIcon name="layout-align-left"/>
</Button>
<Button
tooltip={t('whiteboard/align-center-horizontally')}
onClick={() => runAndTransition(() => app.align(AlignType.CenterHorizontal))}
>
<TablerIcon name="layout-align-center"/>
</Button>
<Button
tooltip={t('whiteboard/align-right')}
onClick={() => runAndTransition(() => app.align(AlignType.Right))}
>
<TablerIcon name="layout-align-right"/>
</Button>
<LSUI.Separator className="tl-toolbar-separator"
orientation="vertical"/>
<Button
tooltip={t('whiteboard/distribute-horizontally')}
onClick={() =>
runAndTransition(() => app.distribute(DistributeType.Horizontal))
}
>
<TablerIcon name="layout-distribute-vertical"/>
</Button>
</div>
<div className="tl-menu-button-row pt-0">
<Button
tooltip={t('whiteboard/align-top')}
onClick={() => runAndTransition(() => app.align(AlignType.Top))}
>
<TablerIcon name="layout-align-top"/>
</Button>
<Button
tooltip={t('whiteboard/align-center-vertically')}
onClick={() => runAndTransition(() => app.align(AlignType.CenterVertical))}
>
<TablerIcon name="layout-align-middle"/>
</Button>
<Button
tooltip={t('whiteboard/align-bottom')}
onClick={() => runAndTransition(() => app.align(AlignType.Bottom))}
>
<TablerIcon name="layout-align-bottom"/>
</Button>
<LSUI.Separator className="tl-toolbar-separator"
orientation="vertical"/>
<Button
tooltip={t('whiteboard/distribute-vertically')}
onClick={() =>
runAndTransition(() => app.distribute(DistributeType.Vertical))
}
>
<TablerIcon name="layout-distribute-horizontal"/>
</Button>
</div>
</LSUI.ContextMenuItem>
<LSUI.ContextMenuSeparator className="menu-separator"/>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.packIntoRectangle)}
>
<TablerIcon className="tl-menu-icon" name="layout-grid"/>
{t('whiteboard/pack-into-rectangle')}
</LSUI.ContextMenuItem>
<LSUI.ContextMenuSeparator className="menu-separator"/>
</>
)}
{app.selectedShapes?.size > 0 && (
<>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.zoomToSelection)}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/zoom-to-fit')}
<KeyboardShortcut action="whiteboard/zoom-to-fit"/>
</LSUI.ContextMenuItem>
<LSUI.ContextMenuSeparator className="menu-separator"/>
</>
)}
{(app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) ||
app.selectedShapesArray.length > 1) &&
app.selectedShapesArray?.some(s => !s.props.isLocked) &&
!app.readOnly && (
<>
{app.selectedShapesArray.some(s => s.type === 'group' || app.getParentGroup(s)) && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.unGroup)}
>
<TablerIcon className="tl-menu-icon" name="ungroup"/>
{t('whiteboard/ungroup')}
<KeyboardShortcut action="whiteboard/ungroup"/>
</LSUI.ContextMenuItem>
)}
{app.selectedShapesArray.length > 1 &&
app.selectedShapesArray?.some(s => !s.props.isLocked) && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.doGroup)}
>
<TablerIcon className="tl-menu-icon" name="group"/>
{t('whiteboard/group')}
<KeyboardShortcut action="whiteboard/group"/>
</LSUI.ContextMenuItem>
)}
<LSUI.ContextMenuSeparator className="menu-separator"/>
</>
)}
{app.selectedShapes?.size > 0 && app.selectedShapesArray?.some(s => !s.props.isLocked) && (
<>
{!app.readOnly && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.cut)}
>
<TablerIcon className="tl-menu-icon" name="cut"/>
{t('whiteboard/cut')}
</LSUI.ContextMenuItem>
)}
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.copy)}
>
<TablerIcon className="tl-menu-icon" name="copy"/>
{t('whiteboard/copy')}
<KeyboardShortcut action="editor/copy"/>
</LSUI.ContextMenuItem>
</>
)}
{!app.readOnly && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.paste)}
>
<TablerIcon className="tl-menu-icon" name="clipboard"/>
{t('whiteboard/paste')}
<KeyboardShortcut shortcut={`${MOD_KEY}+v`}/>
</LSUI.ContextMenuItem>
)}
{app.selectedShapes?.size === 1 && !app.readOnly && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(() => app.paste(undefined, true))}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/paste-as-link')}
<KeyboardShortcut shortcut={`${MOD_KEY}+⇧+v`}/>
</LSUI.ContextMenuItem>
)}
{app.selectedShapes?.size > 0 && (
<>
<LSUI.ContextMenuSeparator className="menu-separator"/>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() =>
runAndTransition(() =>
handlers.exportToImage(app.currentPageId, {
x: app.selectionBounds.minX + app.viewport.camera.point[0] - EXPORT_PADDING,
y: app.selectionBounds.minY + app.viewport.camera.point[1] - EXPORT_PADDING,
width: app.selectionBounds?.width + EXPORT_PADDING * 2,
height: app.selectionBounds?.height + EXPORT_PADDING * 2,
zoom: app.viewport.camera.zoom,
})
)
}
>
<TablerIcon className="tl-menu-icon" name="file-export"/>
{t('whiteboard/export')}
<div className="tl-menu-right-slot">
<span className="keyboard-shortcut"></span>
</div>
</LSUI.ContextMenuItem>
</>
)}
<LSUI.ContextMenuSeparator className="menu-separator"/>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.selectAll)}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/select-all')}
<KeyboardShortcut action="editor/select-parent"/>
</LSUI.ContextMenuItem>
{app.selectedShapes?.size > 1 && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.deselectAll)}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/deselect-all')}
</LSUI.ContextMenuItem>
)}
{!app.readOnly &&
app.selectedShapes?.size > 0 &&
app.selectedShapesArray?.some(s => !s.props.isLocked) && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(() => app.setLocked(true))}
>
<TablerIcon className="tl-menu-icon" name="lock"/>
{t('whiteboard/lock')}
<KeyboardShortcut action="whiteboard/lock"/>
</LSUI.ContextMenuItem>
)}
{!app.readOnly &&
app.selectedShapes?.size > 0 &&
app.selectedShapesArray?.some(s => s.props.isLocked) && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(() => app.setLocked(false))}
>
<TablerIcon className="tl-menu-icon" name="lock-open"/>
{t('whiteboard/unlock')}
<KeyboardShortcut action="whiteboard/unlock"/>
</LSUI.ContextMenuItem>
)}
{app.selectedShapes?.size > 0 &&
!app.readOnly &&
app.selectedShapesArray?.some(s => !s.props.isLocked) && (
<>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.api.deleteShapes)}
>
<TablerIcon className="tl-menu-icon" name="backspace"/>
{t('whiteboard/delete')}
<KeyboardShortcut action="editor/delete"/>
</LSUI.ContextMenuItem>
{app.selectedShapes?.size > 1 && !app.readOnly && (
<>
<LSUI.ContextMenuSeparator className="menu-separator"/>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.flipHorizontal)}
>
<TablerIcon className="tl-menu-icon"
name="flip-horizontal"/>
{t('whiteboard/flip-horizontally')}
</LSUI.ContextMenuItem>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.flipVertical)}
>
<TablerIcon className="tl-menu-icon"
name="flip-vertical"/>
{t('whiteboard/flip-vertically')}
</LSUI.ContextMenuItem>
</>
)}
{!app.readOnly && (
<>
<LSUI.ContextMenuSeparator className="menu-separator"/>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.bringToFront)}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/move-to-front')}
<KeyboardShortcut action="whiteboard/bring-to-front"/>
</LSUI.ContextMenuItem>
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => runAndTransition(app.sendToBack)}
>
<TablerIcon className="tl-menu-icon" name="circle-dotted"/>
{t('whiteboard/move-to-back')}
<KeyboardShortcut action="whiteboard/send-to-back"/>
</LSUI.ContextMenuItem>
</>
)}
{developerMode && (
<LSUI.ContextMenuItem
className="tl-menu-item"
onClick={() => {
if (app.selectedShapesArray.length === 1) {
console.log(toJS(app.selectedShapesArray[0].serialized))
} else {
console.log(app.selectedShapesArray.map(s => toJS(s.serialized)))
}
}}
>
{t('whiteboard/dev-print-shape-props')}
</LSUI.ContextMenuItem>
)}
</>
)}
</div>
</LSUI.ContextMenuContent>
</LSUI.ContextMenu>
)
})

View File

@@ -1 +0,0 @@
export * from './ContextMenu'

View File

@@ -1,56 +0,0 @@
import { useRendererContext } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import React from 'react'
import ReactDOM from 'react-dom'
const printPoint = (point: number[]) => {
return `[${point.map(d => d?.toFixed(2) ?? '-').join(', ')}]`
}
export const DevTools = observer(() => {
const {
viewport: {
bounds,
camera: { point, zoom },
},
inputs,
} = useRendererContext()
const statusbarAnchorRef = React.useRef<HTMLElement | null>()
React.useEffect(() => {
const statusbarAnchor = document.getElementById('tl-statusbar-anchor')
statusbarAnchorRef.current = statusbarAnchor
}, [])
const rendererStatusText = [
['Z', zoom?.toFixed(2) ?? 'null'],
['MP', printPoint(inputs.currentPoint)],
['MS', printPoint(inputs.currentScreenPoint)],
['VP', printPoint(point)],
['VBR', printPoint([bounds.maxX, bounds.maxY])],
]
.map(p => p.join(''))
.join('|')
const rendererStatus = statusbarAnchorRef.current
? ReactDOM.createPortal(
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
}}
>
{rendererStatusText}
</div>,
statusbarAnchorRef.current
)
: null
return (
<>
{rendererStatus}
</>
)
})

View File

@@ -1 +0,0 @@
export * from './Devtools'

View File

@@ -1,93 +0,0 @@
import { observer } from 'mobx-react-lite'
import type { Side } from '@radix-ui/react-popper'
import { ToolButton } from '../ToolButton'
import { TablerIcon } from '../icons'
import React from 'react'
import { LogseqContext } from '../../lib/logseq-context'
// @ts-ignore
const LSUI = window.LSUI
interface GeometryToolsProps extends React.HTMLAttributes<HTMLElement> {
popoverSide?: Side
activeGeometry?: string
setGeometry: (e: React.MouseEvent<HTMLButtonElement>) => void
chevron?: boolean
}
export const GeometryTools = observer(function GeometryTools({
popoverSide = 'left',
setGeometry,
activeGeometry,
chevron = true,
...rest
}: GeometryToolsProps) {
const {
handlers: { t },
} = React.useContext(LogseqContext)
const geometries = [
{
id: 'box',
icon: 'square',
tooltip: t('whiteboard/rectangle'),
},
{
id: 'ellipse',
icon: 'circle',
tooltip: t('whiteboard/circle'),
},
{
id: 'polygon',
icon: 'triangle',
tooltip: t('whiteboard/triangle'),
},
]
const shapes = {
id: 'shapes',
icon: 'triangle-square-circle',
tooltip: t('whiteboard/shape'),
}
const activeTool = activeGeometry ? geometries.find(geo => geo.id === activeGeometry) : shapes
return (
<LSUI.Popover>
<LSUI.PopoverTrigger asChild>
<div {...rest} className="tl-geometry-tools-pane-anchor">
<ToolButton {...activeTool} tooltipSide={popoverSide} />
{chevron && (
<TablerIcon
data-selected={activeGeometry}
className="tl-popover-indicator"
name="chevron-down-left"
/>
)}
</div>
</LSUI.PopoverTrigger>
<LSUI.PopoverContent
className="p-0 w-auto"
side={popoverSide}
sideOffset={15}
collisionBoundary={document.querySelector('.logseq-tldraw')}>
<div
className={`tl-toolbar tl-geometry-toolbar ${
['left', 'right'].includes(popoverSide) ? 'flex-col' : 'flex-row'
}`}
>
{geometries.map(props => (
<ToolButton
key={props.id}
id={props.id}
icon={props.icon}
handleClick={setGeometry}
tooltipSide={popoverSide}
/>
))}
</div>
</LSUI.PopoverContent>
</LSUI.Popover>
)
})

View File

@@ -1 +0,0 @@
export * from './GeometryTools'

View File

@@ -1,16 +0,0 @@
import { LogseqContext } from '../../lib/logseq-context'
import * as React from 'react'
export const KeyboardShortcut = ({
action, shortcut, opts,
...props
}: Partial<{ action: string, shortcut: string, opts: any }> & React.HTMLAttributes<HTMLElement>) => {
const { renderers } = React.useContext(LogseqContext)
const Shortcut = renderers?.KeyboardShortcut
return (
<div className="tl-menu-right-slot" {...props}>
<Shortcut action={action} shortcut={shortcut} opts={opts} />
</div>
)
}

View File

@@ -1 +0,0 @@
export * from './KeyboardShortcut'

View File

@@ -1,55 +0,0 @@
import { deepEqual } from '@tldraw/core'
import { useApp, useMinimapEvents } from '@tldraw/react'
import { reaction } from 'mobx'
import { observer } from 'mobx-react-lite'
import React from 'react'
import { PreviewManager } from '../../lib'
import { TablerIcon } from '../icons'
export const Minimap = observer(function Minimap() {
const app = useApp()
const [whiteboardPreviewManager] = React.useState(() => new PreviewManager(app.serialized))
const [preview, setPreview] = React.useState(() =>
whiteboardPreviewManager.generatePreviewJsx(app.viewport)
)
const [active, setActive] = React.useState(false)
const events = useMinimapEvents()
React.useEffect(() => {
return reaction(
() => {
return {
serialized: app.serialized,
viewport: app.viewport,
cameraPoint: app.viewport.camera.point,
}
},
({ serialized, viewport }, prev) => {
if (!deepEqual(prev.serialized, serialized)) {
whiteboardPreviewManager.load(serialized)
}
setPreview(whiteboardPreviewManager.generatePreviewJsx(viewport))
}
)
}, [app])
return (
<>
{active && (
<div className="tl-preview-minimap" {...events}>
{preview}
</div>
)}
<button
// className="tl-preview-minimap-toggle"
data-active={active}
onClick={() => setActive(a => !a)}
>
<TablerIcon name="crosshair2" />
</button>
</>
)
})

View File

@@ -1 +0,0 @@
export * from './Minimap'

View File

@@ -1,39 +0,0 @@
import type { Side, Align } from '@radix-ui/react-popper'
// @ts-ignore
const LSUI = window.LSUI
interface PopoverButton extends React.HTMLAttributes<HTMLButtonElement> {
side: Side // default side
align?: Align
alignOffset?: number
label: React.ReactNode
children: React.ReactNode
border?: boolean
}
export function PopoverButton({ side, align, alignOffset, label, children, border, ...rest }: PopoverButton) {
return (
<LSUI.Popover>
<LSUI.PopoverTrigger
{...rest}
data-border={border}
className="tl-button tl-popover-trigger-button"
>
{label}
</LSUI.PopoverTrigger>
<LSUI.PopoverContent
className="w-auto p-1"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={15}
collisionBoundary={document.querySelector('.logseq-tldraw')}
>
{children}
<LSUI.PopoverArrow className="popper-arrow" />
</LSUI.PopoverContent>
</LSUI.Popover>
)
}

View File

@@ -1 +0,0 @@
export * from './PopoverButton'

View File

@@ -1,103 +0,0 @@
import { useApp } from '@tldraw/react'
import { Geometry } from '@tldraw/core'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import { ToolButton } from '../ToolButton'
import { GeometryTools } from '../GeometryTools'
import { ColorInput } from '../inputs/ColorInput'
import { ScaleInput } from '../inputs/ScaleInput'
import { LogseqContext } from '../../lib/logseq-context'
// @ts-ignore
const LSUI = window.LSUI
export const PrimaryTools = observer(function PrimaryTools() {
const app = useApp()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const handleSetColor = React.useCallback((color: string) => {
app.api.setColor(color)
}, [])
const handleToolClick = React.useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const tool = e.currentTarget.dataset.tool
if (tool) app.selectTool(tool)
}, [])
const [activeGeomId, setActiveGeomId] = React.useState(
() =>
Object.values(Geometry).find((geo: string) => geo === app.selectedTool.id) ??
Object.values(Geometry)[0]
)
React.useEffect(() => {
setActiveGeomId((prevId: Geometry) => {
return Object.values(Geometry).find((geo: string) => geo === app.selectedTool.id) ?? prevId
})
}, [app.selectedTool.id])
return (
<div className="tl-primary-tools" data-html2canvas-ignore="true">
<div className="tl-toolbar tl-tools-floating-panel">
<ToolButton
handleClick={() => app.selectTool('select')}
tooltip={t('whiteboard/select')}
id="select"
icon="select-cursor"
/>
<ToolButton
handleClick={() => app.selectTool('move')}
tooltip={t('whiteboard/pan')}
id="move"
icon={app.isIn('move.panning') ? 'hand-grab' : 'hand-stop'}
/>
<LSUI.Separator orientation="horizontal" />
<ToolButton
handleClick={() => app.selectTool('logseq-portal')}
tooltip={t('whiteboard/add-block-or-page')}
id="logseq-portal"
icon="circle-plus"
/>
<ToolButton
handleClick={() => app.selectTool('pencil')}
tooltip={t('whiteboard/draw')}
id="pencil"
icon="ballpen"
/>
<ToolButton
handleClick={() => app.selectTool('highlighter')}
tooltip={t('whiteboard/highlight')}
id="highlighter"
icon="highlight"
/>
<ToolButton
handleClick={() => app.selectTool('erase')}
tooltip={t('whiteboard/eraser')}
id="erase"
icon="eraser"
/>
<ToolButton
handleClick={() => app.selectTool('line')}
tooltip={t('whiteboard/connector')}
id="line"
icon="connector"
/>
<ToolButton
handleClick={() => app.selectTool('text')}
tooltip={t('whiteboard/text')}
id="text"
icon="text"
/>
<GeometryTools activeGeometry={activeGeomId} setGeometry={handleToolClick} />
<LSUI.Separator
orientation="horizontal"
style={{ margin: '0 -4px' }}
/>
<ColorInput popoverSide="left" color={app.settings.color} setColor={handleSetColor} />
<ScaleInput scaleLevel={app.settings.scaleLevel} popoverSide="left" compact={true} />
</div>
</div>
)
})

View File

@@ -1 +0,0 @@
export * from './PrimaryTools'

View File

@@ -1,44 +0,0 @@
import { TLQuickLinksComponent, useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import React from 'react'
import type { Shape } from '../../lib'
import { LogseqContext } from '../../lib/logseq-context'
import { BlockLink } from '../BlockLink'
export const QuickLinks: TLQuickLinksComponent<Shape> = observer(({ shape }) => {
const app = useApp()
const { handlers } = React.useContext(LogseqContext)
const t = handlers.t
const links = React.useMemo(() => {
const links = [...(shape.props.refs ?? [])].map<[ref: string, showReferenceContent: boolean]>(
// user added links should show the referenced block content
l => [l, true]
)
if (shape.props.type === 'logseq-portal' && shape.props.pageId) {
// portal reference should not show the block content
links.unshift([shape.props.pageId, false])
}
// do not show links for the current page
return links.filter(
link =>
link[0].toLowerCase() !== app.currentPage.id &&
link[0] !== shape.props.pageId
)
}, [shape.props.id, shape.props.type, shape.props.parentId, shape.props.refs])
if (links.length === 0) return null
return (
<div className="tl-quick-links" title={t('whiteboard/shape-quick-links')}>
{links.map(([ref, showReferenceContent]) => {
return (
<div key={ref} className="tl-quick-links-row">
<BlockLink id={ref} showReferenceContent={showReferenceContent} />
</div>
)
})}
</div>
)
})

View File

@@ -1 +0,0 @@
export * from './QuickLinks'

View File

@@ -1,437 +0,0 @@
import { useDebouncedValue } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import React from 'react'
import { Virtuoso } from 'react-virtuoso'
import { LogseqPortalShape } from '../../lib'
import { LogseqContext, SearchResult } from '../../lib/logseq-context'
import { CircleButton } from '../Button'
import { TablerIcon } from '../icons'
import { TextInput } from '../inputs/TextInput'
interface LogseqQuickSearchProps {
onChange: (id: string) => void
className?: string
placeholder?: string
style?: React.CSSProperties
onBlur?: () => void
onAddBlock?: (uuid: string) => void
}
const LogseqTypeTag = ({
type,
active,
}: {
type: 'B' | 'P' | 'BA' | 'PA' | 'WA' | 'WP' | 'BS' | 'PS'
active?: boolean
}) => {
const nameMapping = {
B: 'block',
P: 'page',
WP: 'whiteboard',
BA: 'new-block',
PA: 'new-page',
WA: 'new-whiteboard',
BS: 'block-search',
PS: 'page-search',
}
return (
<span className="tl-type-tag" data-active={active}>
<i className={`tie tie-${nameMapping[type]}`} />
</span>
)
}
function escapeRegExp(text: string) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&')
}
const highlightedJSX = (input: string, keyword: string) => {
return (
<span>
{input
.split(new RegExp(`(${escapeRegExp(keyword)})`, 'gi'))
.map((part, index) => {
if (index % 2 === 1) {
return <mark className="tl-highlighted">{part}</mark>
}
return part
})
.map((frag, idx) => (
<React.Fragment key={idx}>{frag}</React.Fragment>
))}
</span>
)
}
const useSearch = (q: string, searchFilter: 'B' | 'P' | null) => {
const { handlers } = React.useContext(LogseqContext)
const [results, setResults] = React.useState<SearchResult | null>(null)
const dq = useDebouncedValue(q, 200)
React.useEffect(() => {
let canceled = false
if (dq.length > 0) {
const filter = { 'pages?': true, 'blocks?': true, 'files?': false }
if (searchFilter === 'B') {
filter['pages?'] = false
} else if (searchFilter === 'P') {
filter['blocks?'] = false
}
handlers.search(dq, filter).then(_results => {
if (!canceled) {
setResults(_results)
}
})
} else {
setResults(null)
}
return () => {
canceled = true
}
}, [dq, handlers?.search])
return results
}
export const LogseqQuickSearch = observer(
({ className, style, placeholder, onChange, onBlur, onAddBlock }: LogseqQuickSearchProps) => {
const [q, setQ] = React.useState(LogseqPortalShape.defaultSearchQuery)
const [searchFilter, setSearchFilter] = React.useState<'B' | 'P' | null>(
LogseqPortalShape.defaultSearchFilter
)
const rInput = React.useRef<HTMLInputElement>(null)
const { handlers, renderers } = React.useContext(LogseqContext)
const t = handlers.t
const finishSearching = React.useCallback((id: string, isPage: boolean) => {
console.log({id, isPage})
setTimeout(() => onChange(id, isPage))
rInput.current?.blur()
if (id) {
LogseqPortalShape.defaultSearchQuery = ''
LogseqPortalShape.defaultSearchFilter = null
}
}, [])
const handleAddBlock = React.useCallback(
async (content: string) => {
const uuid = await handlers?.addNewBlock(content)
if (uuid) {
finishSearching(uuid)
onAddBlock?.(uuid)
}
return uuid
},
[onAddBlock]
)
const optionsWrapperRef = React.useRef<HTMLDivElement>(null)
const [focusedOptionIdx, setFocusedOptionIdx] = React.useState<number>(0)
const searchResult = useSearch(q, searchFilter)
const [prefixIcon, setPrefixIcon] = React.useState<string>('circle-plus')
const [showPanel, setShowPanel] = React.useState<boolean>(false)
React.useEffect(() => {
// autofocus attr seems not to be working
setTimeout(() => {
rInput.current?.focus()
})
}, [searchFilter])
React.useEffect(() => {
LogseqPortalShape.defaultSearchQuery = q
LogseqPortalShape.defaultSearchFilter = searchFilter
}, [q, searchFilter])
type Option = {
actionIcon: 'search' | 'circle-plus'
onChosen: () => boolean // return true if the action was handled
element: React.ReactNode
}
const options: Option[] = React.useMemo(() => {
const options: Option[] = []
const Breadcrumb = renderers?.Breadcrumb
if (!Breadcrumb || !handlers) {
return []
}
if (onAddBlock) {
// New block option
options.push({
actionIcon: 'circle-plus',
onChosen: () => {
return !!handleAddBlock(q)
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag active type="BA" />
{q.length > 0 ? (
<>
<strong>{t('whiteboard/new-block')}</strong>
{q}
</>
) : (
<strong>{t('whiteboard/new-block-no-colon')}</strong>
)}
</div>
),
})
}
// New page or whiteboard option when no exact match
if (!searchResult?.pages?.some(p => p.title.toLowerCase() === q.toLowerCase()) && q) {
options.push(
{
actionIcon: 'circle-plus',
onChosen: async () => {
let result = await handlers?.addNewPage(q)
finishSearching(result, true)
return true
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag active type="PA" />
<strong>{t('whiteboard/new-page')}</strong>
{q}
</div>
),
},
{
actionIcon: 'circle-plus',
onChosen: async () => {
let result = await handlers?.addNewWhiteboard(q)
finishSearching(result, true)
return true
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag active type="WA" />
<strong>{t('whiteboard/new-whiteboard')}</strong>
{q}
</div>
),
}
)
}
// search filters
if (q.length === 0 && searchFilter === null) {
options.push(
{
actionIcon: 'search',
onChosen: () => {
setSearchFilter('B')
return true
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag type="BS" />
{t('whiteboard/search-only-blocks')}
</div>
),
},
{
actionIcon: 'search',
onChosen: () => {
setSearchFilter('P')
return true
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag type="PS" />
{t('whiteboard/search-only-pages')}
</div>
),
}
)
}
// Page results
if ((!searchFilter || searchFilter === 'P') && searchResult && searchResult.pages) {
options.push(
...searchResult.pages.map(page => {
return {
actionIcon: 'search' as 'search',
onChosen: () => {
finishSearching(page.id, true)
return true
},
element: (
<div className="tl-quick-search-option-row">
<LogseqTypeTag type={handlers.isWhiteboardPage(page.id) ? 'WP' : 'P'} />
{highlightedJSX(page.title, q)}
</div>
),
}
})
)
}
// Block results
if ((!searchFilter || searchFilter === 'B') && searchResult && searchResult.blocks) {
options.push(
...searchResult.blocks
.filter(block => block.title && block.uuid)
.map(({ title, uuid }) => {
const block = handlers.queryBlockByUUID(uuid)
return {
actionIcon: 'search' as 'search',
onChosen: () => {
if (block) {
finishSearching(uuid)
window.logseq?.api?.set_blocks_id?.([uuid])
return true
}
return false
},
// FIXME: breadcrumb not works here because of async loading
element: (
<>
<div className="tl-quick-search-option-row">
<LogseqTypeTag type="B" />
{highlightedJSX(title, q)}
</div>
</>
),
}
})
)
}
return options
}, [q, searchFilter, searchResult, renderers?.Breadcrumb, handlers])
React.useEffect(() => {
const keydownListener = (e: KeyboardEvent) => {
let newIndex = focusedOptionIdx
if (e.key === 'ArrowDown') {
newIndex = Math.min(options.length - 1, focusedOptionIdx + 1)
} else if (e.key === 'ArrowUp') {
newIndex = Math.max(0, focusedOptionIdx - 1)
} else if (e.key === 'Enter') {
options[focusedOptionIdx]?.onChosen()
e.stopPropagation()
e.preventDefault()
} else if (e.key === 'Backspace' && q.length === 0) {
setSearchFilter(null)
} else if (e.key === 'Escape') {
finishSearching('')
}
if (newIndex !== focusedOptionIdx) {
const option = options[newIndex]
setFocusedOptionIdx(newIndex)
setPrefixIcon(option.actionIcon)
e.stopPropagation()
e.preventDefault()
const optionElement = optionsWrapperRef.current?.querySelector(
'.tl-quick-search-option:nth-child(' + (newIndex + 1) + ')'
)
if (optionElement) {
// @ts-expect-error we are using scrollIntoViewIfNeeded, which is not in standards
optionElement?.scrollIntoViewIfNeeded(false)
}
}
}
document.addEventListener('keydown', keydownListener, true)
return () => {
document.removeEventListener('keydown', keydownListener, true)
}
}, [options, focusedOptionIdx, q])
return (
<div className={'tl-quick-search ' + (className ?? '')} style={style}>
<CircleButton
icon={prefixIcon}
onClick={() => {
options[focusedOptionIdx]?.onChosen()
}}
/>
<div className="tl-quick-search-input-container">
{searchFilter && (
<div className="tl-quick-search-input-filter">
<LogseqTypeTag type={searchFilter} />
{searchFilter === 'B' ? 'Search blocks' : 'Search pages'}
<div
className="tl-quick-search-input-filter-remove"
onClick={() => setSearchFilter(null)}
>
<TablerIcon name="x" />
</div>
</div>
)}
<TextInput
ref={rInput}
type="text"
value={q}
className="tl-quick-search-input"
placeholder={placeholder ?? 'Create or search your graph...'}
onChange={q => setQ(q.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
finishSearching(q)
}
e.stopPropagation()
}}
onFocus={() => {
setShowPanel(true)
}}
onBlur={() => {
setShowPanel(false)
onBlur?.()
}}
/>
</div>
{/* TODO: refactor to radix-ui popover */}
{options.length > 0 && (
<div
onWheelCapture={e => e.stopPropagation()}
className="tl-quick-search-options"
ref={optionsWrapperRef}
style={{
// not using display: none so we can persist the scroll position
visibility: showPanel ? 'visible' : 'hidden',
pointerEvents: showPanel ? 'all' : 'none',
}}
>
<Virtuoso
style={{ height: Math.min(Math.max(1, options.length), 12) * 40 }}
totalCount={options.length}
itemContent={index => {
const { actionIcon, onChosen, element } = options[index]
return (
<div
key={index}
data-focused={index === focusedOptionIdx}
className="tl-quick-search-option"
tabIndex={0}
onMouseEnter={() => {
setPrefixIcon(actionIcon)
setFocusedOptionIdx(index)
}}
// we have to use mousedown && stop propagation EARLY, otherwise some
// default behavior of clicking the rendered elements will happen
onPointerDownCapture={e => {
if (onChosen()) {
e.stopPropagation()
e.preventDefault()
}
}}
>
{element}
</div>
)
}}
/>
</div>
)}
</div>
)
}
)

View File

@@ -1 +0,0 @@
export * from './QuickSearch'

View File

@@ -1,16 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import type { Shape } from '../../lib'
export const StatusBar = observer(function StatusBar() {
const app = useApp<Shape>()
return (
<div className="tl-statusbar" data-html2canvas-ignore="true">
{app.selectedTool.id} | {app.selectedTool.currentState.id}
<div style={{ flex: 1 }} />
<div id="tl-statusbar-anchor" className="flex gap-1" />
</div>
)
})

View File

@@ -1 +0,0 @@
export * from './StatusBar'

View File

@@ -1,50 +0,0 @@
import { TLMoveTool, TLSelectTool } from '@tldraw/core'
import { useApp } from '@tldraw/react'
import type { Side } from '@radix-ui/react-popper'
import { observer } from 'mobx-react-lite'
import type * as React from 'react'
import { Button } from '../Button'
import { TablerIcon } from '../icons'
import { KeyboardShortcut } from '../KeyboardShortcut'
export interface ToolButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
id: string
icon: string | React.ReactNode
tooltip: string
tooltipSide?: Side
handleClick: (e: React.MouseEvent<HTMLButtonElement>) => void
}
export const ToolButton = observer(
({ id, icon, tooltip, tooltipSide = 'left', handleClick, ...props }: ToolButtonProps) => {
const app = useApp()
// Tool must exist
const Tool = [...app.Tools, TLSelectTool, TLMoveTool]?.find(T => T.id === id)
const shortcut = (Tool as any)?.['shortcut']
const tooltipContent =
shortcut && tooltip ? (
<div className="flex">
{tooltip}
<KeyboardShortcut action={shortcut} />
</div>
) : (
tooltip
)
return (
<Button
{...props}
tooltipSide={tooltipSide}
tooltip={tooltipContent}
data-tool={id}
data-selected={id === app.selectedTool.id}
onClick={handleClick}
>
{typeof icon === 'string' ? <TablerIcon name={icon} /> : icon}
</Button>
)
}
)

View File

@@ -1 +0,0 @@
export * from './ToolButton'

View File

@@ -1,31 +0,0 @@
import type { Side } from '@radix-ui/react-popper'
// @ts-ignore
const LSUI = window.LSUI
export interface TooltipProps {
children: React.ReactNode
side?: Side
sideOffset?: number
content?: React.ReactNode
}
export function Tooltip({ side, content, sideOffset = 10, ...rest }: TooltipProps) {
return content ? (
<LSUI.TooltipProvider delayDuration={300}>
<LSUI.Tooltip>
<LSUI.TooltipTrigger asChild>{rest.children}</LSUI.TooltipTrigger>
<LSUI.TooltipContent
sideOffset={sideOffset}
side={side}
{...rest}
>
{content}
<LSUI.TooltipArrow className="popper-arrow" />
</LSUI.TooltipContent>
</LSUI.Tooltip>
</LSUI.TooltipProvider>
) : (
<>{rest.children}</>
)
}

View File

@@ -1 +0,0 @@
export * from './Tooltip'

View File

@@ -1,65 +0,0 @@
import { useApp } from '@tldraw/react'
import { KeyboardShortcut } from '../KeyboardShortcut'
import { observer } from 'mobx-react-lite'
// @ts-ignore
const LSUI = window.LSUI
export const ZoomMenu = observer(function ZoomMenu(): JSX.Element {
const app = useApp()
const preventEvent = (e: Event) => {
e.preventDefault()
}
return (
<LSUI.DropdownMenu>
<LSUI.DropdownMenuTrigger className="tl-button text-sm px-2 important" id="tl-zoom">
{(app.viewport.camera.zoom * 100).toFixed(0) + '%'}
</LSUI.DropdownMenuTrigger>
<LSUI.DropdownMenuContent
onCloseAutoFocus={e => e.preventDefault()}
id="zoomPopup"
sideOffset={12}
>
<LSUI.DropdownMenuItem
onSelect={preventEvent}
onClick={app.api.zoomToFit}
>
Zoom to drawing
<KeyboardShortcut action="whiteboard/zoom-to-fit" />
</LSUI.DropdownMenuItem>
<LSUI.DropdownMenuItem
onSelect={preventEvent}
onClick={app.api.zoomToSelection}
disabled={app.selectedShapesArray.length === 0}
>
Zoom to fit selection
<KeyboardShortcut action="whiteboard/zoom-to-selection" />
</LSUI.DropdownMenuItem>
<LSUI.DropdownMenuItem
onSelect={preventEvent}
onClick={app.api.zoomIn}
>
Zoom in
<KeyboardShortcut action="whiteboard/zoom-in" />
</LSUI.DropdownMenuItem>
<LSUI.DropdownMenuItem
onSelect={preventEvent}
onClick={app.api.zoomOut}
>
Zoom out
<KeyboardShortcut action="whiteboard/zoom-out" />
</LSUI.DropdownMenuItem>
<LSUI.DropdownMenuItem
onSelect={preventEvent}
onClick={app.api.resetZoom}
>
Reset zoom
<KeyboardShortcut action="whiteboard/reset-zoom" />
</LSUI.DropdownMenuItem>
</LSUI.DropdownMenuContent>
</LSUI.DropdownMenu>
)
})
export default ZoomMenu

View File

@@ -1 +0,0 @@
export * from './ZoomMenu'

View File

@@ -1,35 +0,0 @@
const extendedIcons = [
'add-link',
'block-search',
'block',
'connector',
'group',
'internal-link',
'link-to-block',
'link-to-page',
'link-to-whiteboard',
'move-to-sidebar-right',
'object-compact',
'object-expanded',
'open-as-page',
'page-search',
'page',
'references-hide',
'references-show',
'select-cursor',
'text',
'ungroup',
'whiteboard-element',
'whiteboard',
]
const cx = (...args: (string | undefined)[]) => args.join(' ')
export const TablerIcon = ({
name,
className,
...props
}: { name: string } & React.HTMLAttributes<HTMLElement>) => {
const classNamePrefix = extendedIcons.includes(name) ? `tie tie-` : `ti ti-`
return <i className={cx(classNamePrefix + name, className)} {...props} />
}

View File

@@ -1 +0,0 @@
export * from './TablerIcon'

View File

@@ -1,122 +0,0 @@
import type { Side } from '@radix-ui/react-popper'
import { Color, isBuiltInColor, debounce } from '@tldraw/core'
import { TablerIcon } from '../icons'
import { PopoverButton } from '../PopoverButton'
import { Tooltip } from '../Tooltip'
import React from 'react'
import { LogseqContext } from '../../lib/logseq-context'
// @ts-ignore
const LSUI = window.LSUI
interface ColorInputProps extends React.HTMLAttributes<HTMLButtonElement> {
color?: string
opacity?: number
popoverSide: Side
setColor: (value: string) => void
setOpacity?: (value: number) => void
}
export function ColorInput({
color,
opacity,
popoverSide,
setColor,
setOpacity,
...rest
}: ColorInputProps) {
const {
handlers: { t },
} = React.useContext(LogseqContext)
function renderColor(color?: string) {
return color ? (
<div className="tl-color-bg" style={{ backgroundColor: color }}>
<div className={`w-full h-full bg-${color}-500`}></div>
</div>
) : (
<div className={'tl-color-bg'}>
<TablerIcon name="color-swatch" />
</div>
)
}
function isHexColor(color: string) {
return /^#(?:[0-9a-f]{3}){1,2}$/i.test(color)
}
const handleChangeDebounced = React.useMemo(() => {
let latestValue = ''
const handler: React.ChangeEventHandler<HTMLInputElement> = e => {
setColor(latestValue)
}
return debounce(handler, 100, e => {
latestValue = e.target.value
})
}, [])
return (
<PopoverButton
{...rest}
border
side={popoverSide}
label={
<Tooltip content={t('whiteboard/color')} side={popoverSide} sideOffset={14}>
{renderColor(color)}
</Tooltip>
}
>
<div className="p-1">
<div className={'tl-color-palette'}>
{Object.values(Color).map(value => (
<button
key={value}
className={`tl-color-drip m-1${value === color ? ' active' : ''}`}
onClick={() => setColor(value)}
>
{renderColor(value)}
</button>
))}
</div>
<div className="flex items-center tl-custom-color">
<div className={`tl-color-drip m-1 mr-3 ${!isBuiltInColor(color) ? 'active' : ''}`}>
<div className="color-input-wrapper tl-color-bg">
<input
className="color-input cursor-pointer"
id="tl-custom-color-input"
type="color"
value={isHexColor(color) ? color : '#000000'}
onChange={handleChangeDebounced}
style={{ opacity: isBuiltInColor(color) ? 0 : 1 }}
{...rest}
/>
</div>
</div>
<label htmlFor="tl-custom-color-input" className="text-xs cursor-pointer">
{t('whiteboard/select-custom-color')}
</label>
</div>
{setOpacity && (
<div className="mx-1 my-2">
<LSUI.Slider
defaultValue={[opacity ?? 0]}
onValueCommit={value => setOpacity(value[0])}
max={1}
step={0.1}
aria-label={t('whiteboard/opacity')}
className="tl-slider-root"
>
<LSUI.SliderTrack className="tl-slider-track">
<LSUI.SliderRange className="tl-slider-range" />
</LSUI.SliderTrack>
<LSUI.SliderThumb className="tl-slider-thumb" />
</LSUI.Slider>
</div>
)}
</div>
</PopoverButton>
)
}

View File

@@ -1,12 +0,0 @@
interface NumberInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string
}
export function NumberInput({ label, ...rest }: NumberInputProps) {
return (
<div className="input">
<label htmlFor={`number-${label}`}>{label}</label>
<input className="number-input" name={`number-${label}`} type="number" {...rest} />
</div>
)
}

View File

@@ -1,59 +0,0 @@
import { SelectInput, type SelectOption } from '../inputs/SelectInput'
import type { Side } from '@radix-ui/react-popper'
import type { SizeLevel } from '../../lib'
import { useApp } from '@tldraw/react'
import React from 'react'
import { LogseqContext } from '../../lib/logseq-context'
interface ScaleInputProps extends React.HTMLAttributes<HTMLButtonElement> {
scaleLevel?: SizeLevel
compact?: boolean
popoverSide?: Side
}
export function ScaleInput({ scaleLevel, compact, popoverSide, ...rest }: ScaleInputProps) {
const app = useApp<Shape>()
const {
handlers: { t },
} = React.useContext(LogseqContext)
const sizeOptions: SelectOption[] = [
{
label: compact ? 'XS' : t('whiteboard/extra-small'),
value: 'xs',
},
{
label: compact ? 'SM' : t('whiteboard/small'),
value: 'sm',
},
{
label: compact ? 'MD' : t('whiteboard/medium'),
value: 'md',
},
{
label: compact ? 'LG' : t('whiteboard/large'),
value: 'lg',
},
{
label: compact ? 'XL' : t('whiteboard/extra-large'),
value: 'xl',
},
{
label: compact ? 'XXL' : t('whiteboard/huge'),
value: 'xxl',
},
]
return (
<SelectInput
tooltip={t('whiteboard/scale-level')}
options={sizeOptions}
value={scaleLevel}
popoverSide={popoverSide}
compact={compact}
onValueChange={v => {
app.api.setScaleLevel(v)
}}
/>
)
}

View File

@@ -1,75 +0,0 @@
import * as React from 'react'
import { Tooltip } from '../Tooltip'
import { ChevronDown } from 'lucide-react'
import type { Side } from '@radix-ui/react-popper'
// @ts-ignore
const LSUI = window.LSUI
export interface SelectOption {
value: string
label: React.ReactNode
}
interface SelectInputProps extends React.HTMLAttributes<HTMLElement> {
options: SelectOption[]
value: string
tooltip?: React.ReactNode
popoverSide?: Side
compact?: boolean
onValueChange: (value: string) => void
}
export function SelectInput({
options,
tooltip,
popoverSide,
compact = false,
value,
onValueChange,
...rest
}: SelectInputProps) {
const [isOpen, setIsOpen] = React.useState(false)
return (
<div {...rest}>
<LSUI.Select
open={isOpen}
onOpenChange={setIsOpen}
value={value}
onValueChange={onValueChange}
>
<Tooltip content={tooltip} side={popoverSide}>
<LSUI.SelectTrigger
className={`tl-select-trigger ${compact ? "compact" : ""}`}>
<LSUI.SelectValue />
{!compact && (
<LSUI.SelectIcon asChild>
<ChevronDown className="h-4 w-4 opacity-50"/>
</LSUI.SelectIcon>
)}
</LSUI.SelectTrigger>
</Tooltip>
<LSUI.SelectContent
className="min-w-min"
side={popoverSide}
position="popper"
sideOffset={14}
align="center"
onKeyDown={e => e.stopPropagation()}
>
{options.map(option => {
return (
<LSUI.SelectItem
key={option.value}
value={option.value}
>
{option.label}
</LSUI.SelectItem>
)
})}
</LSUI.SelectContent>
</LSUI.Select>
</div>
)
}

View File

@@ -1,162 +0,0 @@
import type { Side } from '@radix-ui/react-popper'
import { validUUID } from '@tldraw/core'
import { useApp } from '@tldraw/react'
import React from 'react'
import { observer } from 'mobx-react-lite'
import { LogseqContext } from '../../lib/logseq-context'
import { BlockLink } from '../BlockLink'
import { Button } from '../Button'
import { Tooltip } from '../Tooltip'
import { TablerIcon } from '../icons'
import { PopoverButton } from '../PopoverButton'
import { LogseqQuickSearch } from '../QuickSearch'
interface ShapeLinksInputProps extends React.HTMLAttributes<HTMLButtonElement> {
shapeType: string
side: Side
refs: string[]
pageId?: string // the portal referenced block id or page name
portalType?: 'B' | 'P'
onRefsChange: (value: string[]) => void
}
function ShapeLinkItem({
id,
type,
onRemove,
showContent,
}: {
id: string
type: 'B' | 'P'
onRemove?: () => void
showContent?: boolean
}) {
const app = useApp<Shape>()
const { handlers } = React.useContext(LogseqContext)
const t = handlers.t
return (
<div className="tl-shape-links-panel-item color-level relative">
<div className="whitespace-pre break-all overflow-hidden text-ellipsis inline-flex">
<BlockLink id={id} showReferenceContent={showContent} />
</div>
<div className="flex-1" />
{handlers.getBlockPageName(id) !== app.currentPage.name && (
<Button
tooltip={t('whiteboard/open-page')}
type="button"
onClick={() => handlers?.redirectToPage(id)}
>
<TablerIcon name="open-as-page" />
</Button>
)}
<Button
tooltip={t('whiteboard/open-page-in-sidebar')}
type="button"
onClick={() => handlers?.sidebarAddBlock(id, type === 'B' ? 'block' : 'page')}
>
<TablerIcon name="move-to-sidebar-right" />
</Button>
{onRemove && (
<Button
className="tl-shape-links-panel-item-remove-button"
tooltip={t('whiteboard/remove-link')}
type="button"
onClick={onRemove}
>
<TablerIcon name="x" className="!translate-y-0" />
</Button>
)}
</div>
)
}
export const ShapeLinksInput = observer(function ShapeLinksInput({
pageId,
portalType,
shapeType,
refs,
side,
onRefsChange,
...rest
}: ShapeLinksInputProps) {
const {
handlers: { t },
} = React.useContext(LogseqContext)
const noOfLinks = refs.length + (pageId ? 1 : 0)
const canAddLink = refs.length === 0
const addNewRef = (value?: string) => {
if (value && !refs.includes(value) && canAddLink) {
onRefsChange([...refs, value])
}
}
const showReferencePanel = !!(pageId && portalType)
return (
<PopoverButton
{...rest}
side={side}
align="start"
alignOffset={-6}
label={
<Tooltip content={t('whiteboard/link')} sideOffset={14}>
<div className="flex gap-1 relative items-center justify-center px-1">
<TablerIcon name={noOfLinks > 0 ? 'link' : 'add-link'} />
{noOfLinks > 0 && <div className="tl-shape-links-count">{noOfLinks}</div>}
</div>
</Tooltip>
}
>
<div className="color-level rounded-lg" data-show-reference-panel={showReferencePanel}>
{showReferencePanel && (
<div className="tl-shape-links-reference-panel">
<div className="text-base inline-flex gap-1 items-center">
<TablerIcon className="opacity-50" name="internal-link" />
{t('whiteboard/references')}
</div>
<ShapeLinkItem type={portalType} id={pageId} />
</div>
)}
<div className="tl-shape-links-panel color-level">
<div className="text-base inline-flex gap-1 items-center">
<TablerIcon className="opacity-50" name="add-link" />
{t('whiteboard/link-to-any-page-or-block')}
</div>
{canAddLink && (
<LogseqQuickSearch
style={{
width: 'calc(100% - 46px)',
marginLeft: '46px',
}}
placeholder={t('whiteboard/start-typing-to-search')}
onChange={addNewRef}
/>
)}
{refs.length > 0 && (
<div className="flex flex-col items-stretch gap-2">
{refs.map((ref, i) => {
return (
<ShapeLinkItem
key={ref}
id={ref}
type={validUUID(ref) ? 'B' : 'P'}
onRemove={() => {
onRefsChange(refs.filter((_, j) => i !== j))
}}
showContent
/>
)
})}
</div>
)}
</div>
</div>
</PopoverButton>
)
})

View File

@@ -1,18 +0,0 @@
import * as React from 'react'
interface TextInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
autoResize?: boolean
}
export const TextInput = React.forwardRef<HTMLInputElement, TextInputProps>(
({ autoResize = true, value, className, ...rest }, ref) => {
return (
<div className={'tl-input' + (className ? ' ' + className : '')}>
<div className="tl-input-sizer">
<div className="tl-input-hidden">{value}</div>
<input ref={ref} value={value} className="tl-text-input" type="text" {...rest} />
</div>
</div>
)
}
)

View File

@@ -1,76 +0,0 @@
import { TablerIcon } from '../icons'
import { Tooltip } from '../Tooltip'
// @ts-ignore
const LSUI = window.LSUI
export interface ToggleGroupInputOption {
value: string
icon: string
tooltip?: string
}
interface ToggleGroupInputProps extends React.HTMLAttributes<HTMLElement> {
options: ToggleGroupInputOption[]
value: string
onValueChange: (value: string) => void
}
interface ToggleGroupMultipleInputProps extends React.HTMLAttributes<HTMLElement> {
options: ToggleGroupInputOption[]
value: string[]
onValueChange: (value: string[]) => void
}
export function ToggleGroupInput({ options, value, onValueChange }: ToggleGroupInputProps) {
return (
<LSUI.ToggleGroup
type="single"
value={value}
onValueChange={onValueChange}
>
{options.map(option => {
return (
<Tooltip content={option.tooltip} key={option.value}>
<div className="inline-flex">
<LSUI.ToggleGroupItem
className="tl-button"
value={option.value}
disabled={option.value === value}
>
<TablerIcon name={option.icon} />
</LSUI.ToggleGroupItem>
</div>
</Tooltip>
)
})}
</LSUI.ToggleGroup>
)
}
export function ToggleGroupMultipleInput({
options,
value,
onValueChange,
}: ToggleGroupMultipleInputProps) {
return (
<LSUI.ToggleGroup
className="inline-flex"
type="multiple"
value={value}
onValueChange={onValueChange}
>
{options.map(option => {
return (
<LSUI.ToggleGroupItem
className="tl-button"
key={option.value}
value={option.value}
>
<TablerIcon name={option.icon} />
</LSUI.ToggleGroupItem>
)
})}
</LSUI.ToggleGroup>
)
}

View File

@@ -1,34 +0,0 @@
import { Tooltip } from '../Tooltip'
// @ts-ignore
const LSUI = window.LSUI
interface ToggleInputProps extends React.HTMLAttributes<HTMLElement> {
toggle?: boolean
pressed: boolean
tooltip?: React.ReactNode
onPressedChange: (value: boolean) => void
}
export function ToggleInput({
toggle = true,
pressed,
onPressedChange,
className,
tooltip,
...rest
}: ToggleInputProps) {
return (
<Tooltip content={tooltip}>
<div className="inline-flex">
<LSUI.Toggle
{...rest}
data-toggle={toggle}
className={'tl-button' + (className ? ' ' + className : '')}
pressed={pressed}
onPressedChange={onPressedChange}
/>
</div>
</Tooltip>
)
}

View File

@@ -1,6 +0,0 @@
import { useApp } from '@tldraw/react'
export function useCameraMovingRef() {
const app = useApp()
return app.inputs.state === 'panning' || app.inputs.state === 'pinching'
}

View File

@@ -1,11 +0,0 @@
import type { TLReactCallbacks } from '@tldraw/react'
import * as React from 'react'
import { LogseqContext } from '../lib/logseq-context'
export function useCopy() {
const { handlers } = React.useContext(LogseqContext)
return React.useCallback<TLReactCallbacks['onCopy']>((app, { text, html }) => {
handlers.copyToClipboard(text, html)
}, [])
}

View File

@@ -1,14 +0,0 @@
import type { TLReactCallbacks } from '@tldraw/react'
import * as React from 'react'
import type { Shape } from '../lib'
import { usePaste } from './usePaste'
export function useDrop() {
const handlePaste = usePaste()
return React.useCallback<TLReactCallbacks<Shape>['onDrop']>(
async (app, { dataTransfer, point }) => {
handlePaste(app, { point, shiftKey: false, dataTransfer, fromDrop: true })
},
[]
)
}

View File

@@ -1,515 +0,0 @@
import {
getSizeFromSrc,
isNonNullable,
TLAsset,
TLBinding,
TLCursor,
TLPasteEventInfo,
TLShapeModel,
uniqueId,
validUUID,
} from '@tldraw/core'
import type { TLReactApp, TLReactCallbacks } from '@tldraw/react'
import Vec from '@tldraw/vec'
import * as React from 'react'
import { NIL as NIL_UUID } from 'uuid'
import {
HTMLShape,
IFrameShape,
ImageShape,
PdfShape,
LogseqPortalShape,
VideoShape,
YouTubeShape,
YOUTUBE_REGEX,
TweetShape,
X_OR_TWITTER_REGEX,
type Shape,
} from '../lib'
import { LogseqContext, LogseqContextValue } from '../lib/logseq-context'
const isValidURL = (url: string) => {
try {
const parsedUrl = new URL(url)
return parsedUrl.host && ['http:', 'https:'].includes(parsedUrl.protocol)
} catch {
return false
}
}
interface Asset extends TLAsset {
size?: number[]
}
const assetExtensions = {
image: ['.png', '.svg', '.jpg', '.jpeg', '.gif'],
video: ['.mp4', '.webm', '.ogg'],
pdf: ['.pdf'],
}
function getFileType(filename: string) {
// Get extension, verify that it's an image
const extensionMatch = filename.match(/\.[0-9a-z]+$/i)
if (!extensionMatch) return 'unknown'
const extension = extensionMatch[0].toLowerCase()
const [type, _extensions] = Object.entries(assetExtensions).find(([_type, extensions]) =>
extensions.includes(extension)
) ?? ['unknown', null]
return type
}
type MaybeShapes = TLShapeModel[] | null | undefined
type CreateShapeFN<Args extends any[]> = (...args: Args) => Promise<MaybeShapes> | MaybeShapes
/**
* Try create a shape from a list of create shape functions. If one of the functions returns a
* shape, return it, otherwise try again for the next one until all have been tried.
*/
function tryCreateShapeHelper<Args extends any[]>(...fns: CreateShapeFN<Args>[]) {
return async (...args: Args) => {
for (const fn of fns) {
const result = await fn(...(args as any))
if (result && result.length > 0) {
return result
}
}
return null
}
}
// TODO: support file types
async function getDataFromType(item: DataTransfer | ClipboardItem, type: `text/${string}`) {
if (!item.types.includes(type)) {
return null
}
if (item instanceof DataTransfer) {
return item.getData(type)
}
const blob = await item.getType(type)
return await blob.text()
}
const handleCreatingShapes = async (
app: TLReactApp<Shape>,
{ point, shiftKey, dataTransfer, fromDrop }: TLPasteEventInfo,
handlers: LogseqContextValue['handlers']
) => {
let imageAssetsToCreate: Asset[] = []
let assetsToClone: TLAsset[] = []
const bindingsToCreate: TLBinding[] = []
async function createAssetsFromURL(url: string, type: string): Promise<Asset> {
// Do we already have an asset for this image?
const existingAsset = Object.values(app.assets).find(asset => asset.src === url)
if (existingAsset) {
return existingAsset as Asset
}
// Create a new asset for this image
const asset: Asset = {
id: uniqueId(),
type: type,
src: url,
size: await getSizeFromSrc(handlers.makeAssetUrl(url), type),
}
return asset
}
async function createAssetsFromFiles(files: File[]) {
const tasks = files
.filter(file => getFileType(file.name) !== 'unknown')
.map(async file => {
try {
const dataurl = await handlers.saveAsset(file)
return await createAssetsFromURL(dataurl, getFileType(file.name))
} catch (err) {
console.error(err)
}
return null
})
return (await Promise.all(tasks)).filter(isNonNullable)
}
function createHTMLShape(text: string) {
return [
{
...HTMLShape.defaultProps,
html: text,
point: [point[0], point[1]],
},
]
}
async function tryCreateShapesFromDataTransfer(dataTransfer: DataTransfer) {
return tryCreateShapeHelper(
tryCreateShapeFromFilePath,
tryCreateShapeFromFiles,
tryCreateShapeFromPageName,
tryCreateShapeFromBlockUUID,
tryCreateShapeFromTextPlain,
tryCreateShapeFromTextHTML,
tryCreateLogseqPortalShapesFromString
)(dataTransfer)
}
async function tryCreateShapesFromClipboard() {
const items = await navigator.clipboard.read()
const createShapesFn = tryCreateShapeHelper(
tryCreateShapeFromTextPlain,
tryCreateShapeFromTextHTML,
tryCreateLogseqPortalShapesFromString
)
const allShapes = (await Promise.all(items.map(item => createShapesFn(item))))
.flat()
.filter(isNonNullable)
return allShapes
}
async function tryCreateShapeFromFilePath(item: DataTransfer) {
const file = item.getData('file')
if (!file) return null
const asset = await createAssetsFromURL(file, 'pdf')
app.addAssets([asset])
const newShape = {
...PdfShape.defaultProps,
id: uniqueId(),
assetId: asset.id,
url: file,
opacity: 1,
}
if (asset.size) {
Object.assign(newShape, {
point: [point[0] - asset.size[0] / 4 + 16, point[1] - asset.size[1] / 4 + 16],
size: Vec.div(asset.size, 2),
})
}
return [newShape]
}
async function tryCreateShapeFromFiles(item: DataTransfer) {
const files = Array.from(item.files)
if (files.length > 0) {
const assets = await createAssetsFromFiles(files)
// ? could we get rid of this side effect?
imageAssetsToCreate = assets
return assets.map((asset, i) => {
let defaultProps = null
switch (asset.type) {
case 'video':
defaultProps = VideoShape.defaultProps
break
case 'image':
defaultProps = ImageShape.defaultProps
break
case 'pdf':
defaultProps = PdfShape.defaultProps
break
default:
return null
}
const newShape = {
...defaultProps,
id: uniqueId(),
// TODO: Should be place near the last edited shape
assetId: asset.id,
opacity: 1,
}
if (asset.size) {
Object.assign(newShape, {
point: [point[0] - asset.size[0] / 4 + i * 16, point[1] - asset.size[1] / 4 + i * 16],
size: Vec.div(asset.size, 2),
})
}
return newShape
})
}
return null
}
async function tryCreateShapeFromTextHTML(item: DataTransfer | ClipboardItem) {
// skips if it's a drop event or using shift key
if (item.types.includes('text/plain') && (shiftKey || fromDrop)) {
return null
}
const rawText = await getDataFromType(item, 'text/html')
if (rawText) {
return tryCreateShapeHelper(tryCreateClonedShapesFromJSON, createHTMLShape)(rawText)
}
return null
}
async function tryCreateShapeFromBlockUUID(dataTransfer: DataTransfer) {
// This is a Logseq custom data type defined in frontend.components.block
const rawText = dataTransfer.getData('block-uuid')
if (rawText) {
const text = rawText.trim()
const allSelectedBlocks = window.logseq?.api?.get_selected_blocks?.()
const blockUUIDs =
allSelectedBlocks && allSelectedBlocks?.length > 1
? allSelectedBlocks.map(b => b.uuid)
: [text]
// ensure all uuid in blockUUIDs is persisted
window.logseq?.api?.set_blocks_id?.(blockUUIDs)
const tasks = blockUUIDs.map(uuid => tryCreateLogseqPortalShapesFromUUID(`((${uuid}))`))
const newShapes = (await Promise.all(tasks)).flat().filter(isNonNullable)
return newShapes.map((s, idx) => {
// if there are multiple shapes, shift them to the right
return {
...s,
// TODO: use better alignment?
point: [point[0] + (LogseqPortalShape.defaultProps.size[0] + 16) * idx, point[1]],
}
})
}
return null
}
async function tryCreateShapeFromPageName(dataTransfer: DataTransfer) {
// This is a Logseq custom data type defined in frontend.components.block
const rawText = dataTransfer.getData('page-name')
if (rawText) {
const text = rawText.trim()
return tryCreateLogseqPortalShapesFromUUID(`[[${text}]]`)
}
return null
}
async function tryCreateShapeFromTextPlain(item: DataTransfer | ClipboardItem) {
const rawText = await getDataFromType(item, 'text/plain')
if (rawText) {
const text = rawText.trim()
return tryCreateShapeHelper(tryCreateShapeFromURL, tryCreateShapeFromIframeString)(text)
}
return null
}
function tryCreateClonedShapesFromJSON(rawText: string) {
const result = app.api.getClonedShapesFromTldrString(decodeURIComponent(rawText), point)
if (result) {
const { shapes, assets, bindings } = result
assetsToClone.push(...assets)
bindingsToCreate.push(...bindings)
return shapes
}
return null
}
async function tryCreateShapeFromURL(rawText: string) {
if (isValidURL(rawText) && !shiftKey) {
if (YOUTUBE_REGEX.test(rawText)) {
return [
{
...YouTubeShape.defaultProps,
url: rawText,
point: [point[0], point[1]],
},
]
}
if (X_OR_TWITTER_REGEX.test(rawText)) {
return [
{
...TweetShape.defaultProps,
url: rawText,
point: [point[0], point[1]],
},
]
}
return [
{
...IFrameShape.defaultProps,
url: rawText,
point: [point[0], point[1]],
},
]
}
return null
}
function tryCreateShapeFromIframeString(rawText: string) {
// if rawText is iframe text
if (rawText.startsWith('<iframe')) {
return [
{
...HTMLShape.defaultProps,
html: rawText,
point: [point[0], point[1]],
},
]
}
return null
}
async function tryCreateLogseqPortalShapesFromUUID(rawText: string) {
if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
const blockRef = rawText.slice(2, -2)
if (validUUID(blockRef)) {
return [
{
...LogseqPortalShape.defaultProps,
point: [point[0], point[1]],
size: [400, 0], // use 0 here to enable auto-resize
pageId: blockRef,
fill: app.settings.color,
stroke: app.settings.color,
scaleLevel: app.settings.scaleLevel,
blockType: 'B' as 'B',
},
]
}
}
// [[page name]] ?
else if (/^\[\[.*\]\]$/.test(rawText)) {
const pageName = rawText.slice(2, -2)
return [
{
...LogseqPortalShape.defaultProps,
point: [point[0], point[1]],
size: [400, 0], // use 0 here to enable auto-resize
pageId: pageName,
fill: app.settings.color,
stroke: app.settings.color,
scaleLevel: app.settings.scaleLevel,
blockType: 'P' as 'P',
},
]
}
return null
}
async function tryCreateLogseqPortalShapesFromString(item: DataTransfer | ClipboardItem) {
const rawText = await getDataFromType(item, 'text/plain')
if (rawText) {
const text = rawText.trim()
// Create a new block that belongs to the current whiteboard
const uuid = await handlers?.addNewBlock(text)
if (uuid) {
// create text shape
return [
{
...LogseqPortalShape.defaultProps,
size: [400, 0], // use 0 here to enable auto-resize
point: [point[0], point[1]],
pageId: uuid,
fill: app.settings.color,
stroke: app.settings.color,
scaleLevel: app.settings.scaleLevel,
blockType: 'B' as 'B',
compact: true,
},
]
}
}
return null
}
app.cursors.setCursor(TLCursor.Progress)
let newShapes: TLShapeModel[] = []
try {
if (dataTransfer) {
newShapes.push(...((await tryCreateShapesFromDataTransfer(dataTransfer)) ?? []))
} else {
// from Clipboard app or Shift copy etc
// in this case, we do not have the dataTransfer object
newShapes.push(...((await tryCreateShapesFromClipboard()) ?? []))
}
} catch (error) {
console.error(error)
}
const allShapesToAdd: TLShapeModel<Shape['props']>[] = newShapes.map(shape => {
return {
...shape,
parentId: app.currentPageId,
isLocked: false,
id: validUUID(shape.id) ? shape.id : uniqueId(),
}
})
const filesOnly = dataTransfer?.types.every(t => t === 'Files')
app.wrapUpdate(() => {
const allAssets = [...imageAssetsToCreate, ...assetsToClone]
if (allAssets.length > 0) {
app.createAssets(allAssets)
}
if (allShapesToAdd.length > 0) {
app.createShapes(allShapesToAdd)
}
app.currentPage.updateBindings(Object.fromEntries(bindingsToCreate.map(b => [b.id, b])))
if (app.selectedShapesArray.length === 1 && allShapesToAdd.length === 1 && fromDrop) {
const source = app.selectedShapesArray[0]
const target = app.getShapeById(allShapesToAdd[0].id!)!
app.createNewLineBinding(source, target)
}
app.setSelectedShapes(allShapesToAdd.map(s => s.id))
app.selectedTool.transition('idle') // clears possible editing states
app.cursors.setCursor(TLCursor.Default)
if (fromDrop || filesOnly) {
app.packIntoRectangle()
}
})
}
// FIXME: for assets, we should prompt the user a loading spinner
export function usePaste() {
const { handlers } = React.useContext(LogseqContext)
return React.useCallback<TLReactCallbacks<Shape>['onPaste']>(async (app, info) => {
// there is a special case for SHIFT+PASTE
// it will set the link to the current selected shape
if (info.shiftKey && app.selectedShapesArray.length === 1) {
// TODO: thinking about how to make this more generic with usePaste hook
// TODO: handle whiteboard shapes?
const items = await navigator.clipboard.read()
let newRef: string | undefined
if (items.length > 0) {
const blob = await items[0].getType('text/plain')
const rawText = (await blob.text()).trim()
if (rawText) {
if (/^\(\(.*\)\)$/.test(rawText) && rawText.length === NIL_UUID.length + 4) {
const blockRef = rawText.slice(2, -2)
if (validUUID(blockRef)) {
newRef = blockRef
}
} else if (/^\[\[.*\]\]$/.test(rawText)) {
newRef = rawText.slice(2, -2)
}
}
}
if (newRef) {
app.selectedShapesArray[0].update({
refs: [newRef],
})
app.persist()
return
}
// fall through to creating shapes
}
handleCreatingShapes(app, info, handlers)
}, [])
}

View File

@@ -1,12 +0,0 @@
import type { TLReactCallbacks } from '@tldraw/react'
import React from 'react'
import type { Shape } from '../lib'
export function useQuickAdd() {
return React.useCallback<TLReactCallbacks<Shape>['onCanvasDBClick']>(async app => {
// Give a timeout so that the quick add input will not be blurred too soon
setTimeout(() => {
app.transition('logseq-portal').selectedTool.transition('creating')
}, 100)
}, [])
}

View File

@@ -1,18 +0,0 @@
export * from './app'
export * from './lib/preview-manager'
declare global {
interface Window {
logseq?: {
api?: {
make_asset_url?: (url: string) => string
get_page_blocks_tree?: (pageName: string) => any[]
edit_block?: (uuid: string) => void
set_blocks_id?: (uuids: string[]) => void
open_external_link?: (url: string) => void
get_selected_blocks?: () => { uuid: string }[]
get_state_from_store?: (path: string) => any
}
}
}
}

View File

@@ -1,3 +0,0 @@
export * from './shapes'
export * from './tools'
export * from './preview-manager'

View File

@@ -1,70 +0,0 @@
import React from 'react'
export interface SearchResult {
pages?: string[]
blocks?: { content: string; page: number; uuid: string }[]
files?: string[]
}
export interface LogseqContextValue {
renderers: {
Page: React.FC<{
pageName: string
}>
Block: React.FC<{
blockId: string
}>
Breadcrumb: React.FC<{
blockId: string
levelLimit?: number
endSeparator?: boolean
}>
Tweet: React.FC<{
tweetId: string
}>
PageName: React.FC<{
pageName: string
}>
BlockReference: React.FC<{
blockId: string
}>
BacklinksCount: React.FC<{
id: string
className?: string
options?: {
'portal?'?: boolean
'hover?'?: boolean
renderFn?: (open?: boolean, count?: number) => React.ReactNode
}
}>
KeyboardShortcut: React.FC<{
action?: string,
shortcut?: string,
opts?: any
}>
}
handlers: {
t: (key: string) => any
search: (
query: string,
filters: { 'pages?': boolean; 'blocks?': boolean; 'files?': boolean }
) => Promise<SearchResult>
addNewWhiteboard: (pageName: string) => void
exportToImage: (pageName: string, options: object) => void
addNewBlock: (content: string) => string // returns the new block uuid
queryBlockByUUID: (uuid: string) => any
getBlockPageName: (uuid: string) => string
getRedirectPageName: (uuidOrPageName: string) => string
isWhiteboardPage: (pageName: string) => boolean
isMobile: () => boolean
saveAsset: (file: File) => Promise<string>
makeAssetUrl: (relativeUrl: string | null) => string
inflateAsset: (src: string) => object
setCurrentPdf: (src: string | null) => void
sidebarAddBlock: (uuid: string, type: 'block' | 'page') => void
redirectToPage: (uuidOrPageName: string) => void
copyToClipboard: (text: string, html: string) => void
}
}
export const LogseqContext = React.createContext<LogseqContextValue>({} as LogseqContextValue)

View File

@@ -1,148 +0,0 @@
import { BoundsUtils, TLAsset, TLDocumentModel, TLShapeConstructor, TLViewport } from '@tldraw/core'
import ReactDOMServer from 'react-dom/server'
import { Shape, shapes } from './shapes'
const SVG_EXPORT_PADDING = 16
const ShapesMap = new Map(shapes.map(shape => [shape.id, shape]))
const getShapeClass = (type: string): TLShapeConstructor<Shape> => {
if (!type) throw Error('No shape type provided.')
const Shape = ShapesMap.get(type)
if (!Shape) throw Error(`Could not find shape class for ${type}`)
return Shape
}
export class PreviewManager {
shapes: Shape[] | undefined
pageId: string | undefined
assets: TLAsset[] | undefined
constructor(serializedApp?: TLDocumentModel<Shape>) {
if (serializedApp) {
this.load(serializedApp)
}
}
load(snapshot: TLDocumentModel) {
const page = snapshot?.pages?.[0]
this.pageId = page?.id
this.assets = snapshot.assets
this.shapes = page?.shapes
.map(s => {
const ShapeClass = getShapeClass(s.type)
return new ShapeClass(s)
})
// do not need to render group shape because it is invisible in preview
.filter(s => s.type !== 'group')
}
generatePreviewJsx(viewport?: TLViewport, ratio?: number) {
const allBounds = [...(this.shapes ?? []).map(s => s.getRotatedBounds())]
const vBounds = viewport?.currentView
if (vBounds) {
allBounds.push(vBounds)
}
let commonBounds = BoundsUtils.getCommonBounds(allBounds)
if (!commonBounds) {
return null
}
commonBounds = BoundsUtils.expandBounds(commonBounds, SVG_EXPORT_PADDING)
// make sure commonBounds is of ratio 4/3 (should we have another ratio setting?)
commonBounds = ratio ? BoundsUtils.ensureRatio(commonBounds, ratio) : commonBounds
const translatePoint = (p: [number, number]): [string, string] => {
return [(p[0] - commonBounds.minX).toFixed(2), (p[1] - commonBounds.minY).toFixed(2)]
}
const [vx, vy] = vBounds ? translatePoint([vBounds.minX, vBounds.minY]) : [0, 0]
const svgElement = commonBounds && (
<svg
xmlns="http://www.w3.org/2000/svg"
data-common-bound-x={commonBounds.minX.toFixed(2)}
data-common-bound-y={commonBounds.minY.toFixed(2)}
data-common-bound-width={commonBounds.width.toFixed(2)}
data-common-bound-height={commonBounds.height.toFixed(2)}
viewBox={[0, 0, commonBounds.width, commonBounds.height].join(' ')}
>
<defs>
{vBounds && (
<>
<rect
id={this.pageId + '-camera-rect'}
transform={`translate(${vx}, ${vy})`}
width={vBounds.width}
height={vBounds.height}
/>
<mask id={this.pageId + '-camera-mask'}>
<rect width={commonBounds.width} height={commonBounds.height} fill="white" />
<use href={`#${this.pageId}-camera-rect`} fill="black" />
</mask>
</>
)}
</defs>
<g id={this.pageId + '-preview-shapes'}>
{this.shapes?.map(s => {
const {
bounds,
props: { rotation },
} = s
const [tx, ty] = translatePoint([bounds.minX, bounds.minY])
const r = +((((rotation ?? 0) + (bounds.rotation ?? 0)) * 180) / Math.PI).toFixed(2)
const [rdx, rdy] = [(bounds.width / 2).toFixed(2), (bounds.height / 2).toFixed(2)]
const transformArr = [`translate(${tx}, ${ty})`, `rotate(${r}, ${rdx}, ${rdy})`]
return (
<g transform={transformArr.join(' ')} key={s.id}>
{s.getShapeSVGJsx({
assets: this.assets ?? [],
})}
</g>
)
})}
</g>
<rect
mask={vBounds ? `url(#${this.pageId}-camera-mask)` : ''}
width={commonBounds.width}
height={commonBounds.height}
fill="transparent"
/>
{vBounds && (
<use
id="minimap-camera-rect"
data-x={vx}
data-y={vy}
data-width={vBounds.width}
data-height={vBounds.height}
href={`#${this.pageId}-camera-rect`}
fill="transparent"
stroke="red"
strokeWidth={4 / viewport.camera.zoom}
/>
)}
</svg>
)
return svgElement
}
exportAsSVG(ratio: number) {
const svgElement = this.generatePreviewJsx(undefined, ratio)
return svgElement ? ReactDOMServer.renderToString(svgElement) : ''
}
}
/**
* One off helper to generate tldraw preview
*
* @param serializedApp
*/
export function generateSVGFromModel(serializedApp: TLDocumentModel<Shape>, ratio = 4 / 3) {
const preview = new PreviewManager(serializedApp)
return preview.exportAsSVG(ratio)
}
export function generateJSXFromModel(serializedApp: TLDocumentModel<Shape>, ratio = 4 / 3) {
const preview = new PreviewManager(serializedApp)
return preview.generatePreviewJsx(undefined, ratio)
}

View File

@@ -1,32 +0,0 @@
interface BindingIndicatorProps {
strokeWidth: number
size: number[]
mode: 'svg' | 'html'
}
export function BindingIndicator({ strokeWidth, size, mode }: BindingIndicatorProps) {
return mode === 'svg' ? (
<rect
className="tl-binding-indicator"
x={strokeWidth}
y={strokeWidth}
rx={2}
ry={2}
width={Math.max(0, size[0] - strokeWidth * 2)}
height={Math.max(0, size[1] - strokeWidth * 2)}
strokeWidth={strokeWidth * 4}
/>
) : (
<div
className="tl-binding-indicator"
style={{
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
boxShadow: '0 0 0 4px var(--tl-binding)',
borderRadius: 4,
}}
/>
)
}

View File

@@ -1,195 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import { TLBoxShape, TLBoxShapeProps, getComputedColor, getTextLabelSize } from '@tldraw/core'
import Vec from '@tldraw/vec'
import * as React from 'react'
import { observer } from 'mobx-react-lite'
import { CustomStyleProps, withClampedStyles } from './style-props'
import { BindingIndicator } from './BindingIndicator'
import { TextLabel } from './text/TextLabel'
import type { SizeLevel } from '.'
import { action, computed } from 'mobx'
export interface BoxShapeProps extends TLBoxShapeProps, CustomStyleProps {
borderRadius: number
type: 'box'
label: string
fontSize: number
fontWeight: number
italic: boolean
scaleLevel?: SizeLevel
}
const font = '20px / 1 var(--ls-font-family)'
const levelToScale = {
xs: 10,
sm: 16,
md: 20,
lg: 32,
xl: 48,
xxl: 60,
}
export class BoxShape extends TLBoxShape<BoxShapeProps> {
static id = 'box'
static defaultProps: BoxShapeProps = {
id: 'box',
parentId: 'page',
type: 'box',
point: [0, 0],
size: [100, 100],
borderRadius: 2,
stroke: '',
fill: '',
noFill: false,
fontWeight: 400,
fontSize: 20,
italic: false,
strokeType: 'line',
strokeWidth: 2,
opacity: 1,
label: '',
}
canEdit = true
ReactComponent = observer(
({ events, isErasing, isBinding, isSelected, isEditing, onEditingEnd }: TLComponentProps) => {
const {
props: {
size: [w, h],
stroke,
fill,
noFill,
strokeWidth,
strokeType,
borderRadius,
opacity,
label,
italic,
fontWeight,
fontSize,
},
} = this
const labelSize =
label || isEditing
? getTextLabelSize(
label,
{ fontFamily: 'var(--ls-font-family)', fontSize, lineHeight: 1, fontWeight },
4
)
: [0, 0]
const midPoint = Vec.mul(this.props.size, 0.5)
const scale = Math.max(0.5, Math.min(1, w / labelSize[0], h / labelSize[1]))
const bounds = this.getBounds()
const offset = React.useMemo(() => {
return Vec.sub(midPoint, Vec.toFixed([bounds.width / 2, bounds.height / 2]))
}, [bounds, scale, midPoint])
const handleLabelChange = React.useCallback(
(label: string) => {
this.update?.({ label })
},
[label]
)
return (
<div
{...events}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
className="tl-box-container"
>
<TextLabel
font={font}
text={label}
color={getComputedColor(stroke, 'text')}
offsetX={offset[0]}
offsetY={offset[1]}
fontSize={fontSize}
scale={scale}
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onEditingEnd}
fontStyle={italic ? 'italic' : 'normal'}
fontWeight={fontWeight}
pointerEvents={!!label}
/>
<SVGContainer opacity={isErasing ? 0.2 : opacity}>
{isBinding && <BindingIndicator mode="svg" strokeWidth={strokeWidth} size={[w, h]} />}
<rect
className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
x={strokeWidth / 2}
y={strokeWidth / 2}
rx={borderRadius}
ry={borderRadius}
width={Math.max(0.01, w - strokeWidth)}
height={Math.max(0.01, h - strokeWidth)}
pointerEvents="all"
/>
<rect
x={strokeWidth / 2}
y={strokeWidth / 2}
rx={borderRadius}
ry={borderRadius}
width={Math.max(0.01, w - strokeWidth)}
height={Math.max(0.01, h - strokeWidth)}
strokeWidth={strokeWidth}
stroke={getComputedColor(stroke, 'stroke')}
strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
fill={noFill ? 'none' : getComputedColor(fill, 'background')}
/>
</SVGContainer>
</div>
)
}
)
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
this.update({
scaleLevel: v,
fontSize: levelToScale[v ?? 'md'],
strokeWidth: levelToScale[v ?? 'md'] / 10,
})
this.onResetBounds()
}
ReactIndicator = observer(() => {
const {
props: {
size: [w, h],
borderRadius,
isLocked,
},
} = this
return (
<g>
<rect
width={w}
height={h}
rx={borderRadius}
ry={borderRadius}
fill="transparent"
strokeDasharray={isLocked ? '8 2' : undefined}
/>
</g>
)
})
validateProps = (props: Partial<BoxShapeProps>) => {
if (props.size !== undefined) {
props.size[0] = Math.max(props.size[0], 1)
props.size[1] = Math.max(props.size[1], 1)
}
if (props.borderRadius !== undefined) props.borderRadius = Math.max(0, props.borderRadius)
return withClampedStyles(this, props)
}
}

View File

@@ -1,62 +0,0 @@
import { TLDotShape, TLDotShapeProps } from '@tldraw/core'
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import { CustomStyleProps, withClampedStyles } from './style-props'
export interface DotShapeProps extends TLDotShapeProps, CustomStyleProps {
type: 'dot'
}
export class DotShape extends TLDotShape<DotShapeProps> {
static id = 'dot'
static defaultProps: DotShapeProps = {
id: 'dot',
parentId: 'page',
type: 'dot',
point: [0, 0],
radius: 4,
stroke: '#000000',
fill: 'var(--ls-secondary-background-color)',
noFill: false,
strokeType: 'line',
strokeWidth: 2,
opacity: 1,
}
ReactComponent = observer(({ events, isErasing }: TLComponentProps) => {
const { radius, stroke, fill, strokeWidth, opacity } = this.props
return (
<SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
<circle className="tl-hitarea-fill" cx={radius} cy={radius} r={radius} />
<circle
cx={radius}
cy={radius}
r={radius}
stroke={stroke}
fill={fill}
strokeWidth={strokeWidth}
pointerEvents="none"
/>
</SVGContainer>
)
})
ReactIndicator = observer(() => {
const { radius, isLocked } = this.props
return (
<circle
cx={radius}
cy={radius}
r={radius}
pointerEvents="all"
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
)
})
validateProps = (props: Partial<DotShapeProps>) => {
if (props.radius !== undefined) props.radius = Math.max(props.radius, 1)
return withClampedStyles(this, props)
}
}

View File

@@ -1,223 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
TLEllipseShapeProps,
TLEllipseShape,
getComputedColor,
getTextLabelSize,
} from '@tldraw/core'
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import Vec from '@tldraw/vec'
import * as React from 'react'
import { observer } from 'mobx-react-lite'
import { CustomStyleProps, withClampedStyles } from './style-props'
import { TextLabel } from './text/TextLabel'
import type { SizeLevel } from '.'
import { action, computed } from 'mobx'
export interface EllipseShapeProps extends TLEllipseShapeProps, CustomStyleProps {
type: 'ellipse'
size: number[]
label: string
fontSize: number
fontWeight: number
italic: boolean
scaleLevel?: SizeLevel
}
const font = '18px / 1 var(--ls-font-family)'
const levelToScale = {
xs: 10,
sm: 16,
md: 20,
lg: 32,
xl: 48,
xxl: 60,
}
export class EllipseShape extends TLEllipseShape<EllipseShapeProps> {
static id = 'ellipse'
static defaultProps: EllipseShapeProps = {
id: 'ellipse',
parentId: 'page',
type: 'ellipse',
point: [0, 0],
size: [100, 100],
stroke: '',
fill: '',
noFill: false,
fontWeight: 400,
fontSize: 20,
italic: false,
strokeType: 'line',
strokeWidth: 2,
opacity: 1,
label: '',
}
canEdit = true
ReactComponent = observer(
({ isSelected, isErasing, events, isEditing, onEditingEnd }: TLComponentProps) => {
const {
size: [w, h],
stroke,
fill,
noFill,
strokeWidth,
strokeType,
opacity,
label,
italic,
fontWeight,
fontSize,
} = this.props
const labelSize =
label || isEditing
? getTextLabelSize(
label,
{ fontFamily: 'var(--ls-font-family)', fontSize, lineHeight: 1, fontWeight },
4
)
: [0, 0]
const midPoint = Vec.mul(this.props.size, 0.5)
const scale = Math.max(0.5, Math.min(1, w / labelSize[0], h / labelSize[1]))
const bounds = this.getBounds()
const offset = React.useMemo(() => {
return Vec.sub(midPoint, Vec.toFixed([bounds.width / 2, bounds.height / 2]))
}, [bounds, scale, midPoint])
const handleLabelChange = React.useCallback(
(label: string) => {
this.update?.({ label })
},
[label]
)
return (
<div
{...events}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
className="tl-ellipse-container"
>
<TextLabel
font={font}
text={label}
color={getComputedColor(stroke, 'text')}
offsetX={offset[0]}
offsetY={offset[1]}
scale={scale}
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onEditingEnd}
fontStyle={italic ? 'italic' : 'normal'}
fontSize={fontSize}
fontWeight={fontWeight}
pointerEvents={!!label}
/>
<SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
<ellipse
className={isSelected || !noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
cx={w / 2}
cy={h / 2}
rx={Math.max(0.01, (w - strokeWidth) / 2)}
ry={Math.max(0.01, (h - strokeWidth) / 2)}
/>
<ellipse
cx={w / 2}
cy={h / 2}
rx={Math.max(0.01, (w - strokeWidth) / 2)}
ry={Math.max(0.01, (h - strokeWidth) / 2)}
strokeWidth={strokeWidth}
stroke={getComputedColor(stroke, 'stroke')}
strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
fill={noFill ? 'none' : getComputedColor(fill, 'background')}
/>
</SVGContainer>
</div>
)
}
)
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
this.update({
scaleLevel: v,
fontSize: levelToScale[v ?? 'md'],
strokeWidth: levelToScale[v ?? 'md'] / 10,
})
this.onResetBounds()
}
ReactIndicator = observer(() => {
const {
size: [w, h],
isLocked,
} = this.props
return (
<g>
<ellipse
cx={w / 2}
cy={h / 2}
rx={w / 2}
ry={h / 2}
strokeWidth={2}
fill="transparent"
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
</g>
)
})
validateProps = (props: Partial<EllipseShapeProps>) => {
if (props.size !== undefined) {
props.size[0] = Math.max(props.size[0], 1)
props.size[1] = Math.max(props.size[1], 1)
}
return withClampedStyles(this, props)
}
/**
* Get a svg group element that can be used to render the shape with only the props data. In the
* base, draw any shape as a box. Can be overridden by subclasses.
*/
getShapeSVGJsx(opts: any) {
const {
size: [w, h],
stroke,
fill,
noFill,
strokeWidth,
strokeType,
opacity,
} = this.props
return (
<g opacity={opacity}>
<ellipse
className={!noFill ? 'tl-hitarea-fill' : 'tl-hitarea-stroke'}
cx={w / 2}
cy={h / 2}
rx={Math.max(0.01, (w - strokeWidth) / 2)}
ry={Math.max(0.01, (h - strokeWidth) / 2)}
/>
<ellipse
cx={w / 2}
cy={h / 2}
rx={Math.max(0.01, (w - strokeWidth) / 2)}
ry={Math.max(0.01, (h - strokeWidth) / 2)}
strokeWidth={strokeWidth}
stroke={getComputedColor(stroke, 'stroke')}
strokeDasharray={strokeType === 'dashed' ? '8 2' : undefined}
fill={noFill ? 'none' : getComputedColor(fill, 'background')}
/>
</g>
)
}
}

View File

@@ -1,65 +0,0 @@
import { GROUP_PADDING, TLGroupShape, TLGroupShapeProps } from '@tldraw/core'
import { SVGContainer, TLComponentProps, useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
export interface GroupShapeProps extends TLGroupShapeProps {}
export class GroupShape extends TLGroupShape<GroupShapeProps> {
static id = 'group'
static defaultProps: GroupShapeProps = {
id: 'group',
type: 'group',
parentId: 'page',
point: [0, 0],
size: [0, 0],
children: [],
}
// TODO: add styles for arrow binding states
ReactComponent = observer(({ events }: TLComponentProps) => {
const strokeWidth = 2
const bounds = this.getBounds()
const app = useApp()
const childSelected = app.selectedShapesArray.some(s => {
return app.shapesInGroups([this]).includes(s)
})
const Indicator = this.ReactIndicator
return (
<SVGContainer {...events} className="tl-group-container">
<rect
className={'tl-hitarea-fill'}
x={strokeWidth / 2}
y={strokeWidth / 2}
width={Math.max(0.01, bounds.width - strokeWidth)}
height={Math.max(0.01, bounds.height - strokeWidth)}
pointerEvents="all"
/>
{childSelected && (
<g stroke="var(--color-selectedFill)">
<Indicator />
</g>
)}
</SVGContainer>
)
})
ReactIndicator = observer(() => {
const bounds = this.getBounds()
return (
<rect
strokeDasharray="8 2"
x={-GROUP_PADDING}
y={-GROUP_PADDING}
rx={GROUP_PADDING / 2}
ry={GROUP_PADDING / 2}
width={bounds.width + GROUP_PADDING * 2}
height={bounds.height + GROUP_PADDING * 2}
fill="transparent"
/>
)
})
}

View File

@@ -1,159 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { delay, TLBoxShape, TLBoxShapeProps, TLResetBoundsInfo } from '@tldraw/core'
import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
import Vec from '@tldraw/vec'
import { action, computed } from 'mobx'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import type { SizeLevel, Shape } from '.'
import { useCameraMovingRef } from '../../hooks/useCameraMoving'
import { withClampedStyles } from './style-props'
export interface HTMLShapeProps extends TLBoxShapeProps {
type: 'html'
html: string
scaleLevel?: SizeLevel
}
const levelToScale = {
xs: 0.5,
sm: 0.8,
md: 1,
lg: 1.5,
xl: 2,
xxl: 3,
}
export class HTMLShape extends TLBoxShape<HTMLShapeProps> {
static id = 'html'
static defaultProps: HTMLShapeProps = {
id: 'html',
type: 'html',
parentId: 'page',
point: [0, 0],
size: [600, 0],
html: '',
}
canChangeAspectRatio = true
canFlip = false
canEdit = true
htmlAnchorRef = React.createRef<HTMLDivElement>()
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
const newSize = Vec.mul(
this.props.size,
levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
)
this.update({
scaleLevel: v,
})
await delay()
this.update({
size: newSize,
})
}
onResetBounds = (info?: TLResetBoundsInfo) => {
if (this.htmlAnchorRef.current) {
const rect = this.htmlAnchorRef.current.getBoundingClientRect()
const [w, h] = Vec.div([rect.width, rect.height], info?.zoom ?? 1)
const clamp = (v: number) => Math.max(Math.min(v || 400, 1400), 10)
this.update({
size: [clamp(w), clamp(h)],
})
}
return this
}
ReactComponent = observer(({ events, isErasing, isEditing }: TLComponentProps) => {
const {
props: { html, scaleLevel },
} = this
const isMoving = useCameraMovingRef()
const app = useApp<Shape>()
const isSelected = app.selectedIds.has(this.id)
const tlEventsEnabled =
isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select'
const stop = React.useCallback(
e => {
if (!tlEventsEnabled) {
// TODO: pinching inside Logseq Shape issue
e.stopPropagation()
}
},
[tlEventsEnabled]
)
const scaleRatio = levelToScale[scaleLevel ?? 'md']
React.useEffect(() => {
if (this.props.size[1] === 0) {
this.onResetBounds({ zoom: app.viewport.camera.zoom })
app.persist()
}
}, [])
return (
<HTMLContainer
style={{
overflow: 'hidden',
pointerEvents: 'all',
opacity: isErasing ? 0.2 : 1,
}}
{...events}
>
<div
onWheelCapture={stop}
onPointerDown={stop}
onPointerUp={stop}
className="tl-html-container"
style={{
pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
overflow: isEditing ? 'auto' : 'hidden',
width: `calc(100% / ${scaleRatio})`,
height: `calc(100% / ${scaleRatio})`,
transform: `scale(${scaleRatio})`,
}}
>
<div
ref={this.htmlAnchorRef}
className="tl-html-anchor"
dangerouslySetInnerHTML={{ __html: html.trim() }}
/>
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {
props: {
size: [w, h],
isLocked,
},
} = this
return (
<rect
width={w}
height={h}
fill="transparent"
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
)
})
validateProps = (props: Partial<HTMLShapeProps>) => {
if (props.size !== undefined) {
props.size[0] = Math.max(props.size[0], 1)
props.size[1] = Math.max(props.size[1], 1)
}
return withClampedStyles(this, props)
}
}

View File

@@ -1,116 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SvgPathUtils, TLDrawShape, TLDrawShapeProps, getComputedColor } from '@tldraw/core'
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import { action, computed, makeObservable } from 'mobx'
import type { SizeLevel } from '.'
import { CustomStyleProps, withClampedStyles } from './style-props'
export interface HighlighterShapeProps extends TLDrawShapeProps, CustomStyleProps {
type: 'highlighter'
scaleLevel?: SizeLevel
}
const levelToScale = {
xs: 1,
sm: 1.6,
md: 2,
lg: 3.2,
xl: 4.8,
xxl: 6,
}
export class HighlighterShape extends TLDrawShape<HighlighterShapeProps> {
constructor(props = {} as Partial<HighlighterShapeProps>) {
super(props)
makeObservable(this)
}
static id = 'highlighter'
static defaultProps: HighlighterShapeProps = {
id: 'highlighter',
parentId: 'page',
type: 'highlighter',
point: [0, 0],
points: [],
isComplete: false,
stroke: '',
fill: '',
noFill: true,
strokeType: 'line',
strokeWidth: 2,
opacity: 0.5,
}
@computed get pointsPath() {
const { points } = this.props
return SvgPathUtils.getCurvedPathForPoints(points)
}
ReactComponent = observer(({ events, isErasing }: TLComponentProps) => {
const {
pointsPath,
props: { stroke, strokeWidth, opacity },
} = this
return (
<SVGContainer {...events} opacity={isErasing ? 0.2 : 1}>
<path
d={pointsPath}
strokeWidth={strokeWidth * 16}
stroke={getComputedColor(stroke, 'stroke')}
fill="none"
pointerEvents="all"
strokeLinejoin="round"
strokeLinecap="round"
opacity={opacity}
/>
</SVGContainer>
)
})
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
this.update({
scaleLevel: v,
strokeWidth: levelToScale[v ?? 'md'],
})
this.onResetBounds()
}
ReactIndicator = observer(() => {
const { pointsPath, props } = this
return (
<path d={pointsPath} fill="none" strokeDasharray={props.isLocked ? '8 2' : 'undefined'} />
)
})
validateProps = (props: Partial<HighlighterShapeProps>) => {
props = withClampedStyles(this, props)
if (props.strokeWidth !== undefined) props.strokeWidth = Math.max(props.strokeWidth, 1)
return props
}
getShapeSVGJsx() {
const {
pointsPath,
props: { stroke, strokeWidth, opacity },
} = this
return (
<path
d={pointsPath}
strokeWidth={strokeWidth * 16}
stroke={getComputedColor(stroke, 'stroke')}
fill="none"
pointerEvents="all"
strokeLinejoin="round"
strokeLinecap="round"
opacity={opacity}
/>
)
}
}

View File

@@ -1,100 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
import { action } from 'mobx'
import { observer } from 'mobx-react-lite'
export interface IFrameShapeProps extends TLBoxShapeProps {
type: 'iframe'
url: string
}
export class IFrameShape extends TLBoxShape<IFrameShapeProps> {
static id = 'iframe'
frameRef = React.createRef<HTMLIFrameElement>()
static defaultProps: IFrameShapeProps = {
id: 'iframe',
type: 'iframe',
parentId: 'page',
point: [0, 0],
size: [853, 480],
url: '',
}
canEdit = true
@action onIFrameSourceChange = (url: string) => {
this.update({ url })
}
@action reload = () => {
if (this.frameRef.current) {
this.frameRef.current.src = this.frameRef?.current?.src
}
}
ReactComponent = observer(({ events, isErasing, isEditing }: TLComponentProps) => {
const ref = React.useRef<HTMLIFrameElement>(null)
const app = useApp<Shape>()
return (
<HTMLContainer
style={{
overflow: 'hidden',
pointerEvents: 'all',
opacity: isErasing ? 0.2 : 1,
}}
{...events}
>
<div
className="tl-iframe-container"
style={{
pointerEvents: isEditing || app.readOnly ? 'all' : 'none',
userSelect: 'none',
}}
>
{this.props.url && (
<div
style={{
overflow: 'hidden',
position: 'relative',
height: '100%',
}}
>
<iframe
ref={this.frameRef}
className="absolute inset-0 w-full h-full m-0"
width="100%"
height="100%"
src={`${this.props.url}`}
frameBorder="0"
sandbox="allow-scripts allow-same-origin allow-presentation"
/>
</div>
)}
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {
props: {
size: [w, h],
isLocked,
},
} = this
return (
<rect
width={w}
height={h}
fill="transparent"
rx={8}
ry={8}
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
)
})
}

View File

@@ -1,119 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TLAsset, TLImageShape, TLImageShapeProps } from '@tldraw/core'
import { HTMLContainer, TLComponentProps } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import { LogseqContext } from '../logseq-context'
import { BindingIndicator } from './BindingIndicator'
export interface ImageShapeProps extends TLImageShapeProps {
type: 'image'
assetId: string
opacity: number
}
export class ImageShape extends TLImageShape<ImageShapeProps> {
static id = 'image'
static defaultProps: ImageShapeProps = {
id: 'image1',
parentId: 'page',
type: 'image',
point: [0, 0],
size: [100, 100],
opacity: 1,
assetId: '',
clipping: 0,
objectFit: 'fill',
isAspectRatioLocked: true,
}
ReactComponent = observer(({ events, isErasing, isBinding, asset }: TLComponentProps) => {
const {
props: {
opacity,
objectFit,
clipping,
size: [w, h],
},
} = this
const [t, r, b, l] = Array.isArray(clipping)
? clipping
: [clipping, clipping, clipping, clipping]
const { handlers } = React.useContext(LogseqContext)
return (
<HTMLContainer {...events} opacity={isErasing ? 0.2 : opacity}>
{isBinding && <BindingIndicator mode="html" strokeWidth={4} size={[w, h]} />}
<div data-asset-loaded={!!asset} className="tl-image-shape-container">
{asset ? (
<img
src={handlers ? handlers.makeAssetUrl(asset.src) : asset.src}
draggable={false}
style={{
position: 'relative',
top: -t,
left: -l,
width: w + (l - r),
height: h + (t - b),
objectFit,
}}
/>
) : (
'Asset is missing'
)}
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {
props: {
size: [w, h],
isLocked,
},
} = this
return (
<rect
width={w}
height={h}
fill="transparent"
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
)
})
getShapeSVGJsx({ assets }: { assets: TLAsset[] }) {
// Do not need to consider the original point here
const bounds = this.getBounds()
const {
assetId,
clipping,
size: [w, h],
} = this.props
const asset = assets.find(ass => ass.id === assetId)
if (asset) {
// TODO: add clipping
const [t, r, b, l] = Array.isArray(clipping)
? clipping
: [clipping, clipping, clipping, clipping]
const make_asset_url = window.logseq?.api?.make_asset_url
return (
<image
width={bounds.width}
height={bounds.height}
href={make_asset_url ? make_asset_url(asset.src) : asset.src}
/>
)
} else {
return super.getShapeSVGJsx({})
}
}
}

View File

@@ -1,252 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Decoration, TLLineShape, TLLineShapeProps, getComputedColor } from '@tldraw/core'
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import Vec from '@tldraw/vec'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import { Arrow } from './arrow/Arrow'
import { getArrowPath } from './arrow/arrowHelpers'
import { CustomStyleProps, withClampedStyles } from './style-props'
import { getTextLabelSize } from '@tldraw/core'
import { LabelMask } from './text/LabelMask'
import { TextLabel } from './text/TextLabel'
import type { SizeLevel } from '.'
import { action, computed } from 'mobx'
interface LineShapeProps extends CustomStyleProps, TLLineShapeProps {
type: 'line'
label: string
fontSize: number
fontWeight: number
italic: boolean
scaleLevel?: SizeLevel
}
const font = '20px / 1 var(--ls-font-family)'
const levelToScale = {
xs: 10,
sm: 16,
md: 20,
lg: 32,
xl: 48,
xxl: 60,
}
export class LineShape extends TLLineShape<LineShapeProps> {
static id = 'line'
static defaultProps: LineShapeProps = {
id: 'line',
parentId: 'page',
type: 'line',
point: [0, 0],
handles: {
start: { id: 'start', canBind: true, point: [0, 0] },
end: { id: 'end', canBind: true, point: [1, 1] },
},
stroke: '',
fill: '',
noFill: true,
fontWeight: 400,
fontSize: 20,
italic: false,
strokeType: 'line',
strokeWidth: 1,
opacity: 1,
decorations: {
end: Decoration.Arrow,
},
label: '',
}
hideSelection = true
canEdit = true
ReactComponent = observer(({ events, isErasing, isEditing, onEditingEnd }: TLComponentProps) => {
const {
stroke,
handles: { start, end },
opacity,
label,
italic,
fontWeight,
fontSize,
id,
} = this.props
const labelSize =
label || isEditing
? getTextLabelSize(
label || 'Enter text',
{ fontFamily: 'var(--ls-font-family)', fontSize, lineHeight: 1, fontWeight },
6
)
: [0, 0]
const midPoint = Vec.med(start.point, end.point)
const dist = Vec.dist(start.point, end.point)
const scale = Math.max(
0.5,
Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
)
const bounds = this.getBounds()
const offset = React.useMemo(() => {
return Vec.sub(midPoint, Vec.toFixed([bounds.width / 2, bounds.height / 2]))
}, [bounds, scale, midPoint])
const handleLabelChange = React.useCallback(
(label: string) => {
this.update?.({ label })
},
[label]
)
return (
<div
{...events}
style={{ width: '100%', height: '100%', overflow: 'hidden' }}
className="tl-line-container"
>
<TextLabel
font={font}
text={label}
fontSize={fontSize}
color={getComputedColor(stroke, 'text')}
offsetX={offset[0]}
offsetY={offset[1]}
scale={scale}
isEditing={isEditing}
onChange={handleLabelChange}
onBlur={onEditingEnd}
fontStyle={italic ? 'italic' : 'normal'}
fontWeight={fontWeight}
pointerEvents={!!label}
/>
<SVGContainer opacity={isErasing ? 0.2 : opacity} id={id + '_svg'}>
<LabelMask id={id} bounds={bounds} labelSize={labelSize} offset={offset} scale={scale} />
<g pointerEvents="none" mask={label || isEditing ? `url(#${id}_clip)` : ``}>
{this.getShapeSVGJsx({ preview: false })}
</g>
</SVGContainer>
</div>
)
})
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
this.update({
scaleLevel: v,
fontSize: levelToScale[v ?? 'md'],
})
this.onResetBounds()
}
ReactIndicator = observer(({ isEditing }: TLComponentProps) => {
const {
id,
decorations,
label,
strokeWidth,
fontSize,
fontWeight,
handles: { start, end },
isLocked,
} = this.props
const bounds = this.getBounds()
const labelSize =
label || isEditing
? getTextLabelSize(
label,
{ fontFamily: 'var(--ls-font-family)', fontSize, lineHeight: 1, fontWeight },
6
)
: [0, 0]
const midPoint = Vec.med(start.point, end.point)
const dist = Vec.dist(start.point, end.point)
const scale = Math.max(
0.5,
Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
)
const offset = React.useMemo(() => {
return Vec.sub(midPoint, Vec.toFixed([bounds.width / 2, bounds.height / 2]))
}, [bounds, scale, midPoint])
return (
<g>
<path
mask={label ? `url(#${id}_clip)` : ``}
d={getArrowPath(
{ strokeWidth },
start.point,
end.point,
decorations?.start,
decorations?.end
)}
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
{label && !isEditing && (
<rect
x={bounds.width / 2 - (labelSize[0] / 2) * scale + offset[0]}
y={bounds.height / 2 - (labelSize[1] / 2) * scale + offset[1]}
width={labelSize[0] * scale}
height={labelSize[1] * scale}
rx={4 * scale}
ry={4 * scale}
fill="transparent"
/>
)}
</g>
)
})
validateProps = (props: Partial<LineShapeProps>) => {
return withClampedStyles(this, props)
}
getShapeSVGJsx({ preview }: any) {
const {
stroke,
fill,
strokeWidth,
strokeType,
decorations,
label,
scaleLevel,
handles: { start, end },
} = this.props
const midPoint = Vec.med(start.point, end.point)
return (
<>
<Arrow
style={{
stroke: getComputedColor(stroke, 'text'),
fill,
strokeWidth,
strokeType,
}}
scaleLevel={scaleLevel}
start={start.point}
end={end.point}
decorationStart={decorations?.start}
decorationEnd={decorations?.end}
/>
{preview && (
<>
<text
style={{
transformOrigin: 'top left',
}}
fontFamily="Inter"
fontSize={20}
transform={`translate(${midPoint[0]}, ${midPoint[1]})`}
textAnchor="middle"
fill={getComputedColor(stroke, 'text')}
stroke={getComputedColor(stroke, 'text')}
>
{label}
</text>
</>
)}
</>
)
}
}

View File

@@ -1,605 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
delay,
getComputedColor,
TLBoxShape,
TLBoxShapeProps,
TLResetBoundsInfo,
TLResizeInfo,
validUUID,
isBuiltInColor,
} from '@tldraw/core'
import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
import Vec from '@tldraw/vec'
import { action, computed, makeObservable } from 'mobx'
import { observer } from 'mobx-react-lite'
import * as React from 'react'
import type { Shape, SizeLevel } from '.'
import { LogseqQuickSearch } from '../../components/QuickSearch'
import { useCameraMovingRef } from '../../hooks/useCameraMoving'
import { LogseqContext } from '../logseq-context'
import { BindingIndicator } from './BindingIndicator'
import { CustomStyleProps, withClampedStyles } from './style-props'
const HEADER_HEIGHT = 40
const AUTO_RESIZE_THRESHOLD = 1
export interface LogseqPortalShapeProps extends TLBoxShapeProps, CustomStyleProps {
type: 'logseq-portal'
pageId: string // page name or UUID
blockType?: 'P' | 'B'
collapsed?: boolean
compact?: boolean
borderRadius?: number
collapsedHeight?: number
scaleLevel?: SizeLevel
}
const levelToScale = {
xs: 0.5,
sm: 0.8,
md: 1,
lg: 1.5,
xl: 2,
xxl: 3,
}
const LogseqPortalShapeHeader = observer(
({
type,
fill,
opacity,
children,
}: {
type: 'P' | 'B'
fill?: string
opacity: number
children: React.ReactNode
}) => {
const bgColor =
fill !== 'var(--ls-secondary-background-color)'
? getComputedColor(fill, 'background')
: 'var(--ls-tertiary-background-color)'
const fillGradient =
fill && fill !== 'var(--ls-secondary-background-color)'
? isBuiltInColor(fill)
? `var(--ls-highlight-color-${fill})`
: fill
: 'var(--ls-secondary-background-color)'
return (
<div
className={`tl-logseq-portal-header tl-logseq-portal-header-${
type === 'P' ? 'page' : 'block'
}`}
>
<div
className="absolute inset-0 tl-logseq-portal-header-bg"
style={{
opacity,
background:
type === 'P' ? bgColor : `linear-gradient(0deg, ${fillGradient}, ${bgColor})`,
}}
></div>
<div className="relative">{children}</div>
</div>
)
}
)
export class LogseqPortalShape extends TLBoxShape<LogseqPortalShapeProps> {
static id = 'logseq-portal'
static defaultSearchQuery = ''
static defaultSearchFilter: 'B' | 'P' | null = null
static defaultProps: LogseqPortalShapeProps = {
id: 'logseq-portal',
type: 'logseq-portal',
parentId: 'page',
point: [0, 0],
size: [400, 50],
// collapsedHeight is the height before collapsing
collapsedHeight: 0,
stroke: '',
fill: '',
noFill: false,
borderRadius: 8,
strokeWidth: 2,
strokeType: 'line',
opacity: 1,
pageId: '',
collapsed: false,
compact: false,
scaleLevel: 'md',
isAutoResizing: true,
}
hideRotateHandle = true
canChangeAspectRatio = true
canFlip = true
canEdit = true
persist: ((replace?: boolean) => void) | null = null
// For quick add shapes, we want to calculate the page height dynamically
initialHeightCalculated = true
getInnerHeight: (() => number) | null = null // will be overridden in the hook
constructor(props = {} as Partial<LogseqPortalShapeProps>) {
super(props)
makeObservable(this)
if (props.collapsed) {
Object.assign(this.canResize, [true, false])
}
if (props.size?.[1] === 0) {
this.initialHeightCalculated = false
}
}
static isPageOrBlock(id: string): 'P' | 'B' | false {
const blockRefEg = '((62af02d0-0443-42e8-a284-946c162b0f89))'
if (id) {
return /^\(\(.*\)\)$/.test(id) && id.length === blockRefEg.length ? 'B' : 'P'
}
return false
}
@computed get collapsed() {
return this.props.blockType === 'B' ? this.props.compact : this.props.collapsed
}
@action setCollapsed = async (collapsed: boolean) => {
if (this.props.blockType === 'B') {
this.update({ compact: collapsed })
this.canResize[1] = !collapsed
if (!collapsed) {
this.onResetBounds()
}
} else {
const originalHeight = this.props.size[1]
this.canResize[1] = !collapsed
this.update({
isAutoResizing: !collapsed,
collapsed: collapsed,
size: [this.props.size[0], collapsed ? this.getHeaderHeight() : this.props.collapsedHeight],
collapsedHeight: collapsed ? originalHeight : this.props.collapsedHeight,
})
}
this.persist?.()
}
@computed get scaleLevel() {
return this.props.scaleLevel ?? 'md'
}
@action setScaleLevel = async (v?: SizeLevel) => {
const newSize = Vec.mul(
this.props.size,
levelToScale[(v as SizeLevel) ?? 'md'] / levelToScale[this.props.scaleLevel ?? 'md']
)
this.update({
scaleLevel: v,
})
await delay()
this.update({
size: newSize,
})
}
useComponentSize<T extends HTMLElement>(ref: React.RefObject<T> | null, selector = '') {
const [size, setSize] = React.useState<[number, number]>([0, 0])
const app = useApp<Shape>()
React.useEffect(() => {
setTimeout(() => {
if (ref?.current) {
const el = selector ? ref.current.querySelector<HTMLElement>(selector) : ref.current
if (el) {
const updateSize = () => {
const { width, height } = el.getBoundingClientRect()
const bound = Vec.div([width, height], app.viewport.camera.zoom) as [number, number]
setSize(bound)
return bound
}
updateSize()
// Hacky, I know 🤨
this.getInnerHeight = () => updateSize()[1]
const resizeObserver = new ResizeObserver(() => {
updateSize()
})
resizeObserver.observe(el)
return () => {
resizeObserver.disconnect()
}
}
}
return () => {}
}, 10);
}, [ref, selector])
return size
}
getHeaderHeight() {
const scale = levelToScale[this.props.scaleLevel ?? 'md']
return this.props.compact ? 0 : HEADER_HEIGHT * scale
}
getAutoResizeHeight() {
if (this.getInnerHeight) {
return this.getHeaderHeight() + this.getInnerHeight()
}
return null
}
onResetBounds = (info?: TLResetBoundsInfo) => {
const height = this.getAutoResizeHeight()
if (height !== null && Math.abs(height - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
this.update({
size: [this.props.size[0], height],
})
this.initialHeightCalculated = true
}
return this
}
onResize = (initialProps: any, info: TLResizeInfo): this => {
const {
bounds,
rotation,
scale: [scaleX, scaleY],
} = info
const nextScale = [...this.scale]
if (scaleX < 0) nextScale[0] *= -1
if (scaleY < 0) nextScale[1] *= -1
let height = bounds.height
if (this.props.isAutoResizing) {
height = this.getAutoResizeHeight() ?? height
}
return this.update({
point: [bounds.minX, bounds.minY],
size: [Math.max(1, bounds.width), Math.max(1, height)],
scale: nextScale,
rotation,
})
}
PortalComponent = observer(({}: TLComponentProps) => {
const {
props: { pageId, fill, opacity },
} = this
const { renderers } = React.useContext(LogseqContext)
const app = useApp<Shape>()
const cpRefContainer = React.useRef<HTMLDivElement>(null)
const [, innerHeight] = this.useComponentSize(
cpRefContainer,
this.props.compact
? '.tl-logseq-cp-container > .single-block'
: '.tl-logseq-cp-container > .page'
)
if (!renderers?.Page) {
return null // not being correctly configured
}
const { Page, Block } = renderers
const [loaded, setLoaded] = React.useState(false)
React.useEffect(() => {
if (this.props.isAutoResizing) {
const latestInnerHeight = this.getInnerHeight?.() ?? innerHeight
const newHeight = latestInnerHeight + this.getHeaderHeight()
if (innerHeight && Math.abs(newHeight - this.props.size[1]) > AUTO_RESIZE_THRESHOLD) {
this.update({
size: [this.props.size[0], newHeight],
})
if (loaded) app.persist({})
}
}
}, [innerHeight, this.props.isAutoResizing])
React.useEffect(() => {
if (!this.initialHeightCalculated) {
setTimeout(() => {
this.onResetBounds()
app.persist({})
})
}
}, [this.initialHeightCalculated])
React.useEffect(() => {
setTimeout(function () {
setLoaded(true)
})
}, [])
return (
<>
<div
className="absolute inset-0 tl-logseq-cp-container-bg"
style={{
textRendering: app.viewport.camera.zoom < 0.5 ? 'optimizeSpeed' : 'auto',
background:
fill && fill !== 'var(--ls-secondary-background-color)'
? isBuiltInColor(fill)
? `var(--ls-highlight-color-${fill})`
: fill
: 'var(--ls-secondary-background-color)',
opacity,
}}
></div>
<div
ref={cpRefContainer}
className="relative tl-logseq-cp-container"
style={{ overflow: this.props.isAutoResizing ? 'visible' : 'auto' }}
>
{(loaded || !this.initialHeightCalculated) &&
(this.props.blockType === 'B' && this.props.compact ? (
<Block blockId={pageId} />
) : (
<Page pageName={pageId} />
))}
</div>
</>
)
})
ReactComponent = observer((componentProps: TLComponentProps) => {
const { events, isErasing, isEditing, isBinding } = componentProps
const {
props: { opacity, pageId, fill, scaleLevel, strokeWidth, size, isLocked },
} = this
const app = useApp<Shape>()
const { renderers, handlers } = React.useContext(LogseqContext)
this.persist = () => app.persist()
const isMoving = useCameraMovingRef()
const isSelected = app.selectedIds.has(this.id) && app.selectedIds.size === 1
const isCreating = app.isIn('logseq-portal.creating') && !pageId
const tlEventsEnabled =
(isMoving || (isSelected && !isEditing) || app.selectedTool.id !== 'select') && !isCreating
const stop = React.useCallback(
e => {
if (!tlEventsEnabled) {
// TODO: pinching inside Logseq Shape issue
e.stopPropagation()
}
},
[tlEventsEnabled]
)
// There are some other portal sharing the same page id are selected
const portalSelected =
app.selectedShapesArray.length === 1 &&
app.selectedShapesArray.some(
shape =>
shape.type === 'logseq-portal' &&
shape.props.id !== this.props.id &&
pageId &&
(shape as LogseqPortalShape).props['pageId'] === pageId
)
const scaleRatio = levelToScale[scaleLevel ?? 'md']
// It is a bit weird to update shapes here. Is there a better place?
React.useEffect(() => {
if (this.props.collapsed && isEditing) {
// Should temporarily disable collapsing
this.update({
size: [this.props.size[0], this.props.collapsedHeight],
})
return () => {
this.update({
size: [this.props.size[0], this.getHeaderHeight()],
})
}
}
return () => {
// no-ops
}
}, [isEditing, this.props.collapsed])
React.useEffect(() => {
if (isCreating) {
const screenSize = [app.viewport.bounds.width, app.viewport.bounds.height]
const boundScreenCenter = app.viewport.getScreenPoint([this.bounds.minX, this.bounds.minY])
if (
boundScreenCenter[0] > screenSize[0] - 400 ||
boundScreenCenter[1] > screenSize[1] - 240 ||
app.viewport.camera.zoom > 1.5 ||
app.viewport.camera.zoom < 0.5
) {
app.viewport.zoomToBounds({ ...this.bounds, minY: this.bounds.maxY + 25 })
}
}
}, [app.viewport.bounds.height.toFixed(2)])
const onPageNameChanged = React.useCallback((id: string, isPage: boolean) => {
this.initialHeightCalculated = false
const blockType = isPage ? 'P' : 'B'
const height = isPage ? 320 : 40
this.update({
pageId: id,
size: [400, height],
blockType: blockType,
compact: blockType === 'B',
})
app.selectTool('select')
app.history.resume()
app.history.persist()
}, [])
const PortalComponent = this.PortalComponent
const blockContent = React.useMemo(() => {
if (pageId && this.props.blockType === 'B') {
return handlers?.queryBlockByUUID(pageId)?.title
}
}, [handlers?.queryBlockByUUID, pageId])
const targetNotFound = this.props.blockType === 'B' && typeof blockContent !== 'string'
const showingPortal = (!this.props.collapsed || isEditing) && !targetNotFound
if (!renderers?.Page) {
return null // not being correctly configured
}
const { Breadcrumb, PageName } = renderers
const portalStyle: React.CSSProperties = {
width: `calc(100% / ${scaleRatio})`,
height: `calc(100% / ${scaleRatio})`,
opacity: isErasing ? 0.2 : 1,
}
// Reduce the chance of blurry text
if (scaleRatio !== 1) {
portalStyle.transform = `scale(${scaleRatio})`
}
return (
<HTMLContainer
style={{
pointerEvents: 'all',
}}
{...events}
>
{isBinding && <BindingIndicator mode="html" strokeWidth={strokeWidth} size={size} />}
<div
data-inner-events={!tlEventsEnabled}
onWheelCapture={stop}
onPointerDown={stop}
onPointerUp={stop}
style={{
width: '100%',
height: '100%',
pointerEvents: !isMoving && (isEditing || isSelected) ? 'all' : 'none',
}}
>
{isCreating ? (
<LogseqQuickSearch
onChange={onPageNameChanged}
onAddBlock={uuid => {
// wait until the editor is mounted
setTimeout(() => {
app.api.editShape(this)
window.logseq?.api?.edit_block?.(uuid)
}, 128)
}}
placeholder="Create or search your graph..."
/>
) : (
<div
className="tl-logseq-portal-container"
data-collapsed={this.collapsed}
data-page-id={pageId}
data-portal-selected={portalSelected}
data-editing={isEditing}
style={portalStyle}
>
{!this.props.compact && !targetNotFound && (
<LogseqPortalShapeHeader
type={this.props.blockType ?? 'P'}
fill={fill}
opacity={opacity}
>
{this.props.blockType === 'P' ? (
<PageName pageName={pageId} />
) : (
<Breadcrumb blockId={pageId} />
)}
</LogseqPortalShapeHeader>
)}
{targetNotFound && <div className="tl-target-not-found">Target not found</div>}
{showingPortal && <PortalComponent {...componentProps} />}
</div>
)}
</div>
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const bounds = this.getBounds()
return (
<rect
width={bounds.width}
height={bounds.height}
fill="transparent"
rx={8}
ry={8}
strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'}
/>
)
})
validateProps = (props: Partial<LogseqPortalShapeProps>) => {
if (props.size !== undefined) {
const scale = levelToScale[this.props.scaleLevel ?? 'md']
props.size[0] = Math.max(props.size[0], 60 * scale)
props.size[1] = Math.max(props.size[1], HEADER_HEIGHT * scale)
}
return withClampedStyles(this, props)
}
getShapeSVGJsx({ preview }: any) {
// Do not need to consider the original point here
const bounds = this.getBounds()
return (
<>
<rect
fill={
this.props.fill && this.props.fill !== 'var(--ls-secondary-background-color)'
? isBuiltInColor(this.props.fill)
? `var(--ls-highlight-color-${this.props.fill})`
: this.props.fill
: 'var(--ls-secondary-background-color)'
}
stroke={getComputedColor(this.props.fill, 'background')}
strokeWidth={this.props.strokeWidth ?? 2}
fillOpacity={this.props.opacity ?? 0.2}
width={bounds.width}
rx={8}
ry={8}
height={bounds.height}
/>
{!this.props.compact && (
<rect
fill={
this.props.fill && this.props.fill !== 'var(--ls-secondary-background-color)'
? getComputedColor(this.props.fill, 'background')
: 'var(--ls-tertiary-background-color)'
}
fillOpacity={this.props.opacity ?? 0.2}
x={1}
y={1}
width={bounds.width - 2}
height={HEADER_HEIGHT - 2}
rx={8}
ry={8}
/>
)}
<text
style={{
transformOrigin: 'top left',
}}
transform={`translate(${bounds.width / 2}, ${10 + bounds.height / 2})`}
textAnchor="middle"
fontFamily="var(--ls-font-family)"
fontSize="32"
fill="var(--ls-secondary-text-color)"
stroke="var(--ls-secondary-text-color)"
>
{this.props.blockType === 'P' ? this.props.pageName : ''}
</text>
</>
)
}
}

View File

@@ -1,83 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import * as React from 'react'
import { TLBoxShape, TLBoxShapeProps } from '@tldraw/core'
import { HTMLContainer, TLComponentProps, useApp } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import { LogseqContext } from '../logseq-context'
import { useCameraMovingRef } from '../../hooks/useCameraMoving'
export interface PdfShapeProps extends TLBoxShapeProps {
type: 'pdf'
assetId: string
}
export class PdfShape extends TLBoxShape<PdfShapeProps> {
static id = 'pdf'
frameRef = React.createRef<HTMLElement>()
static defaultProps: PdfShapeProps = {
id: 'pdf',
type: 'pdf',
parentId: 'page',
point: [0, 0],
size: [595, 842],
assetId: '',
}
canChangeAspectRatio = true
canFlip = true
canEdit = true
ReactComponent = observer(({ events, asset, isErasing, isEditing }: TLComponentProps) => {
const ref = React.useRef<HTMLElement>(null)
const { handlers } = React.useContext(LogseqContext)
const app = useApp<Shape>()
const isMoving = useCameraMovingRef()
return (
<HTMLContainer
style={{
overflow: 'hidden',
pointerEvents: 'all',
opacity: isErasing ? 0.2 : 1,
}}
{...events}
>
{asset ? (
<embed
src={handlers ? handlers.inflateAsset(asset.src).url : asset.src}
className="relative tl-pdf-container"
onWheelCapture={stop}
onPointerDown={stop}
onPointerUp={stop}
style={{
width: '100%',
height: '100%',
pointerEvents: !isMoving && isEditing ? 'all' : 'none',
}}
/>
) : null}
</HTMLContainer>
)
})
ReactIndicator = observer(() => {
const {
props: {
size: [w, h],
isLocked,
},
} = this
return (
<rect
width={w}
height={h}
fill="transparent"
rx={8}
ry={8}
strokeDasharray={isLocked ? '8 2' : 'undefined'}
/>
)
})
}

View File

@@ -1,77 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getStroke } from 'perfect-freehand'
import { SvgPathUtils, TLDrawShape, TLDrawShapeProps, getComputedColor } from '@tldraw/core'
import { SVGContainer, TLComponentProps } from '@tldraw/react'
import { observer } from 'mobx-react-lite'
import { computed, makeObservable } from 'mobx'
import { CustomStyleProps, withClampedStyles } from './style-props'
export interface PenShapeProps extends TLDrawShapeProps, CustomStyleProps {
type: 'pen'
}
export class PenShape extends TLDrawShape<PenShapeProps> {
constructor(props = {} as Partial<PenShapeProps>) {
super(props)
makeObservable(this)
}
static id = 'pen'
static defaultProps: PenShapeProps = {
id: 'pen',
parentId: 'page',
type: 'pen',
point: [0, 0],
points: [],
isComplete: false,
stroke: '',
fill: '',
noFill: false,
strokeType: 'line',
strokeWidth: 2,
opacity: 1,
}
@computed get pointsPath() {
const {
props: { points, isComplete, strokeWidth },
} = this
if (points.length < 2) {
return `M -4, 0
a 4,4 0 1,0 8,0
a 4,4 0 1,0 -8,0`
}
const stroke = getStroke(points, { size: 4 + strokeWidth * 2, last: isComplete })
return SvgPathUtils.getCurvedPathForPolygon(stroke)
}
ReactComponent = observer(({ events, isErasing }: TLComponentProps) => {
const {
pointsPath,
props: { stroke, strokeWidth, opacity },
} = this
return (
<SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
<path
d={pointsPath}
strokeWidth={strokeWidth}
stroke={getComputedColor(stroke, 'stroke')}
fill={getComputedColor(stroke, 'stroke')}
pointerEvents="all"
/>
</SVGContainer>
)
})
ReactIndicator = observer(() => {
const { pointsPath } = this
return <path d={pointsPath} strokeDasharray={this.props.isLocked ? '8 2' : 'undefined'} />
})
validateProps = (props: Partial<PenShapeProps>) => {
props = withClampedStyles(this, props)
if (props.strokeWidth !== undefined) props.strokeWidth = Math.max(props.strokeWidth, 1)
return props
}
}

Some files were not shown because too many files have changed in this diff Show More