mirror of
https://github.com/logseq/logseq.git
synced 2026-02-01 22:47:36 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
7
deps/cli/src/logseq/cli/common/graph.cljs
vendored
7
deps/cli/src/logseq/cli/common/graph.cljs
vendored
@@ -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)))))))
|
||||
|
||||
18
deps/common/resources/templates/config.edn
vendored
18
deps/common/resources/templates/config.edn
vendored
@@ -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
|
||||
}
|
||||
|
||||
2
deps/common/src/logseq/common/config.cljs
vendored
2
deps/common/src/logseq/common/config.cljs
vendored
@@ -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
|
||||
[]
|
||||
|
||||
2
deps/common/src/logseq/common/graph.cljs
vendored
2
deps/common/src/logseq/common/graph.cljs
vendored
@@ -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]
|
||||
|
||||
20
deps/db/src/logseq/db/common/view.cljs
vendored
20
deps/db/src/logseq/db/common/view.cljs
vendored
@@ -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)
|
||||
|
||||
166
deps/outliner/src/logseq/outliner/core.cljs
vendored
166
deps/outliner/src/logseq/outliner/core.cljs
vendored
@@ -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))
|
||||
|
||||
25
deps/outliner/src/logseq/outliner/op.cljs
vendored
25
deps/outliner/src/logseq/outliner/op.cljs
vendored
@@ -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))
|
||||
|
||||
6
deps/outliner/src/logseq/outliner/page.cljs
vendored
6
deps/outliner/src/logseq/outliner/page.cljs
vendored
@@ -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
|
||||
|
||||
2
deps/outliner/src/logseq/outliner/tree.cljs
vendored
2
deps/outliner/src/logseq/outliner/tree.cljs
vendored
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
**/node_modules/*
|
||||
**/out/*
|
||||
**/.next/*
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
2
packages/tldraw/.gitattributes
vendored
2
packages/tldraw/.gitattributes
vendored
@@ -1,2 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
17
packages/tldraw/.gitignore
vendored
17
packages/tldraw/.gitignore
vendored
@@ -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
|
||||
@@ -1,17 +0,0 @@
|
||||
/.github/
|
||||
/.vscode/
|
||||
/node_modules/
|
||||
/build/
|
||||
/tmp/
|
||||
.idea/*
|
||||
/docs/
|
||||
|
||||
coverage
|
||||
*.log
|
||||
.gitlab-ci.yml
|
||||
|
||||
package-lock.json
|
||||
/*.tgz
|
||||
/tmp*
|
||||
/mnt/
|
||||
/package/
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"jsxSingleQuote": false,
|
||||
"jsxBracketSameLine": false,
|
||||
"arrowParens": "avoid"
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
# @tldraw/core Simple Example
|
||||
|
||||
A (relatively) simple example project for `@tldraw/core`.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = ctx => ({
|
||||
plugins: [require('autoprefixer')()],
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ActionBar'
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './BlockLink'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Button'
|
||||
export * from './CircleButton'
|
||||
@@ -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)
|
||||
@@ -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)!)
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ContextBar'
|
||||
export * from './contextBarActionFactory'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ContextMenu'
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Devtools'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './GeometryTools'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './KeyboardShortcut'
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Minimap'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './PopoverButton'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './PrimaryTools'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './QuickLinks'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export * from './QuickSearch'
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export * from './StatusBar'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ToolButton'
|
||||
@@ -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}</>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './Tooltip'
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
export * from './ZoomMenu'
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './TablerIcon'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { useApp } from '@tldraw/react'
|
||||
|
||||
export function useCameraMovingRef() {
|
||||
const app = useApp()
|
||||
return app.inputs.state === 'panning' || app.inputs.state === 'pinching'
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
[]
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './shapes'
|
||||
export * from './tools'
|
||||
export * from './preview-manager'
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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({})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user