penpot/frontend/src/app/main/ui/comments.cljs
luisδμ 45059d73da
🐛 Fix several bugs in comments (#5716)
* ♻️ Remove obsolete CSS code

* 🐛 Fix post a comment with keyboard

* 🐛 Fix show comments cursor on view mode

* 🐛 Fix avoid comment bubbles to appear on top of sidebars

* 🐛 Fix cancel native browser zoom when comment threads are visible
2025-01-30 10:35:47 +01:00

1227 lines
43 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.comments
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.geom.point :as gpt]
[app.common.math :as mth]
[app.common.uuid :as uuid]
[app.config :as cfg]
[app.main.data.comments :as dcm]
[app.main.data.modal :as modal]
[app.main.data.workspace.comments :as dwcm]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.components.dropdown :refer [dropdown]]
[app.main.ui.ds.buttons.button :refer [button*]]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*]]
[app.main.ui.hooks :as h]
[app.main.ui.icons :as i]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.object :as obj]
[app.util.time :as dt]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[clojure.math :refer [floor]]
[cuerdas.core :as str]
[okulary.core :as l]
[rumext.v2 :as mf]))
(def ^:private ref:comments-local-options
(l/derived :options refs/comments-local))
(def mentions-context (mf/create-context nil))
(def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)")
(def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)")
(defn- parse-comment
"Parse a comment into its elements (texts and mentions)"
[comment]
(d/interleave-all
(->> (str/split comment r-mentions-split)
(map #(hash-map :type :text :content %)))
(->> (re-seq r-mentions comment)
(map (fn [[_ user id]]
{:type :mention
:content user
:data {:id id}})))))
(defn- parse-nodes
"Parse the nodes to format a comment"
[node]
(->> (dom/get-children node)
(map
(fn [node]
(cond
(and (instance? js/HTMLElement node) (dom/get-data node "user-id"))
(str/ffmt "@[%](%)" (.-textContent node) (dom/get-data node "user-id"))
:else
(.-textContent node))))
(str/join "")))
(defn- create-text-node
"Creates a text-only node"
([]
(create-text-node ""))
([text]
(-> (dom/create-element "span")
(dom/set-data! "type" "text")
(dom/set-html! (if (empty? text) "​" text)))))
(defn- create-mention-node
"Creates a mention node"
[id fullname]
(-> (dom/create-element "span")
(dom/set-data! "type" "mention")
(dom/set-data! "user-id" (dm/str id))
(dom/set-data! "fullname" fullname)
(obj/set! "textContent" fullname)))
(defn- current-text-node
"Retrieves the text node and the offset that the cursor is positioned on"
[node]
(let [selection (wapi/get-selection)
range (wapi/get-range selection 0)
anchor-node (wapi/range-start-container range)
anchor-offset (wapi/range-start-offset range)]
(when (and node (.contains node anchor-node))
(let [span-node
(if (instance? js/Text anchor-node)
(dom/get-parent anchor-node)
anchor-node)
container (dom/get-parent span-node)]
(when (= node container)
[span-node anchor-offset])))))
(defn- absolute-offset
[node child offset]
(loop [nodes (seq (dom/get-children node))
acc 0]
(if-let [head (first nodes)]
(if (= head child)
(+ acc offset)
(recur (rest nodes) (+ acc (.-length (.-textContent head)))))
nil)))
(defn- get-prev-node
[parent node]
(->> (d/with-prev (dom/get-children parent))
(d/seek (fn [[it _]] (= node it)))
(second)))
(defn- blank-content?
[content]
(or (str/blank? content)
(str/empty? content)
;; If only one char and it's the zero-width whitespace
(and (= 1 (count content)) (= (first content) \u200B))))
;; Component that renders the component content
(mf/defc comment-content*
{::mf/private true}
[{:keys [content]}]
(let [comment-elements (mf/use-memo (mf/deps content) #(parse-comment content))]
(for [[idx {:keys [type content]}] (d/enumerate comment-elements)]
(case type
[:span
{:key idx
:class (stl/css-case
:comment-text (= type :text)
:comment-mention (= type :mention))}
content]))))
;; Input text for comments with mentions
(mf/defc comment-input*
{::mf/private true}
[{:keys [value placeholder max-length autofocus on-focus on-blur on-change on-esc on-ctrl-enter]}]
(let [value (d/nilv value "")
prev-value (h/use-previous value)
local-ref (mf/use-ref nil)
mentions-s (mf/use-ctx mentions-context)
cur-mention (mf/use-var nil)
prev-selection (mf/use-var nil)
init-input
(mf/use-fn
(fn [node]
(mf/set-ref-val! local-ref node)
(when node
(doseq [{:keys [type content data]} (parse-comment value)]
(case type
:text (dom/append-child! node (create-text-node content))
:mention (dom/append-child! node (create-mention-node (:id data) content))
nil)))))
handle-input
(mf/use-fn
(mf/deps on-change)
(fn []
(let [node (mf/ref-val local-ref)
children (dom/get-children node)]
(doseq [^js child-node children]
;; Remove nodes that are not span. This can happen if the user copy/pastes
(when (not= (.-tagName child-node) "SPAN")
(.remove child-node))
;; If a node is empty we set the content to "empty"
(when (and (= (dom/get-data child-node "type") "text")
(empty? (dom/get-text child-node)))
(dom/set-html! child-node "​"))
;; Remove mentions that have been modified
(when (and (= (dom/get-data child-node "type") "mention")
(not= (dom/get-data child-node "fullname")
(dom/get-text child-node)))
(.remove child-node)))
;; If there are no nodes we need to create an empty node
(when (= 0 (.-length children))
(dom/append-child! node (create-text-node)))
(let [new-input (parse-nodes node)]
(when (and on-change (<= (count new-input) max-length))
(on-change new-input))))))
handle-select
(mf/use-fn
(fn []
(let [node (mf/ref-val local-ref)
selection (wapi/get-selection)
range (wapi/get-range selection 0)
anchor-node (wapi/range-start-container range)]
(when (and (= node anchor-node) (.-collapsed range))
(wapi/set-cursor-after! anchor-node)))
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)
[prev-span prev-offset] @prev-selection]
(reset! prev-selection #js [span-node offset])
(when (= (dom/get-data span-node "type") "mention")
(let [from-offset (absolute-offset node prev-span prev-offset)
to-offset (absolute-offset node span-node offset)
[_ prev next]
(->> node
(dom/seq-nodes)
(d/with-prev-next)
(filter (fn [[elem _ _]] (= elem span-node)))
(first))]
(if (> from-offset to-offset)
(wapi/set-cursor-after! prev)
(wapi/set-cursor-before! next))))
(when span-node
(let [node-text (subs (dom/get-text span-node) 0 offset)
current-at-symbol
(str/last-index-of (subs node-text 0 offset) "@")
mention-text
(subs node-text current-at-symbol)]
(if (re-matches #"@\w*" mention-text)
(do
(reset! cur-mention mention-text)
(rx/push! mentions-s {:type :display-mentions})
(let [mention (subs mention-text 1)]
(when (d/not-empty? mention)
(rx/push! mentions-s {:type :filter-mentions :data mention}))))
(do
(reset! cur-mention nil)
(rx/push! mentions-s {:type :hide-mentions}))))))))
handle-focus
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(dom/set-css-property! (mf/ref-val local-ref) "--placeholder" "")
(when on-focus
(on-focus event))))
handle-blur
(mf/use-fn
(mf/deps value)
(fn [event]
(when (empty? value)
(let [node (mf/ref-val local-ref)]
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))))
(when on-blur
(on-blur event))))
handle-insert-mention
(mf/use-fn
(mf/deps on-change)
(fn [data]
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)]
(when span-node
(let [node-text
(dom/get-text span-node)
current-at-symbol
(or (str/last-index-of (subs node-text 0 offset) "@")
(absolute-offset node span-node offset))
mention
(re-find #"@\w*" (subs node-text current-at-symbol))
prefix
(subs node-text 0 current-at-symbol)
suffix
(subs node-text (+ current-at-symbol (count mention)))
mention-span (create-mention-node (-> data :user :id) (-> data :user :fullname))
after-span (create-text-node (dm/str " " suffix))
sel (wapi/get-selection)]
(dom/set-html! span-node (if (empty? prefix) "&#8203;" prefix))
(dom/insert-after! node span-node mention-span)
(dom/insert-after! node mention-span after-span)
(wapi/set-cursor-after! after-span)
(wapi/collapse-end! sel)
(when (fn? on-change)
(on-change (parse-nodes node))))))))
handle-key-down
(mf/use-fn
(mf/deps on-esc on-ctrl-enter handle-select handle-input)
(fn [event]
(handle-select event)
(let [node (mf/ref-val local-ref)
[span-node offset] (current-text-node node)]
(cond
(and @cur-mention (kbd/enter? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-s {:type :insert-selected-mention}))
(and @cur-mention (kbd/down-arrow? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-s {:type :insert-next-mention}))
(and @cur-mention (kbd/up-arrow? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-s {:type :insert-prev-mention}))
(and @cur-mention (kbd/esc? event))
(do (dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-s {:type :hide-mentions}))
(and (kbd/esc? event) (fn? on-esc))
(on-esc event)
(and (kbd/mod? event) (kbd/enter? event) (fn? on-ctrl-enter))
(on-ctrl-enter event)
(kbd/enter? event)
(let [sel (wapi/get-selection)
range (.getRangeAt sel 0)]
(dom/prevent-default event)
(dom/stop-propagation event)
(let [[span-node offset] (current-text-node node)]
(.deleteContents range)
(handle-input)
(when span-node
(let [txt (.-textContent span-node)]
(dom/set-html! span-node (dm/str (subs txt 0 offset) "\n&#8203;" (subs txt offset)))
(wapi/set-cursor! span-node (inc offset))
(handle-input)))))
(kbd/backspace? event)
(let [prev-node (get-prev-node node span-node)]
(when (and (some? prev-node)
(= "mention" (dom/get-data prev-node "type"))
(= offset 1))
(dom/prevent-default event)
(dom/stop-propagation event)
(.remove prev-node)))))))]
(mf/use-layout-effect
(mf/deps autofocus)
(fn []
(when autofocus
(dom/focus! (mf/ref-val local-ref)))))
;; Creates the handlers for selection
(mf/with-effect [handle-select]
(let [handle-select* handle-select]
(js/document.addEventListener "selectionchange" handle-select*)
#(js/document.removeEventListener "selectionchange" handle-select*)))
;; Effect to communicate with the mentions panel
(mf/with-effect []
(when mentions-s
(->> mentions-s
(rx/subs!
(fn [{:keys [type data]}]
(case type
:insert-mention
(handle-insert-mention data)
nil))))))
;; Auto resize input to display the comment
(mf/with-layout-effect nil
(let [^js node (mf/ref-val local-ref)]
(set! (.-height (.-style node)) "0")
(set! (.-height (.-style node)) (str (+ 2 (.-scrollHeight node)) "px"))))
(mf/with-effect [value prev-value]
(let [node (mf/ref-val local-ref)]
(cond
(and (d/not-empty? prev-value) (empty? value))
(do (dom/set-html! node "")
(dom/append-child! node (create-text-node))
(dom/set-css-property! node "--placeholder" "")
(dom/focus! node))
(and (some? node) (empty? value) (not (dom/focus? node)))
(dom/set-css-property! node "--placeholder" (dm/str "\"" placeholder "\""))
(some? node)
(dom/set-css-property! node "--placeholder" ""))))
[:div
{:role "textbox"
:class (stl/css :comment-input)
:content-editable "true"
:suppress-content-editable-warning true
:on-input handle-input
:ref init-input
:on-key-down handle-key-down
:on-focus handle-focus
:on-blur handle-blur}]))
(mf/defc mentions-panel*
[]
(let [mentions-s (mf/use-ctx mentions-context)
profile (mf/deref refs/profile)
profiles (mf/deref refs/profiles)
state*
(mf/use-state
#(do {:display false
:mention-filter ""
:selected 0}))
{:keys [display mention-filter selected]}
(deref state*)
mentions-users
(mf/with-memo [mention-filter]
(->> (vals profiles)
(filter (fn [{:keys [id fullname email]}]
(and
(not= id (:id profile))
(or (not mention-filter)
(empty? mention-filter)
(str/includes? (str/lower fullname) (str/lower mention-filter))
(str/includes? (str/lower email) (str/lower mention-filter))))))
(take 4)
(into [])))
selected
(mth/clamp selected 0 (dec (count mentions-users)))
handle-click-mention
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(let [id (-> (dom/get-current-target event)
(dom/get-data "user-id")
(uuid/uuid))]
(rx/push! mentions-s {:type :insert-mention
:data {:user (get profiles id)}}))))]
(mf/with-effect [mentions-users selected]
(let [sub
(->> mentions-s
(rx/subs!
(fn [{:keys [type data]}]
(case type
;; Display the mentions dialog
:display-mentions
(swap! state* assoc :display true)
;; Hide mentions
:hide-mentions
(swap! state* assoc :display false :mention-filter "")
;; Filter the metions by some characters
:filter-mentions
(swap! state* assoc :mention-filter data)
:insert-selected-mention
(rx/push! mentions-s {:type :insert-mention
:data {:user (get mentions-users selected)}})
:insert-next-mention
(swap! state* update :selected #(mth/clamp (inc %) 0 (dec (count mentions-users))))
:insert-prev-mention
(swap! state* update :selected #(mth/clamp (dec %) 0 (dec (count mentions-users))))
;;
nil))))]
#(rx/dispose! sub)))
(when ^boolean display
[:div {:class (stl/css :comments-mentions-choice)}
(if (empty? mentions-users)
[:div {:class (stl/css :comments-mentions-empty)}
(tr "comments.mentions.not-found" mention-filter)]
(for [[idx {:keys [id fullname email] :as user}] (d/enumerate mentions-users)]
[:div {:key id
:on-pointer-down handle-click-mention
:data-user-id (dm/str id)
:class (stl/css-case :comments-mentions-entry true
:is-selected (= selected idx))}
[:img {:class (stl/css :comments-mentions-avatar)
:src (cfg/resolve-profile-photo-url user)}]
[:div {:class (stl/css :comments-mentions-name)} fullname]
[:div {:class (stl/css :comments-mentions-email)} email]]))])))
(mf/defc mentions-button*
{::mf/props :obj
::mf/private true}
[]
(let [mentions-s (mf/use-ctx mentions-context)
display-mentions* (mf/use-state false)
handle-mouse-down
(mf/use-fn
(fn [event]
(dom/prevent-default event)
(dom/stop-propagation event)
(rx/push! mentions-s {:type :display-mentions})))]
(mf/use-effect
(fn []
(let [sub
(rx/subs!
(fn [{:keys [type _]}]
(case type
:display-mentions (reset! display-mentions* true)
:hide-mentions (reset! display-mentions* false)
nil))
mentions-s)]
#(rx/dispose! sub))))
[:> icon-button*
{:variant "ghost"
:aria-label (tr "labels.options")
:on-pointer-down handle-mouse-down
:icon-class (stl/css-case :open-mentions-button true
:is-toggled @display-mentions*)
:icon "at"}]))
(def ^:private schema:comment-avatar
[:map
[:class {:optional true} :string]
[:image :string]
[:variant {:optional true}
[:maybe [:enum "read" "unread" "solved"]]]])
(mf/defc comment-avatar*
{::mf/schema schema:comment-avatar}
[{:keys [image variant class] :rest props}]
(let [variant (or variant "read")
class (dm/str class " " (stl/css-case :avatar true
:avatar-read (= variant "read")
:avatar-unread (= variant "unread")
:avatar-solved (= variant "solved")))
props (mf/spread-props props {:class class})]
[:> :div props
[:img {:src image
:class (stl/css :avatar-image)}]
[:div {:class (stl/css-case :avatar-mask true
:avatar-darken (= variant "solved"))}]]))
(mf/defc comment-info*
{::mf/private true}
[{:keys [item profile]}]
[:*
[:div {:class (stl/css :author)}
[:> comment-avatar* {:image (cfg/resolve-profile-photo-url profile)
:class (stl/css :avatar-lg)
:variant (cond (:is-resolved item) "solved"
(pos? (:count-unread-comments item)) "unread"
:else "read")}]
[:div {:class (stl/css :author-identity)}
[:div {:class (stl/css :author-fullname)} (:fullname profile)]
[:div {:class (stl/css :author-timeago)} (dt/timeago (:modified-at item))]]]
[:div {:class (stl/css :item)}
[:> comment-content* {:content (:content item)}]]
[:div {:class (stl/css :replies)}
(let [total-comments (:count-comments item 1)
total-replies (dec total-comments)
unread-replies (:count-unread-comments item 0)]
[:*
(when (> total-replies 0)
(if (= total-replies 1)
[:span {:class (stl/css :replies-total)} (str total-replies " " (tr "labels.reply"))]
[:span {:class (stl/css :replies-total)} (str total-replies " " (tr "labels.replies"))]))
(when (and (> total-replies 0) (> unread-replies 0))
(if (= unread-replies 1)
[:span {:class (stl/css :replies-unread)} (str unread-replies " " (tr "labels.reply.new"))]
[:span {:class (stl/css :replies-unread)} (str unread-replies " " (tr "labels.replies.new"))]))])]])
(mf/defc comment-form-buttons*
{::mf/props :obj
::mf/private true}
[{:keys [on-submit on-cancel is-disabled]}]
(let [handle-cancel
(mf/use-fn
(mf/deps on-cancel)
(fn [event]
(when (kbd/enter? event)
(on-cancel))))
handle-submit
(mf/use-fn
(mf/deps on-submit)
(fn [event]
(when (kbd/enter? event)
(on-submit))))]
[:div {:class (stl/css :form-buttons-wrapper)}
[:> mentions-button*]
[:> button* {:variant "ghost"
:type "button"
:on-key-down handle-cancel
:on-click on-cancel}
(tr "ds.confirm-cancel")]
[:> button* {:variant "primary"
:type "button"
:on-key-down handle-submit
:on-click on-submit
:disabled is-disabled}
(tr "labels.post")]]))
(mf/defc comment-reply-form*
{::mf/props :obj
::mf/private true}
[{:keys [on-submit]}]
(let [show-buttons? (mf/use-state false)
content (mf/use-state "")
disabled? (blank-content? @content)
on-focus
(mf/use-fn
#(reset! show-buttons? true))
on-blur
(mf/use-fn
#(reset! show-buttons? false))
on-change
(mf/use-fn
#(reset! content %))
on-cancel
(mf/use-fn
#(do (reset! content "")
(reset! show-buttons? false)))
on-submit*
(mf/use-fn
(mf/deps @content)
(fn []
(on-submit @content)
(on-cancel)))]
[:div {:class (stl/css :form)}
[:> comment-input*
{:value @content
:placeholder (tr "labels.reply.thread")
:autofocus true
:on-blur on-blur
:on-focus on-focus
:on-ctrl-enter on-submit*
:on-change on-change
:max-length 750}]
(when (or @show-buttons? (seq @content))
[:> comment-form-buttons* {:on-submit on-submit*
:on-cancel on-cancel
:is-disabled disabled?}])]))
(mf/defc comment-edit-form*
{::mf/private true}
[{:keys [content on-submit on-cancel]}]
(let [content (mf/use-state content)
disabled? (blank-content? @content)
on-change
(mf/use-fn
#(reset! content %))
on-submit*
(mf/use-fn
(mf/deps @content)
(fn [] (on-submit @content)))]
[:div {:class (stl/css :form)}
[:> comment-input*
{:value @content
:autofocus true
:on-ctrl-enter on-submit*
:on-change on-change
:max-length 750}]
[:> comment-form-buttons* {:on-submit on-submit*
:on-cancel on-cancel
:is-disabled disabled?}]]))
(mf/defc comment-floating-thread-draft*
[{:keys [draft zoom on-cancel on-submit position-modifier]}]
(let [profile (mf/deref refs/profile)
mentions-s (mf/use-memo #(rx/subject))
position (cond-> (:position draft)
(some? position-modifier)
(gpt/transform position-modifier))
content (:content draft)
pos-x (* (:x position) zoom)
pos-y (* (:y position) zoom)
disabled? (blank-content? content)
on-esc
(mf/use-fn
(mf/deps draft)
(fn [event]
(dom/stop-propagation event)
(if (fn? on-cancel)
(on-cancel)
(st/emit! :interrupt))))
on-change
(mf/use-fn
(mf/deps draft)
(fn [content]
(st/emit! (dcm/update-draft-thread {:content content}))))
on-submit*
(mf/use-fn
(mf/deps draft)
(fn []
(on-submit draft)))]
[:> (mf/provider mentions-context) {:value mentions-s}
[:div
{:class (stl/css :floating-preview-wrapper)
:data-testid "floating-thread-bubble"
:style {:top (str pos-y "px")
:left (str pos-x "px")}
:on-click dom/stop-propagation}
[:> comment-avatar* {:class (stl/css :avatar-lg)
:image (cfg/resolve-profile-photo-url profile)}]]
[:div {:class (stl/css :floating-thread-wrapper :cursor-auto)
:style {:top (str (- pos-y 24) "px")
:left (str (+ pos-x 28) "px")}
:on-click dom/stop-propagation}
[:div {:class (stl/css :form)}
[:> comment-input*
{:placeholder (tr "labels.write-new-comment")
:value (or content "")
:autofocus true
:on-esc on-esc
:on-change on-change
:on-ctrl-enter on-submit*
:max-length 750}]
[:> comment-form-buttons* {:on-submit on-submit*
:on-cancel on-esc
:is-disabled disabled?}]]
[:> mentions-panel*]]]))
(mf/defc comment-floating-thread-header*
{::mf/private true}
[{:keys [thread origin]}]
(let [owner (dcm/get-owner thread)
profile (mf/deref refs/profile)
options (mf/deref ref:comments-local-options)
toggle-resolved
(mf/use-fn
(mf/deps thread)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/update-comment-thread (update thread :is-resolved not)))))
on-toggle-options
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/toggle-comment-options uuid/zero))))
delete-thread
(mf/use-fn
(fn []
(st/emit! (dcm/close-thread)
(if (= origin :viewer)
(dcm/delete-comment-thread-on-viewer thread)
(dcm/delete-comment-thread-on-workspace thread)))))
on-delete-thread
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/hide-comment-options))
(st/emit! (modal/show
{:type :confirm
:title (tr "modals.delete-comment-thread.title")
:message (tr "modals.delete-comment-thread.message")
:accept-label (tr "modals.delete-comment-thread.accept")
:on-accept delete-thread}))))
on-hide-options
(mf/use-fn
(mf/deps options)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/hide-comment-options))))]
[:*
[:div {:class (stl/css :floating-thread-header-left)}
(tr "labels.comment") " " [:span {:class (stl/css :grayed-text)} "#" (:seqn thread)]]
[:div {:class (stl/css :floating-thread-header-right)}
(when (some? thread)
[:div {:class (stl/css :checkbox-wrapper)
:title (tr "labels.comment.mark-as-solved")
:on-click toggle-resolved}
[:span {:class (stl/css-case :checkbox true
:global/checked (:is-resolved thread))} i/tick]])
(when (= (:id profile) (:id owner))
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.options")
:on-click on-toggle-options
:icon "menu"}])]
[:& dropdown {:show (= options uuid/zero)
:on-close on-hide-options}
[:ul {:class (stl/css :dropdown-menu)}
[:li {:class (stl/css :dropdown-menu-option)
:on-click on-delete-thread}
(tr "labels.delete-comment-thread")]]]]))
(mf/defc comment-floating-thread-item*
{::mf/private true}
[{:keys [comment thread]}]
(let [owner (dcm/get-owner comment)
profile (mf/deref refs/profile)
options (mf/deref ref:comments-local-options)
edition? (mf/use-state false)
on-toggle-options
(mf/use-fn
(mf/deps options)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/toggle-comment-options (:id comment)))))
on-hide-options
(mf/use-fn
(mf/deps options)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dcm/hide-comment-options))))
on-edit-clicked
(mf/use-fn
(mf/deps options)
(fn []
(st/emit! (dcm/hide-comment-options))
(reset! edition? true)))
on-delete-comment
(mf/use-fn
(mf/deps comment)
#(st/emit! (dcm/delete-comment comment)))
on-submit
(mf/use-fn
(mf/deps comment thread)
(fn [content]
(reset! edition? false)
(st/emit! (dcm/update-comment (assoc comment :content content)))))
on-cancel
(mf/use-fn #(reset! edition? false))]
[:div {:class (stl/css :floating-thread-item-wrapper)}
[:div {:class (stl/css :floating-thread-item)}
[:div {:class (stl/css :author)}
[:> comment-avatar* {:image (cfg/resolve-profile-photo-url owner)}]
[:div {:class (stl/css :author-identity)}
[:div {:class (stl/css :author-fullname)} (:fullname owner)]
[:div {:class (stl/css :author-timeago)} (dt/timeago (:modified-at comment))]]
(when (= (:id profile) (:id owner))
[:> icon-button* {:variant "ghost"
:aria-label (tr "labels.options")
:on-click on-toggle-options
:icon "menu"}])]
[:div {:class (stl/css :item)}
(if @edition?
[:> comment-edit-form* {:content (:content comment)
:on-submit on-submit
:on-cancel on-cancel}]
[:span {:class (stl/css :text)}
[:> comment-content* {:content (:content comment)}]])]]
[:& dropdown {:show (= options (:id comment))
:on-close on-hide-options}
[:ul {:class (stl/css :dropdown-menu)}
[:li {:class (stl/css :dropdown-menu-option)
:on-click on-edit-clicked}
(tr "labels.edit")]
(when-not thread
[:li {:class (stl/css :dropdown-menu-option)
:on-click on-delete-comment}
(tr "labels.delete-comment")])]]]))
(defn make-comments-ref
[thread-id]
(l/derived (l/in [:comments thread-id]) st/state))
(defn- offset-position [position viewport zoom bubble-margin]
(let [viewport (or viewport {:offset-x 0 :offset-y 0 :width 0 :height 0})
base-x (+ (* (:x position) zoom) (:offset-x viewport))
base-y (+ (* (:y position) zoom) (:offset-y viewport))
x (:x position)
y (:y position)
w (:width viewport)
h (:height viewport)
comment-width 284 ;; TODO: this is the width set via CSS in an outer container…
;; We should probably do this in a different way.
orientation-left? (>= (+ base-x comment-width (:x bubble-margin)) w)
orientation-top? (>= base-y (/ h 2))
h-dir (if orientation-left? :left :right)
v-dir (if orientation-top? :top :bottom)]
{:x x :y y :h-dir h-dir :v-dir v-dir}))
(mf/defc comment-floating-thread*
{::mf/wrap [mf/memo]}
[{:keys [thread zoom origin position-modifier viewport]}]
(let [ref (mf/use-ref)
mentions-s (mf/use-memo #(rx/subject))
thread-id (:id thread)
thread-pos (:position thread)
base-pos (cond-> thread-pos
(some? position-modifier)
(gpt/transform position-modifier))
max-height (when (some? viewport) (int (* (:height viewport) 0.5)))
;; We should probably look for a better way of doing this.
bubble-margin (gpt/point 24 24)
pos (offset-position base-pos viewport zoom bubble-margin)
margin-x (* (:x bubble-margin) (if (= (:h-dir pos) :left) -1 1))
margin-y (* (:y bubble-margin) (if (= (:v-dir pos) :top) -1 1))
pos-x (+ (* (:x pos) zoom) margin-x)
pos-y (- (* (:y pos) zoom) margin-y)
comments-ref (mf/with-memo [thread-id]
(make-comments-ref thread-id))
comments-map (mf/deref comments-ref)
comments (mf/with-memo [comments-map]
(->> (vals comments-map)
(sort-by :created-at)))
first-comment (first comments)
on-submit
(mf/use-fn
(mf/deps thread)
(fn [content]
(st/emit! (dcm/add-comment thread content))))]
(mf/with-effect [thread-id]
(st/emit! (dcm/retrieve-comments thread-id)))
(mf/with-effect [thread-id]
(st/emit! (dcm/update-comment-thread-status thread-id)))
(mf/with-layout-effect [thread-pos comments-map]
(when-let [node (mf/ref-val ref)]
(dom/scroll-into-view-if-needed! node)))
[:> (mf/provider mentions-context) {:value mentions-s}
(when (some? first-comment)
[:div {:class (stl/css-case :floating-thread-wrapper true
:cursor-auto true
:left (= (:h-dir pos) :left)
:top (= (:v-dir pos) :top))
:id (str "thread-" thread-id)
:style {:left (str pos-x "px")
:top (str pos-y "px")
"--comment-height" (str max-height "px")}
:on-click dom/stop-propagation}
[:div {:class (stl/css :floating-thread-header)}
[:> comment-floating-thread-header* {:thread thread
:origin origin}]]
[:div {:class (stl/css :floating-thread-main)}
[:> comment-floating-thread-item* {:comment first-comment
:thread thread}]
(for [item (rest comments)]
[:* {:key (dm/str (:id item))}
[:> comment-floating-thread-item* {:comment item}]])]
[:> comment-reply-form* {:on-submit on-submit}]
[:> mentions-panel*]])]))
(mf/defc comment-floating-bubble*
{::mf/wrap [mf/memo]}
[{:keys [thread zoom is-open on-click origin position-modifier]}]
(let [owner (mf/with-memo [thread]
(dcm/get-owner thread))
base-pos (cond-> (:position thread)
(some? position-modifier)
(gpt/transform position-modifier))
drag? (mf/use-ref nil)
was-open? (mf/use-ref nil)
dragging-ref (mf/use-ref false)
start-ref (mf/use-ref nil)
position (:position thread)
frame-id (:frame-id thread)
state (mf/use-state
#(do {:is-hover false
:is-grabbing false
:new-position-x nil
:new-position-y nil
:new-frame-id frame-id}))
pos-x (floor (* (or (:new-position-x @state) (:x base-pos)) zoom))
pos-y (floor (* (or (:new-position-y @state) (:y base-pos)) zoom))
on-pointer-down
(mf/use-fn
(mf/deps origin was-open? is-open drag?)
(fn [event]
(when (not= origin :viewer)
(swap! state assoc :is-grabbing true)
(mf/set-ref-val! was-open? is-open)
(when is-open (st/emit! (dcm/close-thread)))
(mf/set-ref-val! drag? false)
(dom/stop-propagation event)
(dom/capture-pointer event)
(mf/set-ref-val! dragging-ref true)
(mf/set-ref-val! start-ref (dom/get-client-position event)))))
on-pointer-up
(mf/use-fn
(mf/deps origin thread (select-keys @state [:new-position-x :new-position-y :new-frame-id]))
(fn [event]
(when (not= origin :viewer)
(swap! state assoc :is-grabbing false)
(dom/stop-propagation event)
(dom/release-pointer event)
(mf/set-ref-val! dragging-ref false)
(mf/set-ref-val! start-ref nil)
(when (and
(some? (:new-position-x @state))
(some? (:new-position-y @state)))
(st/emit! (dwcm/update-comment-thread-position thread [(:new-position-x @state)
(:new-position-y @state)]))
(swap! state assoc
:new-position-x nil
:new-position-y nil)))))
on-pointer-move
(mf/use-fn
(mf/deps origin drag? position zoom)
(fn [event]
(when (not= origin :viewer)
(mf/set-ref-val! drag? true)
(dom/stop-propagation event)
(when-let [_ (mf/ref-val dragging-ref)]
(let [start-pt (mf/ref-val start-ref)
current-pt (dom/get-client-position event)
delta-x (/ (- (:x current-pt) (:x start-pt)) zoom)
delta-y (/ (- (:y current-pt) (:y start-pt)) zoom)]
(swap! state assoc
:new-position-x (+ (:x position) delta-x)
:new-position-y (+ (:y position) delta-y)))))))
on-pointer-enter
(mf/use-fn
(mf/deps is-open)
(fn [event]
(dom/stop-propagation event)
(when (false? is-open)
(swap! state assoc :is-hover true))))
on-pointer-leave
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(swap! state assoc :is-hover false)))
on-click*
(mf/use-fn
(mf/deps origin thread on-click was-open? drag? (select-keys @state [:is-hover]))
(fn [event]
(dom/stop-propagation event)
(when (or (and (mf/ref-val was-open?) (mf/ref-val drag?))
(and (not (mf/ref-val was-open?)) (not (mf/ref-val drag?))))
(swap! state assoc :is-hover false)
(st/emit! (dcm/open-thread thread)))
(when (= origin :viewer)
(on-click thread))))]
[:div {:style {:top (dm/str pos-y "px")
:left (dm/str pos-x "px")}
:on-pointer-down on-pointer-down
:on-pointer-up on-pointer-up
:on-pointer-move on-pointer-move
:on-pointer-enter on-pointer-enter
:on-pointer-leave on-pointer-leave
:on-click on-click*
:class (stl/css-case :floating-preview-wrapper true
:floating-preview-bubble (false? (:is-hover @state)))}
(if (:is-hover @state)
[:div {:class (stl/css-case :floating-thread-wrapper true
:floating-preview-displacement true
:cursor-pointer (false? (:is-grabbing @state))
:cursor-grabbing (true? (:is-grabbing @state)))}
[:div {:class (stl/css :floating-thread-item-wrapper)}
[:div {:class (stl/css :floating-thread-item)}
[:> comment-info* {:item thread
:profile owner}]]]]
[:> comment-avatar*
{:image (cfg/resolve-profile-photo-url owner)
:class (stl/css :avatar-lg)
:data-testid (dm/str "floating-thread-bubble-" (:seqn thread))
:variant (cond
(:is-resolved thread) "solved"
(pos? (:count-unread-comments thread)) "unread"
:else "read")}])]))
(mf/defc comment-sidebar-thread-item*
{::mf/private true}
[{:keys [item on-click]}]
(let [owner (dcm/get-owner item)
;; FIXME
frame (mf/deref (refs/workspace-page-object-by-id (:page-id item) (:frame-id item)))
on-click*
(mf/use-fn
(mf/deps item)
(fn [event]
(dom/stop-propagation event)
(dom/prevent-default event)
(when (fn? on-click)
(on-click item))))]
[:div {:class (stl/css :cover)
:on-click on-click*}
[:div {:class (stl/css :location)}
[:div {:class (stl/css :location-text)}
(str "#" (:seqn item))
(str " - " (:page-name item))
(when (and (some? frame) (not (cfh/root? frame)))
(str " - " (:name frame)))]]
[:> comment-info* {:item item
:profile owner}]]))
(mf/defc comment-sidebar-thread-group*
[{:keys [group on-thread-click]}]
[:div
(for [item (:items group)]
[:> comment-sidebar-thread-item*
{:item item
:on-click on-thread-click
:key (:id item)}])])
(mf/defc comment-dashboard-thread-item*
{::mf/private true}
[{:keys [item on-click]}]
(let [owner (dcm/get-owner item)
on-click*
(mf/use-fn
(mf/deps item)
(fn [event]
(dom/stop-propagation event)
(dom/prevent-default event)
(when (fn? on-click)
(on-click item))))]
[:div {:class (stl/css :cover)
:on-click on-click*}
[:div {:class (stl/css :location)}
[:> icon* {:icon-id "comments"
:class (stl/css :location-icon)}]
[:div {:class (stl/css :location-text)}
(str "#" (:seqn item))
(str " " (:file-name item))
(str ", " (:page-name item))]]
[:> comment-info* {:item item
:profile owner}]]))
(mf/defc comment-dashboard-thread-group*
[{:keys [group on-thread-click]}]
[:div
(for [item (:items group)]
[:> comment-dashboard-thread-item*
{:item item
:on-click on-thread-click
:key (:id item)}])])