Heading selections support keyboards

Resolves #43
pull/645/head
Tienson Qin 2020-05-08 10:19:09 +08:00
parent 3ce794eaf8
commit 9325a93e06
9 changed files with 231 additions and 107 deletions

View File

@ -9,3 +9,5 @@ dummy.fullpath = function() {};
dummy.getRangeAt = function() {};
dummy.getElementsByClassName = function() {};
dummy.containsNode = function() {};
dummy.select = function() {};
dummy.setAttribute = function() {};

View File

@ -54,34 +54,28 @@
(mixins/listen state js/window "click"
(fn [e]
;; hide context menu
(let [context-menu (d/by-id "custom-context-menu")]
(when-not (d/has-class? context-menu "hidden")
(d/add-class! context-menu "hidden"))
;; enable scroll
(let [main (d/by-id "main")]
(d/remove-class! main "overflow-hidden")
(d/add-class! main "overflow-y-scroll")))
(state/hide-custom-context-menu!)
(when (state/in-selection-mode?)
(doseq [heading (state/get-selection-headings)]
(d/remove-class! heading "selected")
(d/remove-class! heading "noselect"))
(state/clear-selection!))))
;; enable scroll
(let [main (d/by-id "main")]
(d/remove-class! main "overflow-hidden")
(d/add-class! main "overflow-y-scroll"))
(handler/clear-selection!)))
(mixins/listen state js/window "contextmenu"
(fn [e]
(when (state/in-selection-mode?)
(util/stop e)
(let [client-x (gobj/get e "clientX")
client-y (gobj/get e "clientY")
context-menu (d/by-id "custom-context-menu")]
(when context-menu
(let [main (d/by-id "main")]
;; disable scroll
(d/remove-class! main "overflow-y-scroll")
(d/add-class! main "overflow-hidden"))
(d/remove-class! context-menu
"hidden")
client-y (gobj/get e "clientY")]
(let [main (d/by-id "main")]
;; disable scroll
(d/remove-class! main "overflow-y-scroll")
(d/add-class! main "overflow-hidden"))
(state/show-custom-context-menu!)
(when-let [context-menu (d/by-id "custom-context-menu")]
(d/set-style! context-menu
:left (str client-x "px")
:top (str client-y "px")))))))))

View File

@ -12,7 +12,8 @@
[frontend.util :as util]
[frontend.state :as state]
[frontend.handler :as handler]
[frontend.config :as config]))
[frontend.config :as config]
[daiquiri.core]))
(def active-button :a.mt-1.group.flex.items-center.px-2.py-2.text-base.leading-6.font-medium.rounded-md.text-white.bg-gray-900.focus:outline-none.focus:bg-gray-700.transition.ease-in-out.duration-150)
(def inactive-button :a.mt-1.group.flex.items-center.px-2.py-2.text-base.leading-6.font-medium.rounded-md.text-gray-300.hover:text-white.hover:bg-gray-700.focus:outline-none.focus:text-white.focus:bg-gray-700.transition.ease-in-out.duration-150)
@ -145,27 +146,39 @@
(rum/defc custom-context-menu-content
[]
[:div#custom-context-menu.w-48.rounded-md.shadow-lg.transition.ease-out.duration-100.transform.opacity-100.scale-100.enter-done.absolute.hidden
[:div#custom-context-menu.w-48.rounded-md.shadow-lg.transition.ease-out.duration-100.transform.opacity-100.scale-100.enter-done.absolute
[:div.py-1.rounded-md.bg-white.shadow-xs
(ui/menu-link
{:key "cut"
:on-click (fn []
(prn "cut"))}
:on-click handler/cut-selection-headings}
"Cut")
(ui/menu-link
{:key "copy"
:on-click (fn []
(prn "copy"))}
:on-click handler/copy-selection-headings}
"Copy")]])
;; TODO: content could be changed
(rum/defc custom-context-menu
;; Also, keyboard bindings should only be activated after
;; headings were already selected.
(defn cut-headings-and-clear-selections!
[]
(ui/css-transition
{:class-names "fade"
:timeout {:enter 500
:exit 300}}
(custom-context-menu-content)))
(handler/cut-selection-headings)
(handler/clear-selection!))
(rum/defc custom-context-menu < rum/reactive
(mixins/keyboard-mixin "ctrl+c"
(fn []
(handler/copy-selection-headings)
(handler/clear-selection!)))
(mixins/keyboard-mixin "ctrl+x" cut-headings-and-clear-selections!)
(mixins/keyboard-mixin "backspace" cut-headings-and-clear-selections!)
(mixins/keyboard-mixin "delete" handler/cut-selection-headings)
[]
(when (state/sub :custom-context-menu/show?)
(ui/css-transition
{:class-names "fade"
:timeout {:enter 500
:exit 300}}
(custom-context-menu-content))))
(rum/defcs sidebar < (mixins/modal)
rum/reactive

View File

@ -343,20 +343,20 @@
react
sort-by-pos)))
;; (defn get-file-by-concat-headings-debug-version
;; ([file]
;; (get-file-by-concat-headings-debug-version
;; (state/get-current-repo)
;; file))
;; ([repo-url file]
;; (->> (d/q '[:find (pull ?heading [*])
;; :in $ ?file
;; :where
;; [?p :file/path ?file]
;; [?heading :heading/file ?p]]
;; (get-conn) file)
;; seq-flatten
;; sort-by-pos)))
(defn get-file-by-concat-headings-debug-version
([file]
(get-file-by-concat-headings-debug-version
(state/get-current-repo)
file))
([repo-url file]
(->> (d/q '[:find (pull ?heading [*])
:in $ ?file
:where
[?p :file/path ?file]
[?heading :heading/file ?p]]
(get-conn) file)
seq-flatten
sort-by-pos)))
(defn get-page-headings
([page]
@ -702,6 +702,14 @@
(get-conn))
seq-flatten))
;; TODO: Does the result preserves the order of the arguments?
(defn get-headings-contents
[heading-uuids]
(let [conn (get-conn (state/get-current-repo) false)]
;; (prn {:db db})
(d/pull-many (d/db conn) '[:heading/content]
(mapv (fn [id] [:heading/uuid id]) heading-uuids))))
(defn reset-config!
[repo-url content]
(let [config (some->> content

View File

@ -672,32 +672,36 @@
[(str prefix value postfix)
value]))
(defn rebuild-after-headings
[repo file before-end-pos new-end-pos]
(let [file-id (:db/id file)
after-headings (db/get-file-after-headings repo file-id before-end-pos)
last-start-pos (atom new-end-pos)]
(mapv
(fn [{:heading/keys [uuid meta] :as heading}]
(let [old-start-pos (:pos meta)
old-end-pos (:end-pos meta)
new-end-pos (if old-end-pos
(+ @last-start-pos (- old-end-pos old-start-pos)))
new-meta {:pos @last-start-pos
:end-pos new-end-pos}]
(reset! last-start-pos new-end-pos)
{:heading/uuid uuid
:heading/meta new-meta}))
after-headings)))
(defn save-heading-if-changed!
[{:heading/keys [uuid content meta file dummy?] :as heading} value]
(let [repo (state/get-current-repo)
value (string/trim value)]
(when (not= (string/trim content) value) ; heading content changed
(let [file-id (:db/id file)
file (db/entity file-id)
(let [file (db/entity (:db/id file))
file-content (:file/content file)
file-path (:file/path file)
format (format/get-format file-path)
[new-content value] (new-file-content heading file-content value)
after-headings (db/get-file-after-headings repo file-id (get meta :end-pos))
{:keys [headings pages start-pos end-pos]} (block/parse-heading (assoc heading :heading/content value) format)
last-start-pos (atom end-pos)
after-headings (mapv
(fn [{:heading/keys [uuid meta] :as heading}]
(let [old-start-pos (:pos meta)
old-end-pos (:end-pos meta)
new-end-pos (if old-end-pos
(+ @last-start-pos (- old-end-pos old-start-pos)))
new-meta {:pos @last-start-pos
:end-pos new-end-pos}]
(reset! last-start-pos new-end-pos)
{:heading/uuid uuid
:heading/meta new-meta}))
after-headings)]
after-headings (rebuild-after-headings repo file (:end-pos meta) end-pos)]
(db/transact!
(concat
pages
@ -705,10 +709,7 @@
after-headings
[{:file/path file-path
:file/content new-content}]))
(alter-file repo
file-path
new-content
{:reset? false})))))
(alter-file repo file-path new-content {:reset? false})))))
(defn delete-heading!
[{:heading/keys [uuid meta content file] :as heading} dummy?]
@ -716,32 +717,39 @@
(let [repo (state/get-current-repo)
file-path (:file/path (db/entity (:db/id file)))
file-content (:file/content (db/entity (:db/id file)))
after-headings (db/get-file-after-headings repo (:db/id file) (:end-pos meta))
last-start-pos (atom (:pos meta))
updated-headings (mapv
(fn [{:heading/keys [uuid meta] :as heading}]
(let [old-start-pos (:pos meta)
old-end-pos (:end-pos meta)
new-end-pos (if old-end-pos
(+ @last-start-pos (- old-end-pos old-start-pos)))
new-meta {:pos @last-start-pos
:end-pos new-end-pos}]
(reset! last-start-pos new-end-pos)
{:heading/uuid uuid
:heading/meta new-meta}))
after-headings)
after-headings (rebuild-after-headings repo file (:end-pos meta) (:pos meta))
new-content (utf8/delete! file-content (:pos meta) (:end-pos meta))]
(db/transact!
(concat
[[:db.fn/retractEntity [:heading/uuid uuid]]]
updated-headings
after-headings
[{:file/path file-path
:file/content new-content}]))
(alter-file repo
file-path
new-content
{:reset? false})
)))
(alter-file repo file-path new-content {:reset? false}))))
(defn delete-headings!
[heading-uuids]
(when (seq heading-uuids)
(let [repo (state/get-current-repo)
first-heading (db/entity [:heading/uuid (first heading-uuids)])
last-heading (db/entity [:heading/uuid (last heading-uuids)])
file (db/entity (:db/id (:heading/file first-heading)))
file-path (:file/path file)
file-content (:file/content file)
start-pos (:pos (:heading/meta first-heading))
end-pos (:end-pos (:heading/meta last-heading))
after-headings (rebuild-after-headings repo file end-pos start-pos)
new-content (utf8/delete! file-content start-pos end-pos)]
(db/transact!
(concat
(mapv
(fn [uuid]
[:db.fn/retractEntity [:heading/uuid uuid]])
heading-uuids)
after-headings
[{:file/path file-path
:file/content new-content}]))
(alter-file repo file-path new-content {:reset? false}))))
(defn clone-and-pull
[repo-url]
@ -811,6 +819,32 @@
(state/set-cursor-range! text-range)
(state/set-edit-input-id! edit-input-id))))
;; headings
(defn clear-selection!
[]
(when (state/in-selection-mode?)
(doseq [heading (state/get-selection-headings)]
(dom/remove-class! heading "selected")
(dom/remove-class! heading "noselect"))
(state/clear-selection!)))
(defn copy-selection-headings
[]
(when-let [headings (seq (get @state/state :selection/headings))]
(let [ids (map #(util/get-heading-id (gobj/get % "id")) headings)
content (some->> (db/get-headings-contents ids)
(map :heading/content)
(string/join ""))]
(when-not (string/blank? content)
(util/copy-to-clipboard! content)))))
(defn cut-selection-headings
[]
(when-let [headings (seq (get @state/state :selection/headings))]
(let [ids (map #(util/get-heading-id (gobj/get % "id")) headings)]
(delete-headings! ids))))
(defn start!
[]
(let [{:keys [repos] :as me} (set-me-if-exists!)]
@ -838,15 +872,15 @@
(p/let [changes (git/get-status-matrix (state/get-current-repo))]
(prn changes)))
;; (defn debug-file-and-headings
;; [path]
;; (p/let [content (load-file (state/get-current-repo)
;; path)]
;; (let [db-content (db/get-file path)
;; headings (db/get-file-by-concat-headings-debug-version path)]
;; (prn {:content content
;; :utf8-length (utf8/length (utf8/encode content))
;; :headings headings}))))
(defn debug-file-and-headings
[path]
(p/let [content (load-file (state/get-current-repo)
path)]
(let [db-content (db/get-file path)
headings (db/get-file-by-concat-headings-debug-version path)]
(prn {:content content
:utf8-length (utf8/length (utf8/encode content))
:headings headings}))))
;; (debug-file-and-headings "readme.org")
)

View File

@ -0,0 +1,28 @@
(ns frontend.keyboard
(:require [goog.events :as events]
[goog.ui.KeyboardShortcutHandler.EventType :as EventType]
[goog.events.KeyCodes :as KeyCodes])
(:import [goog.ui KeyboardShortcutHandler]))
;; Copy from https://github.com/tonsky/rum/blob/gh-pages/doc/useful-mixins.md#keyboard-shortcut
(defn install-shortcut!
"Installs a Keyboard Shortcut handler.
The key is a string the trigger is a function that will receive the keyboard event as the
first argument. If once? is true the keyboard shortcut is only fired once.
The unregister handler is returned and can be called to unregister the listener.
If target is not given it's attached to window."
([key trigger] (install-shortcut! key trigger false js/window))
([key trigger once?] (install-shortcut! key trigger once? js/window))
([key trigger once? target]
(let [handler (new KeyboardShortcutHandler target)]
(.registerShortcut handler (str key once?) key)
(events/listen
handler
EventType/SHORTCUT_TRIGGERED
(fn [e]
(trigger e)
(when once?
(.unregisterShortcut handler keys))))
(fn []
(.unregisterShortcut handler key)))))

View File

@ -1,7 +1,8 @@
(ns frontend.mixins
(:require [rum.core :as rum]
[goog.dom :as dom]
[goog.object :as gobj])
[goog.object :as gobj]
[frontend.keyboard :as keyboard])
(:import [goog.events EventHandler]))
(defn detach
@ -142,3 +143,23 @@
{:will-mount (fn [state]
(handler (:rum/args state))
state)})
(defn keyboard-mixin
"Triggers f when key is pressed while the component is mounted.
if target is a function it will be called AFTER the component mounted
with state and should return a dom node that is the target of the listener.
If no target is given it is defaulted to js/window (global handler)
Ex:
(keyboard-mixin \"esc\" #(browse-to :home/home))"
([key f] (keyboard-mixin key f js/window))
([key f target]
(let [target-fn (if (fn? target) target (fn [_] target))]
{:did-mount
(fn [state]
;; (prn "add shortcut: " key)
(assoc state ::keyboard-listener
(keyboard/install-shortcut! key f false (target-fn state))))
:will-unmount
(fn [state]
((::keyboard-listener state))
state)})))

View File

@ -42,6 +42,7 @@
:selection/mode false
:selection/headings nil
:custom-context-menu/show? false
}))
(defn sub
@ -209,3 +210,11 @@
(defn in-selection-mode?
[]
(:selection/mode @state))
(defn show-custom-context-menu!
[]
(swap! state assoc :custom-context-menu/show? true))
(defn hide-custom-context-menu!
[]
(swap! state assoc :custom-context-menu/show? false))

View File

@ -471,16 +471,31 @@
[class-name]
(try
(when (gobj/get js/window "getSelection")
(let [selection (js/window.getSelection)
range (.getRangeAt selection 0)
container (gobj/get range "commonAncestorContainer")]
(let [container-nodes (array-seq (.getElementsByClassName container class-name))]
(filter
(fn [node]
(.containsNode selection node true))
container-nodes))))
(let [selection (js/window.getSelection)
range (.getRangeAt selection 0)
container (gobj/get range "commonAncestorContainer")]
(let [container-nodes (array-seq (.getElementsByClassName container class-name))]
(filter
(fn [node]
(.containsNode selection node true))
container-nodes))))
(catch js/Error _e
nil)))
(comment
(get-selected-nodes "ls-heading-parent"))
(defn get-heading-id
[id]
(try
(uuid (string/replace id "ls-heading-parent-" ""))
(catch js/Error e
(prn "get-heading-id failed, error: " e))))
(defn copy-to-clipboard! [s]
(let [el (js/document.createElement "textarea")]
(set! (.-value el) s)
(.setAttribute el "readonly" "")
(set! (-> el .-style .-position) "absolute")
(set! (-> el .-style .-left) "-9999px")
(js/document.body.appendChild el)
(.select el)
(js/document.execCommand "copy")
(js/document.body.removeChild el)))