Jelajahi Sumber

Enhance/pdf improvements (#6475)

Full-text search, highlights and assets alias support 
It also exposes a plugin API for highlight context menu   
    ```ts
    /**
       * Current it's only available for pdf viewer
       * @param label - displayed name of command
       * @param action - callback for the clickable item
       * @param opts - clearSelection: clear highlight selection when callback invoked
       */
      registerHighlightContextMenuItem: (
        label: string,
        action: SimpleCommandCallback,
        opts?: {
          clearSelection: boolean
        }
      ) => unknown
    ```
Charlie 3 tahun lalu
induk
melakukan
d53ac94bfc
50 mengubah file dengan 3769 tambahan dan 984 penghapusan
  1. 2 0
      .clj-kondo/config.edn
  2. 1 1
      deps/graph-parser/src/logseq/graph_parser/property.cljs
  3. 2 1
      libs/.npmignore
  4. 18 4
      libs/src/LSPlugin.ts
  5. 29 5
      libs/src/LSPlugin.user.ts
  6. 3 1
      package.json
  7. 1 2
      resources/js/pdfjs/pdf.js
  8. 1 2
      resources/js/pdfjs/pdf.worker.js
  9. 2 3
      resources/js/pdfjs/pdf_viewer.js
  10. 1 5
      resources/js/preload.js
  11. 20 8
      src/electron/electron/core.cljs
  12. 0 1
      src/electron/electron/file_sync_rsapi.cljs
  13. 6 0
      src/electron/electron/utils.cljs
  14. 223 0
      src/main/frontend/components/assets.cljs
  15. 58 45
      src/main/frontend/components/block.cljs
  16. 1 1
      src/main/frontend/components/block.css
  17. 1 1
      src/main/frontend/components/file_sync.cljs
  18. 1 13
      src/main/frontend/components/onboarding/quick_tour.cljs
  19. 2 2
      src/main/frontend/components/page.cljs
  20. 2 0
      src/main/frontend/components/plugins.css
  21. 24 13
      src/main/frontend/components/select.cljs
  22. 19 10
      src/main/frontend/components/settings.cljs
  23. 130 0
      src/main/frontend/components/settings.css
  24. 4 1
      src/main/frontend/components/sidebar.cljs
  25. 11 2
      src/main/frontend/components/svg.cljs
  26. 27 3
      src/main/frontend/config.cljs
  27. 27 10
      src/main/frontend/db/model.cljs
  28. 8 1
      src/main/frontend/dicts.cljc
  29. 3 14
      src/main/frontend/extensions/lightbox.cljs
  30. 84 105
      src/main/frontend/extensions/pdf/assets.cljs
  31. 1066 0
      src/main/frontend/extensions/pdf/finder.js
  32. 453 433
      src/main/frontend/extensions/pdf/highlights.cljs
  33. 320 38
      src/main/frontend/extensions/pdf/pdf.css
  34. 546 0
      src/main/frontend/extensions/pdf/toolbar.cljs
  35. 28 16
      src/main/frontend/extensions/pdf/utils.cljs
  36. 185 0
      src/main/frontend/extensions/pdf/utils.js
  37. 5 1
      src/main/frontend/fs/capacitor_fs.cljs
  38. 183 184
      src/main/frontend/fs/sync.cljs
  39. 131 0
      src/main/frontend/handler/assets.cljs
  40. 69 36
      src/main/frontend/handler/editor.cljs
  41. 1 1
      src/main/frontend/mobile/intent.cljs
  42. 13 1
      src/main/frontend/modules/shortcut/config.cljs
  43. 5 3
      src/main/frontend/modules/shortcut/dicts.cljc
  44. 2 0
      src/main/frontend/spec/storage.cljc
  45. 27 6
      src/main/frontend/state.cljs
  46. 2 0
      src/main/frontend/ui.cljs
  47. 16 1
      src/main/frontend/util.cljc
  48. 0 9
      src/main/frontend/utils.js
  49. 1 1
      src/test/frontend/extensions/pdf/assets_test.cljs
  50. 5 0
      yarn.lock

+ 2 - 0
.clj-kondo/config.edn

@@ -16,6 +16,7 @@
   :unresolved-var {:exclude [frontend.util/node-path.basename
                              frontend.util/node-path.dirname
                              frontend.util/node-path.join
+                             frontend.util/node-path.extname
                              frontend.util/node-path.name]}
 
   :consistent-alias
@@ -89,6 +90,7 @@
            promesa.core/loop clojure.core/loop
            promesa.core/recur clojure.core/recur
            rum.core/defcc rum.core/defc
+           rum.core/with-context clojure.core/let
            rum.core/defcontext clojure.core/def
            clojure.test.check.clojure-test/defspec clojure.core/def
            clojure.test.check.properties/for-all clojure.core/for

+ 1 - 1
deps/graph-parser/src/logseq/graph_parser/property.cljs

@@ -60,7 +60,7 @@
    #{:id :custom-id :background-color :background_color :heading :collapsed
      :created-at :updated-at :last-modified-at :created_at :last_modified_at
      :query-table :query-properties :query-sort-by :query-sort-desc :ls-type
-     :hl-type :hl-page :hl-stamp :logseq.macro-name :logseq.macro-arguments
+     :hl-type :hl-page :hl-stamp :hl-color :logseq.macro-name :logseq.macro-arguments
      :logseq.tldraw.page :logseq.tldraw.shape}
    (set (map keyword markers))
    @built-in-extended-properties))

+ 2 - 1
libs/.npmignore

@@ -1,2 +1,3 @@
 src/
-webpack.*
+webpack.*
+.DS_Store

+ 18 - 4
libs/src/LSPlugin.ts

@@ -214,7 +214,7 @@ export type SlashCommandActionCmd =
   | 'editor/clear-current-slash'
   | 'editor/restore-saved-cursor'
 export type SlashCommandAction = [cmd: SlashCommandActionCmd, ...args: any]
-export type SimpleCommandCallback = (e: IHookEvent) => void
+export type SimpleCommandCallback<E = any> = (e: IHookEvent & E) => void
 export type BlockCommandCallback = (
   e: IHookEvent & { uuid: BlockUUID }
 ) => Promise<void>
@@ -487,15 +487,29 @@ export interface IEditorProxy extends Record<string, any> {
   ) => unknown
 
   /**
-   * register a custom command in the block context menu (triggered by right clicking the block dot)
-   * @param tag - displayed name of command
+   * register a custom command in the block context menu (triggered by right-clicking the block dot)
+   * @param label - displayed name of command
    * @param action - can be a single callback function to run when the command is called
    */
   registerBlockContextMenuItem: (
-    tag: string,
+    label: string,
     action: BlockCommandCallback
   ) => unknown
 
+  /**
+   * Current it's only available for pdf viewer
+   * @param label - displayed name of command
+   * @param action - callback for the clickable item
+   * @param opts - clearSelection: clear highlight selection when callback invoked
+   */
+  registerHighlightContextMenuItem: (
+    label: string,
+    action: SimpleCommandCallback,
+    opts?: {
+      clearSelection: boolean
+    }
+  ) => unknown
+
   // block related APIs
 
   checkEditing: () => Promise<BlockUUID | boolean>

+ 29 - 5
libs/src/LSPlugin.user.ts

@@ -69,6 +69,7 @@ function registerSimpleCommand(
     desc?: string
     palette?: boolean
     keybinding?: SimpleCommandKeybinding
+    extras?: Record<string, any>
   },
   action: SimpleCommandCallback
 ) {
@@ -76,7 +77,7 @@ function registerSimpleCommand(
     return false
   }
 
-  const { key, label, desc, palette, keybinding } = opts
+  const { key, label, desc, palette, keybinding, extras } = opts
   const eventKey = `SimpleCommandHook${key}${++registeredCmdUid}`
 
   this.Editor['on' + eventKey](action)
@@ -85,7 +86,7 @@ function registerSimpleCommand(
     method: 'register-plugin-simple-command',
     args: [
       this.baseInfo.id,
-      [{ key, label, type, desc, keybinding }, ['editor/hook', eventKey]],
+      [{ key, label, type, desc, keybinding, extras}, ['editor/hook', eventKey]],
       palette,
     ],
   })
@@ -243,15 +244,14 @@ const editor: Partial<IEditorProxy> = {
 
   registerBlockContextMenuItem(
     this: LSPluginUser,
-    tag: string,
+    label: string,
     action: BlockCommandCallback
   ) {
     if (typeof action !== 'function') {
       return false
     }
 
-    const key = tag + '_' + this.baseInfo.id
-    const label = tag
+    const key = label + '_' + this.baseInfo.id
     const type = 'block-context-menu-item'
 
     registerSimpleCommand.call(
@@ -265,6 +265,30 @@ const editor: Partial<IEditorProxy> = {
     )
   },
 
+  registerHighlightContextMenuItem(
+    this: LSPluginUser,
+    label: string,
+    action: SimpleCommandCallback,
+    opts?: { clearSelection: boolean }) {
+    if (typeof action !== 'function') {
+      return false
+    }
+
+    const key = label + '_' + this.baseInfo.id
+    const type = 'highlight-context-menu-item'
+
+    registerSimpleCommand.call(
+      this,
+      type,
+      {
+        key,
+        label,
+        extras: opts
+      },
+      action
+    )
+  },
+
   scrollToBlockInPage(
     this: LSPluginUser,
     pageName: BlockPageName,

+ 3 - 1
package.json

@@ -58,6 +58,7 @@
         "cljs:release-electron": "clojure -M:cljs release app electron --debug && clojure -M:cljs release publishing",
         "cljs:release-app": "clojure -M:cljs release app --config-merge \"{:compiler-options {:output-feature-set :es6}}\"",
         "cljs:release-android-app": "clojure -M:cljs release app --config-merge \"{:compiler-options {:output-feature-set :es6}}\"",
+        "cljs:release-publishing": "clojure -M:cljs release publishing",
         "cljs:test": "clojure -M:test compile test",
         "cljs:run-test": "node static/tests.js",
         "cljs:dev-release-app": "clojure -M:cljs release app --config-merge \"{:closure-defines {frontend.config/DEV-RELEASE true}}\"",
@@ -132,7 +133,8 @@
         "send-intent": "3.0.11",
         "threads": "1.6.5",
         "url": "^0.11.0",
-        "yargs-parser": "20.2.4"
+        "yargs-parser": "20.2.4",
+        "path-complete-extname": "1.0.0"
     },
     "resolutions": {
         "pixi-graph-fork/@pixi/app": "6.2.0",

+ 1 - 2
resources/js/pdfjs/pdf.js

@@ -16421,5 +16421,4 @@ const pdfjsBuild = 'eaaa8b4ad';
 /******/ 	return __webpack_exports__;
 /******/ })()
 ;
-});
-//# sourceMappingURL=pdf.js.map
+});

+ 1 - 2
resources/js/pdfjs/pdf.worker.js

@@ -74427,5 +74427,4 @@ const pdfjsBuild = 'eaaa8b4ad';
 /******/ 	return __webpack_exports__;
 /******/ })()
 ;
-});
-//# sourceMappingURL=pdf.worker.js.map
+});

+ 2 - 3
resources/js/pdfjs/pdf_viewer.js

@@ -6327,7 +6327,7 @@ const FindState = {
 };
 exports.FindState = FindState;
 const FIND_TIMEOUT = 250;
-const MATCH_SCROLL_OFFSET_TOP = -50;
+const MATCH_SCROLL_OFFSET_TOP = -200;
 const MATCH_SCROLL_OFFSET_LEFT = -400;
 const CHARACTERS_TO_NORMALIZE = {
   "\u2010": "-",
@@ -8562,5 +8562,4 @@ const pdfjsBuild = 'eaaa8b4ad';
 /******/ 	return __webpack_exports__;
 /******/ })()
 ;
-});
-//# sourceMappingURL=pdf_viewer.js.map
+});

+ 1 - 5
resources/js/preload.js

@@ -110,11 +110,7 @@ contextBridge.exposeInMainWorld('apis', {
 
     const dest = path.join(repoPathRoot, to)
     const assetsRoot = path.dirname(dest)
-
-    if (!/assets$/.test(assetsRoot)) {
-      throw new Error('illegal assets dirname')
-    }
-
+    
     await fs.promises.mkdir(assetsRoot, { recursive: true })
 
     from = from && decodeURIComponent(from || getFilePathFromClipboard())

+ 20 - 8
src/electron/electron/core.cljs

@@ -2,7 +2,8 @@
   (:require [electron.handler :as handler]
             [electron.search :as search]
             [electron.updater :refer [init-updater] :as updater]
-            [electron.utils :refer [*win mac? linux? dev? get-win-from-sender restore-user-fetch-agent get-graph-name]]
+            [electron.utils :refer [*win mac? linux? dev? get-win-from-sender restore-user-fetch-agent
+                                    decode-protected-assets-schema-path get-graph-name send-to-renderer]]
             [electron.url :refer [logseq-url-handler]]
             [electron.logger :as logger]
             [clojure.string :as string]
@@ -24,6 +25,7 @@
 ;; Keep same as main/frontend.util.url
 (defonce LSP_SCHEME "logseq")
 (defonce FILE_LSP_SCHEME "lsp")
+(defonce FILE_ASSETS_SCHEME "assets")
 (defonce LSP_PROTOCOL (str FILE_LSP_SCHEME "://"))
 (defonce PLUGIN_URL (str LSP_PROTOCOL "logseq.io/"))
 (defonce STATIC_URL (str LSP_PROTOCOL "logseq.com/"))
@@ -57,11 +59,12 @@
   (.setAsDefaultProtocolClient app LSP_SCHEME)
 
   (.registerFileProtocol
-   protocol "assets"
+   protocol FILE_ASSETS_SCHEME
    (fn [^js request callback]
      (let [url (.-url request)
-           path (string/replace url "assets://" "")
-           path (js/decodeURI path)]
+           url (decode-protected-assets-schema-path url)
+           path (js/decodeURI url)
+           path (string/replace path "assets://" "")]
        (callback #js {:path path}))))
 
   (.registerFileProtocol
@@ -81,7 +84,7 @@
 
   #(do
      (.unregisterProtocol protocol FILE_LSP_SCHEME)
-     (.unregisterProtocol protocol "assets")))
+     (.unregisterProtocol protocol FILE_ASSETS_SCHEME)))
 
 (defn- handle-export-publish-assets [_event html custom-css-path export-css-path repo-path asset-filenames output-path]
   (p/let [app-path (. app getAppPath)
@@ -132,8 +135,12 @@
                 ;; TODO: ugly, replace with ls-files and filter with ".map"
                 _ (p/all (map (fn [file]
                                 (. fs removeSync (path/join static-dir "js" (str file ".map"))))
-                              ["main.js" "code-editor.js" "excalidraw.js" "tldraw.js" "age-encryption.js"]))]
-          (. dialog showMessageBox (clj->js {:message (str "Export public pages and publish assets to " root-dir " successfully")})))))))
+                              ["main.js" "code-editor.js" "excalidraw.js" "age-encryption.js"]))]
+          
+          (send-to-renderer
+           :notification
+           {:type "success"
+            :payload (str "Export public pages and publish assets to " root-dir " successfully 🎉")}))))))
 
 (defn setup-app-manager!
   [^js win]
@@ -261,7 +268,12 @@
         protocol (bean/->js [{:scheme     LSP_SCHEME
                               :privileges privileges}
                              {:scheme     FILE_LSP_SCHEME
-                              :privileges privileges}]))
+                              :privileges privileges}
+                             {:scheme     FILE_ASSETS_SCHEME
+                              :privileges {:standard        false
+                                           :secure          false
+                                           :bypassCSP       false
+                                           :supportFetchAPI false}}]))
 
       (set-app-menu!)
       (setup-deeplink!)

+ 0 - 1
src/electron/electron/file_sync_rsapi.cljs

@@ -57,4 +57,3 @@
                              (when-not (.isDestroyed win)
                                (.. win -webContents
                                    (send progress-notify-chan (bean/->js progress-info))))))))
-                                   

+ 6 - 0
src/electron/electron/utils.cljs

@@ -140,6 +140,12 @@
   [graph-dir]
   (str "logseq_local_" graph-dir))
 
+(defn decode-protected-assets-schema-path
+  [schema-path]
+  (cond-> schema-path
+    (string? schema-path)
+    (string/replace "/logseq__colon/" ":/")))
+
 ;; Keep update with the normalization in main
 (defn normalize
   [s]

+ 223 - 0
src/main/frontend/components/assets.cljs

@@ -0,0 +1,223 @@
+(ns frontend.components.assets
+  (:require
+   [clojure.set :refer [difference]]
+   [clojure.string :as string]
+   [rum.core :as rum]
+   [frontend.state :as state]
+   [frontend.context.i18n :refer [t]]
+   [frontend.util :as util]
+   [electron.ipc :as ipc]
+   [promesa.core :as p]
+   [medley.core :as medley]
+   [frontend.ui :as ui]
+   [frontend.config :as config]
+   [frontend.components.select :as cp-select]
+   [frontend.handler.notification :as notification]
+   [frontend.handler.assets :as assets-handler]))
+
+(defn -get-all-formats
+  []
+  (->> (concat config/doc-formats
+               config/audio-formats
+               config/video-formats
+               config/image-formats
+               config/markup-formats)
+       (map #(hash-map :id % :value (name %)))))
+
+(rum/defc input-auto-complete
+  [{:keys [items item-cp class
+           on-chosen on-keydown input-opts]}]
+
+  (let [[*input-val, set-*input-val] (rum/use-state (atom nil))
+        [input-empty?, set-input-empty?] (rum/use-state true)]
+
+    (rum/use-effect!
+     #(set-input-empty? (string/blank? @*input-val))
+     [@*input-val])
+
+    (cp-select/select
+     {:items          items
+      :close-modal?   false
+      :item-cp        item-cp
+      :on-chosen      on-chosen
+      :on-input       #(set-input-empty? (string/blank? %))
+      :tap-*input-val #(set-*input-val %)
+      :transform-fn   (fn [results]
+                        (if (and *input-val
+                                 (not (string/blank? @*input-val))
+                                 (not (seq results)))
+                          [{:id nil :value @*input-val}]
+                          results))
+      :host-opts      {:class (util/classnames [:cp__input-ac class {:is-empty-input input-empty?}])}
+      :input-opts     (cond-> input-opts
+                        (fn? on-keydown)
+                        (assoc :on-key-down #(on-keydown % *input-val)))})))
+
+(rum/defc confirm-dir-with-alias-name
+  [dir set-dir!]
+
+  (let [[val set-val!] (rum/use-state "")
+        on-submit (fn []
+                    (when-not (string/blank? val)
+                      (if-not (assets-handler/get-alias-by-name val)
+                        (do (set-dir! val dir nil)
+                            (state/close-modal!))
+                        (notification/show!
+                         (util/format "Alias name of [%s] already exists!" val) :warning))))]
+
+    [:div.cp__assets-alias-name-content
+     [:h1.text-2xl.opacity-90.mb-6 "What's the alias name of this selected directory?"]
+     [:p [:strong "Directory path:"]
+      [:a {:on-click #(when (util/electron?)
+                        (js/apis.openPath dir))} dir]]
+     [:p [:strong "Alias name:"]
+      [:input.px-1.border.rounded
+       {:autoFocus   true
+        :value       val
+        :placeholder "eg. Books"
+        :on-change   (fn [^js e]
+                       (set-val! (util/trim-safe (.. e -target -value))))
+        :on-key-up   (fn [^js e]
+                       (when (and (= 13 (.-which e))
+                                (not (string/blank? val)))
+                         (on-submit)))}]]
+
+     [:div.pt-6.flex.justify-end
+      (ui/button
+       "Save"
+       :disabled (string/blank? val)
+       :on-click on-submit)]]))
+
+(rum/defc restart-button [active?]
+  (when active?
+    (ui/button (t :plugin/restart)
+               :on-click #(js/logseq.api.relaunch)
+               :small? true :intent "logseq")))
+
+(rum/defcs ^:large-vars/data-var alias-directories
+  < rum/reactive
+    (rum/local nil ::ext-editing-dir)
+  [_state]
+  (let [*ext-editing-dir (::ext-editing-dir _state)
+        directories      (into [] (state/sub :assets/alias-dirs))
+        pick-exist       assets-handler/get-alias-by-dir
+        set-dir!         (fn [name dir exts]
+                           (when (and name dir)
+                             (state/set-assets-alias-dirs!
+                              (let [exist (pick-exist dir)]
+                                (if exist
+                                  (assoc directories (first exist) {:name name :dir dir :exts (set exts)})
+                                  (conj directories {:dir dir :name name :exts (set exts)}))))))
+
+        rm-dir           (fn [dir]
+                           (when-let [exist (pick-exist dir)]
+                             (state/set-assets-alias-dirs!
+                              (medley/remove-nth (first exist) directories))))
+
+        del-ext          (fn [dir ext]
+                           (when-let [exist (and ext (pick-exist dir))]
+                             (let [exts (:exts (second exist))
+                                   exts (difference exts (hash-set ext))
+                                   name (:name (second exist))]
+                               (set-dir! name dir exts))))
+
+        add-ext          (fn [dir ext]
+                           (when-let [exist (and ext (pick-exist dir))]
+                             (let [exts (:exts (second exist))
+                                   exts (conj exts (util/safe-lower-case ext))
+                                   name (:name (second exist))]
+                               (set-dir! name dir exts))))
+
+        confirm-dir      (fn [dir set-dir!]
+                           (state/set-sub-modal!
+                            #(confirm-dir-with-alias-name dir set-dir!)))]
+
+    [:div.cp__assets-alias-directories
+     [:ul
+      (for [{:keys [name dir exts]} directories]
+        [:li.item.px-2.py-2
+         [:div.flex.justify-between.items-center
+          [:span.font-semibold
+           (str "@" name)]
+
+          [:div.flex.items-center.space-x-2
+           [:a.opacity-90.active:opacity-50.text-sm.flex.space-x-1
+            {:on-click #(when (util/electron?)
+                          (js/apis.openPath dir))}
+            (ui/icon "folder") dir]]]
+
+         [:div.flex.justify-between.items-center
+          [:div.flex.mt-2.space-x-2.pr-6
+           (for [ext exts]
+             [:small.ext-label.is-del
+              {:key ext :on-click #(del-ext dir ext)}
+              [:span ext]
+              (ui/icon "circle-minus")])
+           (if (= dir @*ext-editing-dir)
+             (input-auto-complete
+              {:items        (-get-all-formats)
+
+               :close-modal? false
+               :item-cp      (fn [{:keys [value]}]
+                               [:div.ext-select-item value])
+
+               :on-chosen    (fn [{:keys [value]}]
+                               (add-ext dir value)
+                               (reset! *ext-editing-dir nil))
+               :on-keydown   (fn [^js e *input-val]
+                               (let [^js input (.-target e)]
+                                 (case (.-which e)
+                                   27                       ;; esc
+                                   (do (if-not (string/blank? (.-value input))
+                                         (reset! *input-val "")
+                                         (reset! *ext-editing-dir nil))
+                                       (util/stop e))
+
+                                   :dune)))
+               :input-opts   {:class       "cp__assets-alias-ext-input"
+                              :placeholder "E.g. mp3"
+                              :on-blur
+                              #(reset! *ext-editing-dir nil)}})
+
+             [:small.ext-label.is-plus
+              {:on-click #(reset! *ext-editing-dir dir)}
+              (ui/icon "plus") "Acceptable file extensions"])]
+
+          [:span.ctrls.flex.space-x-3.text-xs.opacity-30.hover:opacity-100.whitespace-nowrap.hidden.mt-1
+           [:a {:on-click #(rm-dir dir)} (ui/icon "trash-x")]]]
+
+         ])]
+
+     [:p.pt-2
+      (ui/button
+       "+ Add directory"
+       :on-click #(p/let [path (ipc/ipc :openDialog)]
+                    (when-not (or (string/blank? path)
+                                  (pick-exist path))
+                      (confirm-dir path set-dir!)))
+       :small? true)]]))
+
+(rum/defcs settings-content
+  < rum/reactive
+    (rum/local (state/sub :assets/alias-enabled?) ::alias-enabled?)
+  [_state]
+
+  (let [*pre-alias-enabled?    (::alias-enabled? _state)
+        alias-enabled?         (state/sub :assets/alias-enabled?)
+        alias-enabled-changed? (not= @*pre-alias-enabled? alias-enabled?)]
+
+    [:div.cp__assets-settings.panel-wrap
+     [:div.it
+      [:label.block.text-sm.font-medium.leading-5.opacity-70
+       "Alias directories"]
+      [:div (ui/toggle
+             alias-enabled?
+             #(state/set-assets-alias-enabled! (not alias-enabled?))
+             true)]
+      [:span
+       (restart-button alias-enabled-changed?)]]
+
+     (when alias-enabled?
+       [:div.pt-4
+        [:h2.font-bold.opacity-80 "Selected directories:"]
+        (alias-directories)])]))

+ 58 - 45
src/main/frontend/components/block.cljs

@@ -42,6 +42,7 @@
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.file-sync :as file-sync]
             [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.assets :as assets-handler]
             [frontend.handler.query :as query-handler]
             [frontend.handler.repeated :as repeated]
             [frontend.handler.route :as route-handler]
@@ -448,7 +449,9 @@
                     href
 
                     :else
-                    (get-file-absolute-path config href))]
+                    (if (assets-handler/check-alias-path? href)
+                      (assets-handler/normalize-asset-resource-url href)
+                      (get-file-absolute-path config href)))]
          (resizable-image config title href metadata full_text false))))))
 
 (defn repetition-to-string
@@ -593,7 +596,7 @@
          (let [original-name (util/get-page-original-name page-entity)
                s (if (not= (util/safe-page-name-sanity-lc original-name) page-name-in-block)
                    page-name-in-block ;; page-name-in-block might be overrided (legacy)
-                   original-name)
+                   (pdf-assets/human-page-name original-name))
                _ (when-not page-entity (js/console.warn "page-inner's page-entity is nil, given page-name: " page-name
                                                         " page-name-in-block: " page-name-in-block))]
            (if tag? (str "#" s) s))))]))
@@ -701,11 +704,11 @@
 
 (rum/defc asset-reference
   [config title path]
-  (let [repo-path (config/get-repo-dir (state/get-current-repo))
-        full-path (if (util/absolute-path? path)
+  (let [repo (state/get-current-repo)
+        real-path-url (if (util/absolute-path? path)
                     path
-                    (.. util/node-path (join repo-path (config/get-local-asset-absolute-path path))))
-        ext-name (util/get-file-ext full-path)
+                    (assets-handler/resolve-asset-real-path-url repo path))
+        ext-name (util/get-file-ext path)
         title-or-path (cond
                         (string? title)
                         title
@@ -713,26 +716,19 @@
                         (->elem :span (map-inline config title))
                         :else
                         path)]
+
     [:div.asset-ref-wrap
      {:data-ext ext-name}
 
-     (if (= "pdf" ext-name)
-       [:a.asset-ref.is-pdf
-        {:on-mouse-down (fn [e]
-                          (when-let [current (pdf-assets/inflate-asset full-path)]
-                            (util/stop e)
-                            (state/set-state! :pdf/current current)))}
-        title-or-path]
-       [:a.asset-ref {:target "_blank" :href full-path}
-        title-or-path])
-
-     (case ext-name
+     (cond
        ;; https://en.wikipedia.org/wiki/HTML5_video
-       ("mp4" "ogg" "webm" "mov")
-       [:video {:src full-path
+       (contains? config/video-formats (keyword ext-name))
+       [:video {:src real-path-url
                 :controls true}]
 
-       nil)]))
+       :else
+       [:a.asset-ref {:target "_blank" :href real-path-url}
+        title-or-path])]))
 
 (defonce excalidraw-loaded? (atom false))
 (rum/defc excalidraw < rum/reactive
@@ -835,10 +831,9 @@
 
 (defn- get-label-text
   [label]
-  (and (= 1 (count label))
-       (let [label (first label)]
-         (string? (last label))
-         (js/decodeURIComponent (last label)))))
+  (when (and (= 1 (count label))
+             (string? (last (first label))))
+    (js/decodeURIComponent (last (first label)))))
 
 (defn- get-page
   [label]
@@ -987,7 +982,8 @@
         (nil? metadata-show)
         (or
          (gp-config/local-asset? s)
-         (text-util/media-link? media-formats s)))
+         (text-util/media-link? media-formats s)
+         (= (first s) \@)))
        (true? (boolean metadata-show))))
 
      ;; markdown
@@ -1025,7 +1021,9 @@
                  href
 
                  :else
-                 (get-file-absolute-path config href))]
+                 (if (assets-handler/check-alias-path? href)
+                   (assets-handler/resolve-asset-real-path-url (state/get-current-repo) href)
+                   (get-file-absolute-path config href)))]
       (audio-cp href))))
 
 (defn- media-link
@@ -1040,16 +1038,16 @@
       (cond
         (util/electron?)
         [:a.asset-ref.is-pdf
-         {:href "javascript:void(0);"
-          :on-mouse-down (fn [_event]
+         {:on-mouse-down (fn [_event]
                            (when-let [current (pdf-assets/inflate-asset s)]
                              (state/set-state! :pdf/current current)))}
-         label-text]
+         (or label-text
+             (->elem :span (map-inline config label)))]
 
         (mobile-util/native-platform?)
         (asset-link config label-text s metadata full_text))
 
-      (contains? (config/doc-formats) ext)
+      (contains? config/doc-formats ext)
       (asset-link config label-text s metadata full_text)
 
       (not (contains? #{:mp4 :webm :mov} ext))
@@ -1916,7 +1914,7 @@
                  (not= "nil" marker))
         {:class (str (string/lower-case marker))})
       (when bg-color
-        {:style {:background-color (if (some #{bg-color} ui/block-background-colors) 
+        {:style {:background-color (if (some #{bg-color} ui/block-background-colors)
                                      (str "var(--ls-highlight-color-" bg-color ")")
                                      bg-color)}
          :class "px-1 with-bg-color"}))
@@ -1930,20 +1928,30 @@
          (conj
           (map-inline config title)
           (when (= block-type :whiteboard-shape) [:span.mr-1 (ui/icon "whiteboard-element" {:extension? true})])
-          (when (and (util/electron?) (not (#{:default :whiteboard-shape} block-type)))
-            [:a.prefix-link
-             {:on-click #(case block-type
-                           ;; pdf annotation
-                           :annotation (pdf-assets/open-block-ref! t)
-                           (.preventDefault %))}
-
-             [:span.hl-page
-              [:strong.forbid-edit (str "P" (or (:hl-page properties) "?"))]
-              [:label.blank " "]]
-
-             (when-let [st (and (= :area (keyword (:hl-type properties)))
-                                (:hl-stamp properties))]
-               (pdf-assets/area-display t st))]))
+          (when (and
+                 (or config/publishing? (util/electron?))
+                 (not= block-type :default))
+            (let [area? (= :area (keyword (:hl-type properties)))]
+              [:div.prefix-link
+               {:on-mouse-down (fn [^js e]
+                                 (let [^js target (.-target e)]
+                                   (case block-type
+                                     ;; pdf annotation
+                                     :annotation
+                                     (if (and area? (.contains (.-classList target) "blank"))
+                                       :actions
+                                       (do
+                                         (pdf-assets/open-block-ref! t)
+                                         (util/stop e)))
+
+                                     :dune)))}
+
+               [:span.hl-page
+                [:strong.forbid-edit (str "P" (or (:hl-page properties) "?"))]
+                [:label.blank " "]]
+
+               (when (and area? (:hl-stamp properties))
+                 (pdf-assets/area-display t))])))
 
          [[:span.opacity-50 "Click here to start writing, type '/' to see all the commands."]])
        [tags])))))
@@ -2228,6 +2236,10 @@
                {:blockid       (str uuid)
                 :data-type (name block-type)
                 :style {:width "100%" :pointer-events (when stop-events? "none")}}
+
+                (not (string/blank? (:hl-color properties)))
+                (assoc :data-hl-color (:hl-color properties))
+
                 (not block-ref?)
                 (assoc mouse-down-key (fn [e]
                                         (block-content-on-mouse-down e block block-id content edit-input-id))))]
@@ -2373,6 +2385,7 @@
                                            (editor-handler/unhighlight-blocks!)
                                            (state/set-editing! edit-input-id (:block/content block) block ""))}})
             (block-content config block edit-input-id block-id slide?))]
+
           (when-not hide-block-refs-count?
             [:div.flex.flex-row.items-center
              (when (and (:embed? config)

+ 1 - 1
src/main/frontend/components/block.css

@@ -614,7 +614,7 @@ a.cloze-revealed {
 }
 
 .page-property-key {
-  @apply font-medium;
+  @apply font-medium whitespace-nowrap;
   color: var(--ls-secondary-text-color);
 }
 

+ 1 - 1
src/main/frontend/components/file_sync.cljs

@@ -867,4 +867,4 @@
           (state/pub-event! [:file-sync/onboarding-tip type])
           (state/set-state! [:file-sync/onboarding-state (keyword type)] true)))
       (catch :default e
-        (js/console.warn "[onboarding SKIP] " (name type) e)))))
+        (js/console.warn "[onboarding SKIP] " (name type) e)))))

+ 1 - 13
src/main/frontend/components/onboarding/quick_tour.cljs

@@ -1,7 +1,6 @@
 (ns frontend.components.onboarding.quick-tour
   (:require [promesa.core :as p]
             [cljs-bean.core :as bean]
-            [frontend.loader :refer [load]]
             [frontend.state :as state]
             [frontend.date :as date]
             [frontend.util :as util]
@@ -10,20 +9,9 @@
             [hiccups.runtime :as h]
             [dommy.core :as d]))
 
-(defn js-load$
-  [url]
-  (p/create
-   (fn [resolve]
-     (load url resolve))))
-
-(def JS_ROOT
-  (if (= js/location.protocol "file:")
-    "./js"
-    "./static/js"))
-
 (defn- load-base-assets$
   []
-  (js-load$ (str JS_ROOT "/shepherd.min.js")))
+  (util/js-load$ (str util/JS_ROOT "/shepherd.min.js")))
 
 (defn- make-skip-fns
   [^js jsTour]

+ 2 - 2
src/main/frontend/components/page.cljs

@@ -279,11 +279,11 @@
           *edit? (get state ::edit?)
           *input-value (get state ::input-value)
           repo (state/get-current-repo)
-          hls-page? (pdf-assets/hls-page? title)
+          hls-page? (pdf-assets/hls-file? title)
           whiteboard-page? (model/whiteboard-page? page-name)
           untitled? (and whiteboard-page? (parse-uuid page-name)) ;; normal page cannot be untitled right?
           title (if hls-page?
-                  (pdf-assets/human-hls-pagename-display title)
+                  [:a.asset-ref (pdf-assets/fix-local-asset-pagename title)]
                   (if fmt-journal? (date/journal-title->custom-format title) title))
           old-name (or title page-name)]
       [:h1.page-title.flex.cursor-pointer.gap-1

+ 2 - 0
src/main/frontend/components/plugins.css

@@ -767,6 +767,8 @@
   top: 40%;
   left: 30%;
   border: 2px solid var(--ls-border-color);
+  border-radius: 6px;
+  overflow: hidden;
 
   .draggable-handle {
   }

+ 24 - 13
src/main/frontend/components/select.cljs

@@ -38,30 +38,41 @@
                    state)}
   [state {:keys [items limit on-chosen empty-placeholder
                  prompt-key input-default-placeholder close-modal?
-                 extract-fn]
+                 extract-fn host-opts on-input input-opts
+                 item-cp transform-fn tap-*input-val]
           :or {limit 100
                prompt-key :select/default-prompt
                empty-placeholder (fn [_t] [:div])
                close-modal? true
                extract-fn :value}}]
   (let [input (::input state)]
-    [:div.cp__select.cp__select-main
+    (when (fn? tap-*input-val)
+      (tap-*input-val input))
+    [:div.cp__select
+     (merge {:class "cp__select-main"} host-opts)
      [:div.input-wrap
       [:input.cp__select-input.w-full
-       {:type        "text"
-        :placeholder (or input-default-placeholder (t prompt-key))
-        :auto-focus  true
-        :value       @input
-        :on-change   (fn [e] (reset! input (util/evalue e)))}]]
+       (merge {:type        "text"
+               :placeholder (or input-default-placeholder (t prompt-key))
+               :auto-focus  true
+               :value       @input
+               :on-change   (fn [e]
+                              (let [v (util/evalue e)]
+                                (reset! input v)
+                                (and (fn? on-input) (on-input v))))}
+              input-opts)]]
 
      [:div.item-results-wrap
       (ui/auto-complete
-       (search/fuzzy-search items @input :limit limit :extract-fn extract-fn)
-       {:item-render render-item
-        :class       "cp__select-results"
-        :on-chosen   (fn [x]
-                       (when close-modal? (state/close-modal!))
-                       (on-chosen x))
+       (cond-> (search/fuzzy-search items @input :limit limit :extract-fn extract-fn)
+         (fn? transform-fn)
+         (transform-fn @input))
+
+       {:item-render       (or item-cp render-item)
+        :class             "cp__select-results"
+        :on-chosen         (fn [x]
+                             (when close-modal? (state/close-modal!))
+                             (on-chosen x))
         :empty-placeholder (empty-placeholder t)})]]))
 
 (defn select-config

+ 19 - 10
src/main/frontend/components/settings.cljs

@@ -2,6 +2,7 @@
   (:require [clojure.string :as string]
             [frontend.components.svg :as svg]
             [frontend.components.plugins :as plugins]
+            [frontend.components.assets :as assets]
             [frontend.config :as config]
             [frontend.context.i18n :refer [t]]
             [frontend.storage :as storage]
@@ -196,7 +197,7 @@
          enabled?
          (fn []
            (state/set-state! [:electron/user-cfgs :spell-check] (not enabled?))
-           (p/then (ipc/ipc "userAppCfgs" :spell-check (not enabled?))
+           (p/then (ipc/ipc :userAppCfgs :spell-check (not enabled?))
                    #(when (js/confirm (t :relaunch-confirm-to-work))
                       (js/logseq.api.relaunch))))
          true)]]]))
@@ -213,7 +214,7 @@
          enabled?
          (fn []
            (state/set-state! [:electron/user-cfgs :git/disable-auto-commit?] enabled?)
-           (ipc/ipc "userAppCfgs" :git/disable-auto-commit? enabled?))
+           (ipc/ipc :userAppCfgs :git/disable-auto-commit? enabled?))
          true)]]]))
 
 (rum/defcs git-auto-commit-seconds < rum/reactive
@@ -233,7 +234,7 @@
                                      (< 0 value (inc 600)))
                               (do
                                 (state/set-state! [:electron/user-cfgs :git/auto-commit-seconds] value)
-                                (ipc/ipc "userAppCfgs" :git/auto-commit-seconds value))
+                                (ipc/ipc :userAppCfgs :git/auto-commit-seconds value))
                               (when-let [elem (gobj/get event "target")]
                                 (notification/show!
                                  [:div "Invalid value! Must be a number between 1 and 600."]
@@ -247,7 +248,7 @@
             (t :settings-page/auto-updater)
             enabled?
             #((state/set-state! [:electron/user-cfgs :auto-update] (not enabled?))
-              (ipc/ipc "userAppCfgs" :auto-update (not enabled?))))))
+              (ipc/ipc :userAppCfgs :auto-update (not enabled?))))))
 
 (defn language-row [t preferred-language]
   (let [on-change (fn [e]
@@ -737,15 +738,20 @@
       [:aside.md:w-64 {:style {:min-width "10rem"}}
        [:ul.settings-menu
         (for [[label id text icon]
-              [[:general "general" (t :settings-page/tab-general) (ui/icon "adjustments" {:style {:font-size 20}})]
-               [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing" {:style {:font-size 20}})]
+              [[:general "general" (t :settings-page/tab-general) (ui/icon "adjustments")]
+               [:editor "editor" (t :settings-page/tab-editor) (ui/icon "writing")]
+
                (when (and
                       (util/electron?)
                       (not (file-sync-handler/synced-file-graph? current-repo)))
-                 [:git "git" (t :settings-page/tab-version-control) (ui/icon "history" {:style {:font-size 20}})])
-               [:advanced "advanced" (t :settings-page/tab-advanced) (ui/icon "bulb" {:style {:font-size 20}})]
-               [:features "features" (t :settings-page/tab-features) (ui/icon "app-feature" {:style {:font-size 18}
-                                                                                             :extension? true})]
+                 [:git "git" (t :settings-page/tab-version-control) (ui/icon "history")])
+
+               (when (util/electron?)
+                 [:assets "assets" (t :settings-page/tab-assets) (ui/icon "box")])
+
+               [:advanced "advanced" (t :settings-page/tab-advanced) (ui/icon "bulb")]
+               [:features "features" (t :settings-page/tab-features) (ui/icon "app-feature" {:extension? true})]
+
                (when plugins-of-settings
                  [:plugins-setting "plugins" (t :settings-of-plugins) (ui/icon "puzzle")])]]
 
@@ -779,6 +785,9 @@
          :git
          (settings-git)
 
+         :assets
+         (assets/settings-content)
+
          :advanced
          (settings-advanced current-repo)
 

+ 130 - 0
src/main/frontend/components/settings.css

@@ -94,6 +94,8 @@
       }
 
       > .it {
+        @apply sm:grid sm:grid-cols-3 sm:gap-6;
+
         margin-bottom: 0;
         padding-bottom: 12px;
         align-items: center;
@@ -295,6 +297,134 @@
   }
 }
 
+.cp__assets {
+  &-alias-directories {
+    @apply py-2 px-1;
+
+    > ul {
+      @apply m-0 list-none -mx-2;
+
+      > li {
+        border-top: 1px solid var(--ls-secondary-border-color);
+
+        &:hover {
+          .ext-label.is-plus {
+            opacity: 100;
+          }
+
+          .ctrls {
+            display: block;
+          }
+        }
+      }
+    }
+
+    .ext-label {
+      @apply rounded px-1.5 opacity-70 cursor-pointer flex items-center select-none active:opacity-50;
+
+      background-color: var(--ls-secondary-border-color);
+      color: var(--ls-secondary-text-color);
+
+      &.is-del {
+        i.ti {
+          width: 0;
+          overflow: hidden;
+          opacity: .9;
+          color: red;
+          transition: width .3s;
+
+          &:hover {
+            opacity: 1;
+          }
+        }
+
+        &:hover {
+          i.ti {
+            width: 14px;
+            padding-left: 2px;
+          }
+        }
+      }
+
+      &.is-plus {
+        background-color: var(--ls-primary-background-color);
+        border: 1px solid var(--ls-border-color);
+      }
+    }
+
+    .ext-input {
+      @apply leading-none;
+
+      padding: 1px 4px;
+      width: 60px;
+    }
+
+    .cp__input-ac {
+      width: unset;
+      margin: 0;
+      line-height: 1em;
+      position: relative;
+      overflow: visible;
+
+      /*noinspection ALL*/
+
+      .item-results-wrap {
+        position: absolute;
+        top: 24px;
+        left: 0;
+        z-index: 1;
+        width: 100px;
+        max-height: 180px;
+        border:1px solid var(--ls-border-color);
+        border-radius: 4px;
+        overflow: auto;
+        overflow: overlay;
+
+        .menu-link {
+          padding: 4px 6px;
+          font-size: 12px;
+        }
+
+        .ext-select-item {
+          display: block;
+          white-space: nowrap;
+        }
+      }
+
+      &.is-empty-input {
+        .item-results-wrap {
+          display: none;
+        }
+      }
+    }
+  }
+
+  &-alias-name-content {
+    margin: -8px;
+
+    > p {
+      @apply py-1.5 text-lg px-1 my-0;
+
+      strong {
+        @apply inline-block pr-4 text-right w-40 opacity-70;
+      }
+    }
+  }
+
+  &-alias-ext-input {
+    width: 80px !important;
+    padding: 1px 4px;
+    border: 2px solid var(--ls-secondary-border-color);
+    font-size: 11px;
+    border-radius: 4px;
+    height: 22px;
+
+    &:focus {
+      border-color: var(--ls-border-color);
+    }
+  }
+}
+
 html.is-native-android,
 html.is-native-iphone,
 html.is-native-iphone-without-notch {

+ 4 - 1
src/main/frontend/components/sidebar.cljs

@@ -623,6 +623,7 @@
         settings-open? (state/sub :ui/settings-open?)
         left-sidebar-open?  (state/sub :ui/left-sidebar-open?)
         wide-mode? (state/sub :ui/wide-mode?)
+        ls-block-hl-colored? (state/sub :pdf/block-highlight-colored?)
         onboarding-state (state/sub :file-sync/onboarding-state)
         right-sidebar-blocks (state/sub-right-sidebar-blocks)
         route-name (get-in route-match [:data :name])
@@ -658,7 +659,9 @@
      [:main.theme-inner
       {:class (util/classnames [{:ls-left-sidebar-open left-sidebar-open?
                                  :ls-right-sidebar-open sidebar-open?
-                                 :ls-wide-mode wide-mode?}])}
+                                 :ls-wide-mode wide-mode?
+                                 :ls-hl-colored ls-block-hl-colored?}])}
+
       [:button#skip-to-main
        {:on-key-up (fn [e]
                         (when (= (.-key e) "Enter")

+ 11 - 2
src/main/frontend/components/svg.cljs

@@ -204,8 +204,7 @@
   [:svg.h-5.w-5
    {:view-box "0 0 20 20", :fill "currentColor"}
    [:path
-    {:d
-                "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
+    {:d "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
      :clip-rule "evenodd"
      :fill-rule "evenodd"}]])
 
@@ -220,6 +219,16 @@
      :stroke-linecap  "round"}]])
 
 
+(defn search2
+  ([] (search2 nil))
+  ([size]
+   [:svg
+    {:viewBox "0 0 20 20" :width size :height size :fill "currentColor"}
+    [:path
+     {:d         "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
+      :clip-rule "evenodd"
+      :fill-rule "evenodd"}]]))
+
 (defn github
   ([] (github nil))
   ([opts]

+ 27 - 3
src/main/frontend/config.cljs

@@ -76,17 +76,41 @@
 (def markup-formats
   #{:org :md :markdown :asciidoc :adoc :rst})
 
-(defn doc-formats
-  []
+(def doc-formats
   #{:doc :docx :xls :xlsx :ppt :pptx :one :pdf :epub})
 
-(def audio-formats #{:mp3 :ogg :mpeg :wav :m4a :flac :wma :aac})
+(def image-formats
+  #{:png :jpg :jpeg :bmp :gif :webp :svg})
+
+(def audio-formats
+  #{:mp3 :ogg :mpeg :wav :m4a :flac :wma :aac})
+
+(def video-formats
+  #{:mp4 :webm :mov})
 
 (def media-formats (set/union (gp-config/img-formats) audio-formats))
 
 (def html-render-formats
   #{:adoc :asciidoc})
 
+(defn extname-of-supported?
+  ([input] (extname-of-supported?
+            input
+            [image-formats doc-formats audio-formats
+             video-formats markup-formats html-render-formats]))
+  ([input formats]
+   (when-let [input (some->
+                     (cond-> input
+                       (and (string? input)
+                            (not (string/blank? input)))
+                       (string/replace-first "." ""))
+                     (util/safe-lower-case)
+                     (keyword))]
+     (some
+      (fn [s]
+        (contains? s input))
+      formats))))
+
 (def mobile?
   (when-not util/node-test?
     (util/safe-re-find #"Mobi" js/navigator.userAgent)))

+ 27 - 10
src/main/frontend/db/model.cljs

@@ -11,6 +11,7 @@
             [frontend.db.conn :as conn]
             [frontend.db.react :as react]
             [frontend.db.utils :as db-utils]
+            [frontend.extensions.pdf.utils :as pdf-utils]
             [frontend.state :as state]
             [frontend.util :as util :refer [react]]
             [frontend.util.drawer :as drawer]
@@ -1446,16 +1447,32 @@
 
 (defn get-assets
   [datoms]
-  (keep
-   (fn [datom]
-     (when (= :block/content (:a datom))
-       (let [matched (re-seq #"\([./]*/assets/([^)]+)\)" (:v datom))
-             matched (get (into [] matched) 0)
-             path (get matched 1)]
-         (when (and (string? path)
-                    (not (string/ends-with? path ".js")))
-           path))))
-   datoms))
+  (let [get-page-by-eid
+        (memoize #(some->
+                   (db-utils/pull %)
+                   :block/page
+                   :db/id
+                   db-utils/pull))]
+    (flatten
+     (keep
+      (fn [datom]
+        (cond-> []
+
+          (= :block/content (:a datom))
+          (concat (let [matched (re-seq #"\([./]*/assets/([^)]+)\)" (:v datom))]
+                    (when (seq matched)
+                      (for [[_ path] matched]
+                        (when (and (string? path)
+                                   (not (string/ends-with? path ".js")))
+                          path)))))
+          ;; area image assets
+          (= (:hl-type (:v datom)) "area")
+          (#(let [path (some-> (db-utils/pull (:e datom))
+                               (pdf-utils/get-area-block-asset-url
+                                (get-page-by-eid (:e datom))))
+                  path (pdf-utils/clean-asset-path-prefix path)]
+              (conj % path)))))
+      datoms))))
 
 (defn clean-export!
   [db]

+ 8 - 1
src/main/frontend/dicts.cljc

@@ -235,6 +235,7 @@
         :settings-page/tab-shortcuts "Shortcuts"
         :settings-page/tab-version-control "Version control"
         :settings-page/tab-advanced "Advanced"
+        :settings-page/tab-assets "Assets"
         :settings-page/tab-features "Features"
         :settings-page/plugin-system "Plugins"
         :settings-page/enable-flashcards "Flashcards"
@@ -309,7 +310,7 @@
         :all-journals "All journals"
         :my-publishing "My publishing"
         :settings "Settings"
-        :settings-of-plugins "Plugin Settings"
+        :settings-of-plugins "Plugins"
         :plugins "Plugins"
         :themes "Themes"
         :developer-mode-alert "You need to restart the app to enable the plugin system. Do you want to restart it now?"
@@ -380,6 +381,8 @@
         :pdf/copy-text "Copy text"
         :pdf/linked-ref "Linked references"
         :pdf/toggle-dashed "Dashed style for area highlight"
+        :pdf/hl-block-colored "Colored label for highlight block"
+        :pdf/doc-metadata "Document metadata"
 
         :updater/new-version-install "A new version has been downloaded."
         :updater/quit-and-install "Restart to install"
@@ -1354,7 +1357,9 @@
            :settings-page/tab-general "常规"
            :settings-page/tab-editor "编辑器"
            :settings-page/tab-shortcuts "快捷键"
+           :settings-page/tab-assets "附件设置"
            :settings-page/tab-advanced "高级设置"
+           :settings-page/tab-features "更多功能"
            :settings-page/tab-version-control "多版本控制"
            :settings-page/plugin-system "插件系统"
            :settings-page/network-proxy "网络代理"
@@ -1481,6 +1486,8 @@
            :pdf/copy-text "复制文本"
            :pdf/linked-ref "转到注解"
            :pdf/toggle-dashed "区域选取为虚线"
+           :pdf/hl-block-colored "颜色标识高亮块"
+           :pdf/doc-metadata "查看文档元数据"
 
            :updater/new-version-install "新版本已经准备就绪,重启应用即可更新。"
            :updater/quit-and-install "现在安装"

+ 3 - 14
src/main/frontend/extensions/lightbox.cljs

@@ -1,22 +1,11 @@
 (ns frontend.extensions.lightbox
   (:require [promesa.core :as p]
             [cljs-bean.core :as bean]
-            [frontend.loader :refer [load]]))
-
-(defn js-load$
-  [url]
-  (p/create
-    (fn [resolve]
-      (load url resolve))))
-
-(def JS_ROOT
-  (if (= js/location.protocol "file:")
-    "./js"
-    "./static/js"))
+            [frontend.util :as util]))
 
 (defn load-base-assets$
   []
-  (js-load$ (str JS_ROOT "/photoswipe.js")))
+  (util/js-load$ (str util/JS_ROOT "/photoswipe.js")))
 
 (defn preview-images!
   [images]
@@ -27,4 +16,4 @@
           ^js lightbox (js/window.photoswipe.PhotoSwipeLightbox. (bean/->js options))]
       (doto lightbox
         (.init)
-        (.loadAndOpen 0)))))
+        (.loadAndOpen 0)))))

+ 84 - 105
src/main/frontend/extensions/pdf/assets.cljs

@@ -7,10 +7,11 @@
             [frontend.fs :as fs]
             [frontend.handler.editor :as editor-handler]
             [frontend.handler.page :as page-handler]
+            [frontend.handler.assets :as assets-handler]
             [frontend.util.page-property :as page-property]
             [frontend.state :as state]
             [frontend.util :as util]
-            [logseq.graph-parser.util :as gp-util]
+            [frontend.extensions.pdf.utils :as pdf-utils]
             [logseq.graph-parser.config :as gp-config]
             [logseq.graph-parser.util.block-ref :as block-ref]
             [medley.core :as medley]
@@ -18,46 +19,16 @@
             [reitit.frontend.easy :as rfe]
             [rum.core :as rum]))
 
-(def HLS-PREFIX "hls__")
-
-(def HLS-PREFIX-DISPLAY "📒")
-
-(def HLS-PREFIX-LEN (count HLS-PREFIX))
-
-(def HLS-PREFIX-PATTERN (re-pattern (str "^" HLS-PREFIX)))
-
-(defn make-hls
-  [name]
-  (str HLS-PREFIX name))
-
-(defn hls-page?
-  [title]
-  (and title (string? title) (string/starts-with? title HLS-PREFIX)))
+(defn hls-file?
+  [filename]
+  (and filename (string? filename) (string/starts-with? filename "hls__")))
 
 (defn inflate-asset
-  [full-path]
-  (let [filename (util/node-path.basename full-path)
-        web-link? (string/starts-with? full-path "http")
+  [original-path]
+  (let [filename (util/node-path.basename original-path)
+        web-link? (string/starts-with? original-path "http")
         ext-name (util/get-file-ext filename)
-        url (cond
-              web-link?
-              full-path
-
-              (util/absolute-path? full-path)
-              (str "file://" full-path)
-
-              (string/starts-with? full-path "file:/")
-              full-path
-
-              :else
-              (let [full-path (string/replace full-path #"^[.\/\\]+" "")
-                    full-path (if-not (string/starts-with? full-path gp-config/local-assets-dir)
-                                (util/node-path.join gp-config/local-assets-dir full-path)
-                                full-path)]
-                (str "file://"  ;; TODO: bfs
-                     (util/node-path.join
-                       (config/get-repo-dir (state/get-current-repo))
-                       full-path))))]
+        url (assets-handler/normalize-asset-resource-url original-path)]
     (when-let [key
                (if web-link?
                  (str (hash url))
@@ -68,7 +39,14 @@
        :identity (subs key (- (count key) 15))
        :filename filename
        :url      url
-       :hls-file (str "assets/" key ".edn")})))
+       :hls-file (str "assets/" key ".edn")
+       :original-path original-path})))
+
+(defn resolve-area-image-file
+  [img-stamp current {:keys [page id] :as _hl}]
+  (when-let [key (:key current)]
+    (-> (str gp-config/local-assets-dir "/" key "/")
+        (str (util/format "%s_%s_%s.png" page id img-stamp)))))
 
 (defn load-hls-data$
   [{:keys [hls-file]}]
@@ -144,12 +122,14 @@
           (.toBlob canvas' callback))
         ))))
 
-(defn update-hl-area-block!
+(defn update-hl-block!
   [highlight]
-  (when-let [block (and (area-highlight? highlight)
-                        (db-model/get-block-by-uuid (:id highlight)))]
-    (editor-handler/set-block-property!
-      (:block/uuid block) :hl-stamp (get-in highlight [:content :image]))))
+  (when-let [block (db-model/get-block-by-uuid (:id highlight))]
+    (doseq [[k v] {:hl-stamp (if (area-highlight? highlight)
+                               (get-in highlight [:content :image])
+                               (js/Date.now))
+                   :hl-color (get-in highlight [:properties :color])}]
+      (editor-handler/set-block-property! (:block/uuid block) k v))))
 
 (defn unlink-hl-area-image$
   [^js _viewer current hl]
@@ -167,15 +147,15 @@
   [pdf-current]
   (let [page-name (:key pdf-current)
         page-name (string/trim page-name)
-        page-name (make-hls page-name)
-        page (db-model/get-page page-name)
-        url (:url pdf-current)
-        format (state/get-preferred-format)
-        repo-dir (config/get-repo-dir (state/get-current-repo))
+        page-name (str "hls__" page-name)
+        page      (db-model/get-page page-name)
+        file-path (:original-path pdf-current)
+        format    (state/get-preferred-format)
+        repo-dir  (config/get-repo-dir (state/get-current-repo))
         asset-dir (util/node-path.join repo-dir gp-config/local-assets-dir)
-        url (if (string/includes? url asset-dir)
-              (str ".." (last (string/split url repo-dir)))
-              url)]
+        url       (if (string/includes? file-path asset-dir)
+                    (str ".." (last (string/split file-path repo-dir)))
+                    file-path)]
     (if-not page
       (let [label (:filename pdf-current)]
         (page-handler/create! page-name {:redirect?        false :create-first-block? false
@@ -196,26 +176,28 @@
       (page-property/add-property! page-name :file-path url))
     page))
 
-(defn create-ref-block!
-  [{:keys [id content page]}]
-  (when-let [pdf-current (:pdf/current @state/state)]
-    (when-let [ref-page (resolve-ref-page pdf-current)]
-      (if-let [ref-block (db-model/get-block-by-uuid id)]
-        (do
-          (js/console.debug "[existed ref block]" ref-block)
-          ref-block)
-        (let [text (:text content)
-              wrap-props #(if-let [stamp (:image content)]
-                            (assoc % :hl-type "area" :hl-stamp stamp) %)]
-
-          (editor-handler/api-insert-new-block!
-            text {:page        (:block/name ref-page)
-                  :custom-uuid id
-                  :properties  (wrap-props
-                                 {:ls-type "annotation"
-                                  :hl-page page
-                                  ;; force custom uuid
-                                  :id      (str id)})}))))))
+(defn ensure-ref-block!
+  ([pdf hl] (ensure-ref-block! pdf hl nil))
+  ([pdf-current {:keys [id content page properties]} insert-opts]
+   (when-let [ref-page (and pdf-current (resolve-ref-page pdf-current))]
+     (if-let [ref-block (db-model/query-block-by-uuid id)]
+       (do
+         (println "[existed ref block]" ref-block)
+         ref-block)
+       (let [text       (:text content)
+             wrap-props #(if-let [stamp (:image content)]
+                           (assoc % :hl-type "area" :hl-stamp stamp) %)]
+
+         (editor-handler/api-insert-new-block!
+          text (merge {:page        (:block/name ref-page)
+                       :custom-uuid id
+                       :properties  (wrap-props
+                                     {:ls-type  "annotation"
+                                      :hl-page  page
+                                      :hl-color (:color properties)
+                                      ;; force custom uuid
+                                      :id       (str id)})}
+                      insert-opts)))))))
 
 (defn del-ref-block!
   [{:keys [id]}]
@@ -226,7 +208,7 @@
 
 (defn copy-hl-ref!
   [highlight]
-  (when-let [ref-block (create-ref-block! highlight)]
+  (when-let [ref-block (ensure-ref-block! (state/get-current-pdf) highlight)]
     (util/copy-to-clipboard! (block-ref/->block-ref (:block/uuid ref-block)))))
 
 (defn open-block-ref!
@@ -235,10 +217,10 @@
         page (db-utils/pull (:db/id (:block/page block)))
         page-name (:block/original-name page)
         file-path (:file-path (:block/properties page))]
-    (when-let [target-key (and page-name (subs page-name HLS-PREFIX-LEN))]
+    (when-let [target-key (and page-name (subs page-name 5))]
       (p/let [hls (resolve-hls-data-by-key$ target-key)
               hls (and hls (:highlights hls))]
-        (let [file-path (if file-path file-path (str target-key ".pdf"))]
+        (let [file-path (or file-path (str "../assets/" target-key ".pdf"))]
           (if-let [matched (and hls (medley/find-first #(= id (:id %)) hls))]
             (do
               (state/set-state! :pdf/ref-highlight matched)
@@ -247,47 +229,44 @@
             (js/console.debug "[Unmatched highlight ref]" block)))))))
 
 (defn goto-block-ref!
-  [{:keys [id]}]
+  [{:keys [id] :as hl}]
   (when id
+    (ensure-ref-block!
+     (state/get-current-pdf) hl {:edit-block? false})
     (rfe/push-state :page {:name (str id)})))
 
 (defn goto-annotations-page!
   ([current] (goto-annotations-page! current nil))
   ([current id]
    (when-let [name (:key current)]
-     (rfe/push-state :page {:name (make-hls name)} (if id {:anchor (str "block-content-" + id)} nil)))))
+     (rfe/push-state :page {:name (str "hls__" name)} (if id {:anchor (str "block-content-" + id)} nil)))))
 
 (rum/defc area-display
-  [block stamp]
-  (let [id (:block/uuid block)
-        props (:block/properties block)]
-    (when-let [page (db-utils/pull (:db/id (:block/page block)))]
-      (when-let [group-key (string/replace-first (:block/original-name page) HLS-PREFIX-PATTERN "")]
-        (when-let [hl-page (:hl-page props)]
-          (let [encoded-chars? (boolean (re-find gp-util/url-encoded-pattern group-key))
-                group-key (if encoded-chars? (js/encodeURI group-key) group-key)
-                asset-path (editor-handler/make-asset-url
-                             (str "/" gp-config/local-assets-dir "/" group-key "/" (str hl-page "_" id "_" stamp ".png")))]
-            [:span.hl-area
-             [:img {:src asset-path}]]))))))
+  [block]
+  (when-let [asset-path' (and block (pdf-utils/get-area-block-asset-url
+                                     block (db-utils/pull (:db/id (:block/page block)))))]
+    (let [asset-path     (editor-handler/make-asset-url asset-path')]
+      [:span.hl-area
+       [:img {:src asset-path}]])))
 
 (defn fix-local-asset-pagename
-  [title]
-  (when-not (string/blank? title)
-    (let [local-asset? (re-find #"[0-9]{13}_\d$" title)]
-      (if local-asset?
-        (-> title
-            (subs 0 (- (count title) 15))
-            (string/replace HLS-PREFIX-PATTERN HLS-PREFIX-DISPLAY)
+  [filename]
+  (when-not (string/blank? filename)
+    (let [local-asset? (re-find #"[0-9]{13}_\d$" filename)
+          hls?         (re-find #"^hls__" filename)
+          len          (count filename)]
+      (if (or local-asset? hls?)
+        (-> filename
+            (subs 0 (if local-asset? (- len 15) len))
+            (string/replace #"^hls__" "")
             (string/replace "_" " ")
             (string/trimr))
-        (-> title
-            (string/replace HLS-PREFIX-PATTERN HLS-PREFIX-DISPLAY)
-            (gp-util/safe-url-decode)) ;; In case user import URI pdf resource like #6167
-        ))))
+        filename))))
+
+(defn human-page-name
+  [page-name]
+  (cond
+    (string/starts-with? page-name "hls__")
+    (fix-local-asset-pagename page-name)
 
-(rum/defc human-hls-pagename-display
-  "Ensure it's a hls page by `hls-page?` before hand"
-  [title]
-  [:a.asset-ref
-   (fix-local-asset-pagename title)])
+    :else (util/trim-safe page-name)))

+ 1066 - 0
src/main/frontend/extensions/pdf/finder.js

@@ -0,0 +1,1066 @@
+// Fork from https://github.com/mozilla/pdf.js
+
+import { binarySearchFirstItem, getCharacterType, getPdfjsLib } from './utils'
+import { scrollIntoView } from 'codemirror/src/display/scrolling'
+
+const FindState = {
+  FOUND: 0, NOT_FOUND: 1, WRAPPED: 2, PENDING: 3,
+}
+
+const FIND_TIMEOUT = 250 // ms
+const MATCH_SCROLL_OFFSET_TOP = -50 // px
+const MATCH_SCROLL_OFFSET_LEFT = -400 // px
+
+const CHARACTERS_TO_NORMALIZE = {
+  '\u2010': '-', // Hyphen
+  '\u2018': '\'', // Left single quotation mark
+  '\u2019': '\'', // Right single quotation mark
+  '\u201A': '\'', // Single low-9 quotation mark
+  '\u201B': '\'', // Single high-reversed-9 quotation mark
+  '\u201C': '"', // Left double quotation mark
+  '\u201D': '"', // Right double quotation mark
+  '\u201E': '"', // Double low-9 quotation mark
+  '\u201F': '"', // Double high-reversed-9 quotation mark
+  '\u00BC': '1/4', // Vulgar fraction one quarter
+  '\u00BD': '1/2', // Vulgar fraction one half
+  '\u00BE': '3/4', // Vulgar fraction three quarters
+}
+
+// These diacritics aren't considered as combining diacritics
+// when searching in a document:
+//   https://searchfox.org/mozilla-central/source/intl/unicharutil/util/is_combining_diacritic.py.
+// The combining class definitions can be found:
+//   https://www.unicode.org/reports/tr44/#Canonical_Combining_Class_Values
+// Category 0 corresponds to [^\p{Mn}].
+const DIACRITICS_EXCEPTION = new Set([// UNICODE_COMBINING_CLASS_KANA_VOICING
+  // https://www.compart.com/fr/unicode/combining/8
+  0x3099, 0x309a, // UNICODE_COMBINING_CLASS_VIRAMA (under 0xFFFF)
+  // https://www.compart.com/fr/unicode/combining/9
+  0x094d, 0x09cd, 0x0a4d, 0x0acd, 0x0b4d, 0x0bcd, 0x0c4d, 0x0ccd, 0x0d3b, 0x0d3c, 0x0d4d, 0x0dca, 0x0e3a, 0x0eba,
+  0x0f84, 0x1039, 0x103a, 0x1714, 0x1734, 0x17d2, 0x1a60, 0x1b44, 0x1baa, 0x1bab, 0x1bf2, 0x1bf3, 0x2d7f, 0xa806,
+  0xa82c, 0xa8c4, 0xa953, 0xa9c0, 0xaaf6, 0xabed, // 91
+  // https://www.compart.com/fr/unicode/combining/91
+  0x0c56, // 129
+  // https://www.compart.com/fr/unicode/combining/129
+  0x0f71, // 130
+  // https://www.compart.com/fr/unicode/combining/130
+  0x0f72, 0x0f7a, 0x0f7b, 0x0f7c, 0x0f7d, 0x0f80, // 132
+  // https://www.compart.com/fr/unicode/combining/132
+  0x0f74,])
+const DIACRITICS_EXCEPTION_STR = [...DIACRITICS_EXCEPTION.values()]
+  .map(x => String.fromCharCode(x))
+  .join('')
+
+const DIACRITICS_REG_EXP = /\p{M}+/gu
+const SPECIAL_CHARS_REG_EXP = /([.*+?^${}()|[\]\\])|(\p{P})|(\s+)|(\p{M})|(\p{L})/gu
+const NOT_DIACRITIC_FROM_END_REG_EXP = /([^\p{M}])\p{M}*$/u
+const NOT_DIACRITIC_FROM_START_REG_EXP = /^\p{M}*([^\p{M}])/u
+
+let normalizationRegex = null
+
+function normalize (text) {
+  // The diacritics in the text or in the query can be composed or not.
+  // So we use a decomposed text using NFD (and the same for the query)
+  // in order to be sure that diacritics are in the same order.
+
+  if (!normalizationRegex) {
+    // Compile the regular expression for text normalization once.
+    const replace = Object.keys(CHARACTERS_TO_NORMALIZE).join('')
+    normalizationRegex = new RegExp(`([${replace}])|(\\p{M}+(?:-\\n)?)|(\\S-\\n)|(\\n)`, 'gum')
+  }
+
+  // The goal of this function is to normalize the string and
+  // be able to get from an index in the new string the
+  // corresponding index in the old string.
+  // For example if we have: abCd12ef456gh where C is replaced by ccc
+  // and numbers replaced by nothing (it's the case for diacritics), then
+  // we'll obtain the normalized string: abcccdefgh.
+  // So here the reverse map is: [0,1,2,2,2,3,6,7,11,12].
+
+  // The goal is to obtain the array: [[0, 0], [3, -1], [4, -2],
+  // [6, 0], [8, 3]].
+  // which can be used like this:
+  //  - let say that i is the index in new string and j the index
+  //    the old string.
+  //  - if i is in [0; 3[ then j = i + 0
+  //  - if i is in [3; 4[ then j = i - 1
+  //  - if i is in [4; 6[ then j = i - 2
+  //  ...
+  // Thanks to a binary search it's easy to know where is i and what's the
+  // shift.
+  // Let say that the last entry in the array is [x, s] and we have a
+  // substitution at index y (old string) which will replace o chars by n chars.
+  // Firstly, if o === n, then no need to add a new entry: the shift is
+  // the same.
+  // Secondly, if o < n, then we push the n - o elements:
+  // [y - (s - 1), s - 1], [y - (s - 2), s - 2], ...
+  // Thirdly, if o > n, then we push the element: [y - (s - n), o + s - n]
+
+  // Collect diacritics length and positions.
+  const rawDiacriticsPositions = []
+  let m
+  while ((m = DIACRITICS_REG_EXP.exec(text)) !== null) {
+    rawDiacriticsPositions.push([m[0].length, m.index])
+  }
+
+  let normalized = text.normalize('NFD')
+  const positions = [[0, 0]]
+  let k = 0
+  let shift = 0
+  let shiftOrigin = 0
+  let eol = 0
+  let hasDiacritics = false
+
+  normalized = normalized.replace(normalizationRegex, (match, p1, p2, p3, p4, i) => {
+    i -= shiftOrigin
+    if (p1) {
+      // Maybe fractions or quotations mark...
+      const replacement = CHARACTERS_TO_NORMALIZE[match]
+      const jj = replacement.length
+      for (let j = 1; j < jj; j++) {
+        positions.push([i - shift + j, shift - j])
+      }
+      shift -= jj - 1
+      return replacement
+    }
+
+    if (p2) {
+      const hasTrailingDashEOL = p2.endsWith('\n')
+      const len = hasTrailingDashEOL ? p2.length - 2 : p2.length
+
+      // Diacritics.
+      hasDiacritics = true
+      let jj = len
+      if (i + eol === rawDiacriticsPositions[k]?.[1]) {
+        jj -= rawDiacriticsPositions[k][0]
+        ++k
+      }
+
+      for (let j = 1; j < jj + 1; j++) {
+        // i is the position of the first diacritic
+        // so (i - 1) is the position for the letter before.
+        positions.push([i - 1 - shift + j, shift - j])
+      }
+      shift -= jj
+      shiftOrigin += jj
+
+      if (hasTrailingDashEOL) {
+        // Diacritics are followed by a -\n.
+        // See comments in `if (p3)` block.
+        i += len - 1
+        positions.push([i - shift + 1, 1 + shift])
+        shift += 1
+        shiftOrigin += 1
+        eol += 1
+        return p2.slice(0, len)
+      }
+
+      return p2
+    }
+
+    if (p3) {
+      // "X-\n" is removed because an hyphen at the end of a line
+      // with not a space before is likely here to mark a break
+      // in a word.
+      // The \n isn't in the original text so here y = i, n = 1 and o = 2.
+      positions.push([i - shift + 1, 1 + shift])
+      shift += 1
+      shiftOrigin += 1
+      eol += 1
+      return p3.charAt(0)
+    }
+
+    // p4
+    // eol is replaced by space: "foo\nbar" is likely equivalent to
+    // "foo bar".
+    positions.push([i - shift + 1, shift - 1])
+    shift -= 1
+    shiftOrigin += 1
+    eol += 1
+    return ' '
+  })
+
+  positions.push([normalized.length, shift])
+
+  return [normalized, positions, hasDiacritics]
+}
+
+// Determine the original, non-normalized, match index such that highlighting of
+// search results is correct in the `textLayer` for strings containing e.g. "½"
+// characters; essentially "inverting" the result of the `normalize` function.
+function getOriginalIndex (diffs, pos, len) {
+  if (!diffs) {
+    return [pos, len]
+  }
+
+  const start = pos
+  const end = pos + len
+  let i = binarySearchFirstItem(diffs, x => x[0] >= start)
+  if (diffs[i][0] > start) {
+    --i
+  }
+
+  let j = binarySearchFirstItem(diffs, x => x[0] >= end, i)
+  if (diffs[j][0] > end) {
+    --j
+  }
+
+  return [start + diffs[i][1], len + diffs[j][1] - diffs[i][1]]
+}
+
+/**
+ * @typedef {Object} PDFFindControllerOptions
+ * @property {IPDFLinkService} linkService - The navigation/linking service.
+ * @property {EventBus} eventBus - The application event bus.
+ */
+
+/**
+ * Provides search functionality to find a given string in a PDF document.
+ */
+export class PDFFindController {
+
+  /**
+   * @param {PDFFindControllerOptions} options
+   */
+  constructor ({ linkService, eventBus }) {
+    this._linkService = linkService
+    this._eventBus = eventBus
+
+    this.__reset()
+    eventBus._on('find', this.__onFind.bind(this))
+    eventBus._on('findbarclose', this.__onFindBarClose.bind(this))
+  }
+
+  get highlightMatches () {
+    return this._highlightMatches
+  }
+
+  get pageMatches () {
+    return this._pageMatches
+  }
+
+  get pageMatchesLength () {
+    return this._pageMatchesLength
+  }
+
+  get selected () {
+    return this._selected
+  }
+
+  get state () {
+    return this._state
+  }
+
+  /**
+   * Set a reference to the PDF document in order to search it.
+   * Note that searching is not possible if this method is not called.
+   *
+   * @param {PDFDocumentProxy} pdfDocument - The PDF document to search.
+   */
+  setDocument (pdfDocument) {
+    if (this._pdfDocument) {
+      this.__reset()
+    }
+    if (!pdfDocument) {
+      return
+    }
+    this._pdfDocument = pdfDocument
+    this._firstPageCapability.resolve()
+  }
+
+  __onFind (state) {
+    if (!state) {
+      return
+    }
+    const pdfDocument = this._pdfDocument
+    const { type } = state
+
+    if (this._state === null || this.__shouldDirtyMatch(state)) {
+      this._dirtyMatch = true
+    }
+    this._state = state
+    if (type !== 'highlightallchange') {
+      this.__updateUIState(FindState.PENDING)
+    }
+
+    this._firstPageCapability.promise.then(() => {
+      // If the document was closed before searching began, or if the search
+      // operation was relevant for a previously opened document, do nothing.
+      if (!this._pdfDocument || (pdfDocument && this._pdfDocument !== pdfDocument)) {
+        return
+      }
+      this.__extractText()
+
+      const findbarClosed = !this._highlightMatches
+      const pendingTimeout = !!this._findTimeout
+
+      if (this._findTimeout) {
+        clearTimeout(this._findTimeout)
+        this._findTimeout = null
+      }
+      if (!type) {
+        // Trigger the find action with a small delay to avoid starting the
+        // search when the user is still typing (saving resources).
+        this._findTimeout = setTimeout(() => {
+          this.__nextMatch()
+          this._findTimeout = null
+        }, FIND_TIMEOUT)
+      } else if (this._dirtyMatch) {
+        // Immediately trigger searching for non-'find' operations, when the
+        // current state needs to be reset and matches re-calculated.
+        this.__nextMatch()
+      } else if (type === 'again') {
+        this.__nextMatch()
+
+        // When the findbar was previously closed, and `highlightAll` is set,
+        // ensure that the matches on all active pages are highlighted again.
+        if (findbarClosed && this._state.highlightAll) {
+          this.__updateAllPages()
+        }
+      } else if (type === 'highlightallchange') {
+        // If there was a pending search operation, synchronously trigger a new
+        // search *first* to ensure that the correct matches are highlighted.
+        if (pendingTimeout) {
+          this.__nextMatch()
+        } else {
+          this._highlightMatches = true
+        }
+        this.__updateAllPages() // Update the highlighting on all active pages.
+      } else {
+        this.__nextMatch()
+      }
+    })
+  }
+
+  scrollMatchIntoView ({
+    element = null, selectedLeft = 0, pageIndex = -1, matchIndex = -1,
+  }) {
+    if (!this._scrollMatches || !element) {
+      return
+    } else if (matchIndex === -1 || matchIndex !== this._selected.matchIdx) {
+      return
+    } else if (pageIndex === -1 || pageIndex !== this._selected.pageIdx) {
+      return
+    }
+    this._scrollMatches = false // Ensure that scrolling only happens once.
+
+    const spot = {
+      top: MATCH_SCROLL_OFFSET_TOP, left: selectedLeft + MATCH_SCROLL_OFFSET_LEFT,
+    }
+    scrollIntoView(element, spot, /* scrollMatches = */ true)
+  }
+
+  __reset () {
+    this._highlightMatches = false
+    this._scrollMatches = false
+    this._pdfDocument = null
+    this._pageMatches = []
+    this._pageMatchesLength = []
+    this._state = null
+    // Currently selected match.
+    this._selected = {
+      pageIdx: -1, matchIdx: -1,
+    }
+    // Where the find algorithm currently is in the document.
+    this._offset = {
+      pageIdx: null, matchIdx: null, wrapped: false,
+    }
+    this._extractTextPromises = []
+    this._pageContents = [] // Stores the normalized text for each page.
+    this._pageDiffs = []
+    this._hasDiacritics = []
+    this._matchesCountTotal = 0
+    this._pagesToSearch = null
+    this._pendingFindMatches = new Set()
+    this._resumePageIdx = null
+    this._dirtyMatch = false
+    clearTimeout(this._findTimeout)
+    this._findTimeout = null
+
+    this._firstPageCapability = getPdfjsLib().createPromiseCapability()
+  }
+
+  /**
+   * @type {string} The (current) normalized search query.
+   */
+  get __query () {
+    if (this._state.query !== this._rawQuery) {
+      this._rawQuery = this._state.query;
+      [this._normalizedQuery] = normalize(this._state.query)
+    }
+    return this._normalizedQuery
+  }
+
+  __shouldDirtyMatch (state) {
+    // When the search query changes, regardless of the actual search command
+    // used, always re-calculate matches to avoid errors (fixes bug 1030622).
+    if (state.query !== this._state.query) {
+      return true
+    }
+    switch (state.type) {
+      case 'again':
+        const pageNumber = this._selected.pageIdx + 1
+        const linkService = this._linkService
+        // Only treat a 'findagain' event as a new search operation when it's
+        // *absolutely* certain that the currently selected match is no longer
+        // visible, e.g. as a result of the user scrolling in the document.
+        //
+        // NOTE: If only a simple `this._linkService.page` check was used here,
+        // there's a risk that consecutive 'findagain' operations could "skip"
+        // over matches at the top/bottom of pages thus making them completely
+        // inaccessible when there's multiple pages visible in the viewer.
+        if (pageNumber >= 1 && pageNumber <= linkService.pagesCount && pageNumber !== linkService.page && !linkService.isPageVisible(pageNumber)) {
+          return true
+        }
+        return false
+      case 'highlightallchange':
+        return false
+    }
+    return true
+  }
+
+  /**
+   * Determine if the search query constitutes a "whole word", by comparing the
+   * first/last character type with the preceding/following character type.
+   */
+  __isEntireWord (content, startIdx, length) {
+    let match = content
+      .slice(0, startIdx)
+      .match(NOT_DIACRITIC_FROM_END_REG_EXP)
+    if (match) {
+      const first = content.charCodeAt(startIdx)
+      const limit = match[1].charCodeAt(0)
+      if (getCharacterType(first) === getCharacterType(limit)) {
+        return false
+      }
+    }
+
+    match = content
+      .slice(startIdx + length)
+      .match(NOT_DIACRITIC_FROM_START_REG_EXP)
+    if (match) {
+      const last = content.charCodeAt(startIdx + length - 1)
+      const limit = match[1].charCodeAt(0)
+      if (getCharacterType(last) === getCharacterType(limit)) {
+        return false
+      }
+    }
+
+    return true
+  }
+
+  __calculateRegExpMatch (query, entireWord, pageIndex, pageContent) {
+    const matches = [], matchesLength = []
+
+    const diffs = this._pageDiffs[pageIndex]
+    let match
+    while ((match = query.exec(pageContent)) !== null) {
+      if (entireWord && !this.__isEntireWord(pageContent, match.index, match[0].length)) {
+        continue
+      }
+
+      const [matchPos, matchLen] = getOriginalIndex(diffs, match.index, match[0].length)
+
+      if (matchLen) {
+        matches.push(matchPos)
+        matchesLength.push(matchLen)
+      }
+    }
+    this._pageMatches[pageIndex] = matches
+    this._pageMatchesLength[pageIndex] = matchesLength
+  }
+
+  __convertToRegExpString (query, hasDiacritics) {
+    const { matchDiacritics } = this._state
+    let isUnicode = false
+    query = query.replace(SPECIAL_CHARS_REG_EXP, (match, p1 /* to escape */, p2 /* punctuation */, p3 /* whitespaces */, p4 /* diacritics */, p5 /* letters */) => {
+      // We don't need to use a \s for whitespaces since all the different
+      // kind of whitespaces are replaced by a single " ".
+
+      if (p1) {
+        // Escape characters like *+?... to not interfer with regexp syntax.
+        return `[ ]*\\${p1}[ ]*`
+      }
+      if (p2) {
+        // Allow whitespaces around punctuation signs.
+        return `[ ]*${p2}[ ]*`
+      }
+      if (p3) {
+        // Replace spaces by \s+ to be sure to match any spaces.
+        return '[ ]+'
+      }
+      if (matchDiacritics) {
+        return p4 || p5
+      }
+
+      if (p4) {
+        // Diacritics are removed with few exceptions.
+        return DIACRITICS_EXCEPTION.has(p4.charCodeAt(0)) ? p4 : ''
+      }
+
+      // A letter has been matched and it can be followed by any diacritics
+      // in normalized text.
+      if (hasDiacritics) {
+        isUnicode = true
+        return `${p5}\\p{M}*`
+      }
+      return p5
+    })
+
+    const trailingSpaces = '[ ]*'
+    if (query.endsWith(trailingSpaces)) {
+      // The [ ]* has been added in order to help to match "foo . bar" but
+      // it doesn't make sense to match some whitespaces after the dot
+      // when it's the last character.
+      query = query.slice(0, query.length - trailingSpaces.length)
+    }
+
+    if (matchDiacritics) {
+      // aX must not match aXY.
+      if (hasDiacritics) {
+        isUnicode = true
+        query = `${query}(?=[${DIACRITICS_EXCEPTION_STR}]|[^\\p{M}]|$)`
+      }
+    }
+
+    return [isUnicode, query]
+  }
+
+  __calculateMatch (pageIndex) {
+    let query = this.__query
+    if (query.length === 0) {
+      // Do nothing: the matches should be wiped out already.
+      return
+    }
+
+    const { caseSensitive, entireWord, phraseSearch } = this._state
+    const pageContent = this._pageContents[pageIndex]
+    const hasDiacritics = this._hasDiacritics[pageIndex]
+
+    let isUnicode = false
+    if (phraseSearch) {
+      [isUnicode, query] = this.__convertToRegExpString(query, hasDiacritics)
+    } else {
+      // Words are sorted in reverse order to be sure that "foobar" is matched
+      // before "foo" in case the query is "foobar foo".
+      const match = query.match(/\S+/g)
+      if (match) {
+        query = match
+          .sort()
+          .reverse()
+          .map(q => {
+            const [isUnicodePart, queryPart] = this.__convertToRegExpString(q, hasDiacritics)
+            isUnicode ||= isUnicodePart
+            return `(${queryPart})`
+          })
+          .join('|')
+      }
+    }
+
+    const flags = `g${isUnicode ? 'u' : ''}${caseSensitive ? '' : 'i'}`
+    query = new RegExp(query, flags)
+
+    this.__calculateRegExpMatch(query, entireWord, pageIndex, pageContent)
+
+    // When `highlightAll` is set, ensure that the matches on previously
+    // rendered (and still active) pages are correctly highlighted.
+    if (this._state.highlightAll) {
+      this.__updatePage(pageIndex)
+    }
+    if (this._resumePageIdx === pageIndex) {
+      this._resumePageIdx = null
+      this.__nextPageMatch()
+    }
+
+    // Update the match count.
+    const pageMatchesCount = this._pageMatches[pageIndex].length
+    if (pageMatchesCount > 0) {
+      this._matchesCountTotal += pageMatchesCount
+      this.__updateUIResultsCount()
+    }
+  }
+
+  __extractText () {
+    // Perform text extraction once if this method is called multiple times.
+    if (this._extractTextPromises.length > 0) {
+      return
+    }
+
+    let promise = Promise.resolve()
+    for (let i = 0, ii = this._linkService.pagesCount; i < ii; i++) {
+      const extractTextCapability = createPromiseCapability()
+      this._extractTextPromises[i] = extractTextCapability.promise
+
+      promise = promise.then(() => {
+        return this._pdfDocument
+          .getPage(i + 1)
+          .then(pdfPage => {
+            return pdfPage.getTextContent()
+          })
+          .then(textContent => {
+            const strBuf = []
+
+            for (const textItem of textContent.items) {
+              strBuf.push(textItem.str)
+              if (textItem.hasEOL) {
+                strBuf.push('\n')
+              }
+            }
+
+            // Store the normalized page content (text items) as one string.
+            [this._pageContents[i], this._pageDiffs[i], this._hasDiacritics[i],] = normalize(strBuf.join(''))
+            extractTextCapability.resolve()
+          }, reason => {
+            console.error(`Unable to get text content for page ${i + 1}`, reason)
+            // Page error -- assuming no text content.
+            this._pageContents[i] = ''
+            this._pageDiffs[i] = null
+            this._hasDiacritics[i] = false
+            extractTextCapability.resolve()
+          })
+      })
+    }
+  }
+
+  __updatePage (index) {
+    if (this._scrollMatches && this._selected.pageIdx === index) {
+      // If the page is selected, scroll the page into view, which triggers
+      // rendering the page, which adds the text layer. Once the text layer
+      // is built, it will attempt to scroll the selected match into view.
+      this._linkService.page = index + 1
+    }
+
+    this._eventBus.dispatch('updatetextlayermatches', {
+      source: this, pageIndex: index,
+    })
+  }
+
+  __updateAllPages () {
+    this._eventBus.dispatch('updatetextlayermatches', {
+      source: this, pageIndex: -1,
+    })
+  }
+
+  __nextMatch () {
+    const previous = this._state.findPrevious
+    const currentPageIndex = this._linkService.page - 1
+    const numPages = this._linkService.pagesCount
+
+    this._highlightMatches = true
+
+    if (this._dirtyMatch) {
+      // Need to recalculate the matches, reset everything.
+      this._dirtyMatch = false
+      this._selected.pageIdx = this._selected.matchIdx = -1
+      this._offset.pageIdx = currentPageIndex
+      this._offset.matchIdx = null
+      this._offset.wrapped = false
+      this._resumePageIdx = null
+      this._pageMatches.length = 0
+      this._pageMatchesLength.length = 0
+      this._matchesCountTotal = 0
+
+      this.__updateAllPages() // Wipe out any previously highlighted matches.
+
+      for (let i = 0; i < numPages; i++) {
+        // Start finding the matches as soon as the text is extracted.
+        if (this._pendingFindMatches.has(i)) {
+          continue
+        }
+        this._pendingFindMatches.add(i)
+        this._extractTextPromises[i].then(() => {
+          this._pendingFindMatches.delete(i)
+          this.__calculateMatch(i)
+        })
+      }
+    }
+
+    // If there's no query there's no point in searching.
+    if (this.__query === '') {
+      this.__updateUIState(FindState.FOUND)
+      return
+    }
+    // If we're waiting on a page, we return since we can't do anything else.
+    if (this._resumePageIdx) {
+      return
+    }
+
+    const offset = this._offset
+    // Keep track of how many pages we should maximally iterate through.
+    this._pagesToSearch = numPages
+    // If there's already a `matchIdx` that means we are iterating through a
+    // page's matches.
+    if (offset.matchIdx !== null) {
+      const numPageMatches = this._pageMatches[offset.pageIdx].length
+      if ((!previous && offset.matchIdx + 1 < numPageMatches) || (previous && offset.matchIdx > 0)) {
+        // The simple case; we just have advance the matchIdx to select
+        // the next match on the page.
+        offset.matchIdx = previous ? offset.matchIdx - 1 : offset.matchIdx + 1
+        this.__updateMatch(/* found = */ true)
+        return
+      }
+      // We went beyond the current page's matches, so we advance to
+      // the next page.
+      this.__advanceOffsetPage(previous)
+    }
+    // Start searching through the page.
+    this.__nextPageMatch()
+  }
+
+  __matchesReady (matches) {
+    const offset = this._offset
+    const numMatches = matches.length
+    const previous = this._state.findPrevious
+
+    if (numMatches) {
+      // There were matches for the page, so initialize `matchIdx`.
+      offset.matchIdx = previous ? numMatches - 1 : 0
+      this.__updateMatch(/* found = */ true)
+      return true
+    }
+    // No matches, so attempt to search the next page.
+    this.__advanceOffsetPage(previous)
+    if (offset.wrapped) {
+      offset.matchIdx = null
+      if (this._pagesToSearch < 0) {
+        // No point in wrapping again, there were no matches.
+        this.__updateMatch(/* found = */ false)
+        // While matches were not found, searching for a page
+        // with matches should nevertheless halt.
+        return true
+      }
+    }
+    // Matches were not found (and searching is not done).
+    return false
+  }
+
+  __nextPageMatch () {
+    if (this._resumePageIdx !== null) {
+      console.error('There can only be one pending page.')
+    }
+
+    let matches = null
+    do {
+      const pageIdx = this._offset.pageIdx
+      matches = this._pageMatches[pageIdx]
+      if (!matches) {
+        // The matches don't exist yet for processing by `_matchesReady`,
+        // so set a resume point for when they do exist.
+        this._resumePageIdx = pageIdx
+        break
+      }
+    } while (!this.__matchesReady(matches))
+  }
+
+  __advanceOffsetPage (previous) {
+    const offset = this._offset
+    const numPages = this._linkService.pagesCount
+    offset.pageIdx = previous ? offset.pageIdx - 1 : offset.pageIdx + 1
+    offset.matchIdx = null
+
+    this._pagesToSearch--
+
+    if (offset.pageIdx >= numPages || offset.pageIdx < 0) {
+      offset.pageIdx = previous ? numPages - 1 : 0
+      offset.wrapped = true
+    }
+  }
+
+  __updateMatch (found = false) {
+    let state = FindState.NOT_FOUND
+    const wrapped = this._offset.wrapped
+    this._offset.wrapped = false
+
+    if (found) {
+      const previousPage = this._selected.pageIdx
+      this._selected.pageIdx = this._offset.pageIdx
+      this._selected.matchIdx = this._offset.matchIdx
+      state = wrapped ? FindState.WRAPPED : FindState.FOUND
+
+      // Update the currently selected page to wipe out any selected matches.
+      if (previousPage !== -1 && previousPage !== this._selected.pageIdx) {
+        this.__updatePage(previousPage)
+      }
+    }
+
+    this.__updateUIState(state, this._state.findPrevious)
+    if (this._selected.pageIdx !== -1) {
+      // Ensure that the match will be scrolled into view.
+      this._scrollMatches = true
+
+      this.__updatePage(this._selected.pageIdx)
+    }
+  }
+
+  __onFindBarClose (evt) {
+    const pdfDocument = this._pdfDocument
+    // Since searching is asynchronous, ensure that the removal of highlighted
+    // matches (from the UI) is async too such that the 'updatetextlayermatches'
+    // events will always be dispatched in the expected order.
+    this._firstPageCapability.promise.then(() => {
+      // Only update the UI if the document is open, and is the current one.
+      if (!this._pdfDocument || (pdfDocument && this._pdfDocument !== pdfDocument)) {
+        return
+      }
+      // Ensure that a pending, not yet started, search operation is aborted.
+      if (this._findTimeout) {
+        clearTimeout(this._findTimeout)
+        this._findTimeout = null
+      }
+      // Abort any long running searches, to avoid a match being scrolled into
+      // view *after* the findbar has been closed. In this case `this._offset`
+      // will most likely differ from `this._selected`, hence we also ensure
+      // that any new search operation will always start with a clean slate.
+      if (this._resumePageIdx) {
+        this._resumePageIdx = null
+        this._dirtyMatch = true
+      }
+      // Avoid the UI being in a pending state when the findbar is re-opened.
+      this.__updateUIState(FindState.FOUND)
+
+      this._highlightMatches = false
+      this.__updateAllPages() // Wipe out any previously highlighted matches.
+    })
+  }
+
+  __requestMatchesCount () {
+    const { pageIdx, matchIdx } = this._selected
+    let current = 0, total = this._matchesCountTotal
+    if (matchIdx !== -1) {
+      for (let i = 0; i < pageIdx; i++) {
+        current += this._pageMatches[i]?.length || 0
+      }
+      current += matchIdx + 1
+    }
+    // When searching starts, this method may be called before the `pageMatches`
+    // have been counted (in `_calculateMatch`). Ensure that the UI won't show
+    // temporarily broken state when the active find result doesn't make sense.
+    if (current < 1 || current > total) {
+      current = total = 0
+    }
+    return { current, total }
+  }
+
+  __updateUIResultsCount () {
+    this._eventBus.dispatch('updatefindmatchescount', {
+      source: this, matchesCount: this.__requestMatchesCount(),
+    })
+  }
+
+  __updateUIState (state, previous = false) {
+    this._eventBus.dispatch('updatefindcontrolstate', {
+      source: this, state, previous, matchesCount: this.__requestMatchesCount(), rawQuery: this._state?.query ?? null,
+    })
+  }
+}
+
+const MATCHES_COUNT_LIMIT = 1000
+
+/**
+ * Creates a "search bar" given a set of DOM elements that act as controls
+ * for searching or for setting search preferences in the UI. This object
+ * also sets up the appropriate events for the controls. Actual searching
+ * is done by PDFFindController.
+ */
+export class PDFFindBar {
+  constructor (options, eventBus, l10n) {
+    this.opened = false
+
+    this.bar = options.bar
+    this.toggleButton = options.toggleButton
+    this.findField = options.findField
+    this.highlightAll = options.highlightAllCheckbox
+    this.caseSensitive = options.caseSensitiveCheckbox
+    this.matchDiacritics = options.matchDiacriticsCheckbox
+    this.entireWord = options.entireWordCheckbox
+    this.findMsg = options.findMsg
+    this.findResultsCount = options.findResultsCount
+    this.findPreviousButton = options.findPreviousButton
+    this.findNextButton = options.findNextButton
+    this.eventBus = eventBus
+    this.l10n = l10n
+
+    // Add event listeners to the DOM elements.
+    this.toggleButton.addEventListener('click', () => {
+      this.toggle()
+    })
+
+    this.findField.addEventListener('input', () => {
+      this.dispatchEvent('')
+    })
+
+    this.bar.addEventListener('keydown', e => {
+      switch (e.keyCode) {
+        case 13: // Enter
+          if (e.target === this.findField) {
+            this.dispatchEvent('again', e.shiftKey)
+          }
+          break
+        case 27: // Escape
+          this.close()
+          break
+      }
+    })
+
+    this.findPreviousButton.addEventListener('click', () => {
+      this.dispatchEvent('again', true)
+    })
+
+    this.findNextButton.addEventListener('click', () => {
+      this.dispatchEvent('again', false)
+    })
+
+    this.highlightAll.addEventListener('click', () => {
+      this.dispatchEvent('highlightallchange')
+    })
+
+    this.caseSensitive.addEventListener('click', () => {
+      this.dispatchEvent('casesensitivitychange')
+    })
+
+    this.entireWord.addEventListener('click', () => {
+      this.dispatchEvent('entirewordchange')
+    })
+
+    this.matchDiacritics.addEventListener('click', () => {
+      this.dispatchEvent('diacriticmatchingchange')
+    })
+
+    this.eventBus._on('resize', this.__adjustWidth.bind(this))
+  }
+
+  reset () {
+    this.updateUIState()
+  }
+
+  dispatchEvent (type, findPrev = false) {
+    this.eventBus.dispatch('find', {
+      source: this,
+      type,
+      query: this.findField.value,
+      phraseSearch: true,
+      caseSensitive: this.caseSensitive.checked,
+      entireWord: this.entireWord.checked,
+      highlightAll: this.highlightAll.checked,
+      findPrevious: findPrev,
+      matchDiacritics: this.matchDiacritics.checked,
+    })
+  }
+
+  updateUIState (state, previous, matchesCount) {
+    let findMsg = Promise.resolve('')
+    let status = ''
+
+    switch (state) {
+      case FindState.FOUND:
+        break
+      case FindState.PENDING:
+        status = 'pending'
+        break
+      case FindState.NOT_FOUND:
+        findMsg = this.l10n.get('find_not_found')
+        status = 'notFound'
+        break
+      case FindState.WRAPPED:
+        findMsg = this.l10n.get(`find_reached_${previous ? 'top' : 'bottom'}`)
+        break
+    }
+    this.findField.setAttribute('data-status', status)
+    this.findField.setAttribute('aria-invalid', state === FindState.NOT_FOUND)
+
+    findMsg.then(msg => {
+      this.findMsg.textContent = msg
+      this.__adjustWidth()
+    })
+
+    this.updateResultsCount(matchesCount)
+  }
+
+  updateResultsCount ({ current = 0, total = 0 } = {}) {
+    const limit = MATCHES_COUNT_LIMIT
+    let matchCountMsg = Promise.resolve('')
+
+    if (total > 0) {
+      if (total > limit) {
+        let key = 'find_match_count_limit'
+
+        if (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) {
+          // TODO: Remove this hard-coded `[other]` form once plural support has
+          // been implemented in the mozilla-central specific `l10n.js` file.
+          key += '[other]'
+        }
+        matchCountMsg = this.l10n.get(key, { limit })
+      } else {
+        let key = 'find_match_count'
+
+        if (typeof PDFJSDev !== 'undefined' && PDFJSDev.test('MOZCENTRAL')) {
+          // TODO: Remove this hard-coded `[other]` form once plural support has
+          // been implemented in the mozilla-central specific `l10n.js` file.
+          key += '[other]'
+        }
+        matchCountMsg = this.l10n.get(key, { current, total })
+      }
+    }
+    matchCountMsg.then(msg => {
+      this.findResultsCount.textContent = msg
+      // Since `updateResultsCount` may be called from `PDFFindController`,
+      // ensure that the width of the findbar is always updated correctly.
+      this.__adjustWidth()
+    })
+  }
+
+  open () {
+    if (!this.opened) {
+      this.opened = true
+      this.toggleButton.classList.add('toggled')
+      this.toggleButton.setAttribute('aria-expanded', 'true')
+      this.bar.classList.remove('hidden')
+    }
+    this.findField.select()
+    this.findField.focus()
+
+    this.__adjustWidth()
+  }
+
+  close () {
+    if (!this.opened) {
+      return
+    }
+    this.opened = false
+    this.toggleButton.classList.remove('toggled')
+    this.toggleButton.setAttribute('aria-expanded', 'false')
+    this.bar.classList.add('hidden')
+
+    this.eventBus.dispatch('findbarclose', { source: this })
+  }
+
+  toggle () {
+    if (this.opened) {
+      this.close()
+    } else {
+      this.open()
+    }
+  }
+
+  __adjustWidth () {
+    if (!this.opened) {
+      return
+    }
+
+    // The find bar has an absolute position and thus the browser extends
+    // its width to the maximum possible width once the find bar does not fit
+    // entirely within the window anymore (and its elements are automatically
+    // wrapped). Here we detect and fix that.
+    this.bar.classList.remove('wrapContainers')
+
+    const findbarHeight = this.bar.clientHeight
+    const inputContainerHeight = this.bar.firstElementChild.clientHeight
+
+    if (findbarHeight > inputContainerHeight) {
+      // The findbar is taller than the input container, which means that
+      // the browser wrapped some of the elements. For a consistent look,
+      // wrap all of them to adjust the width of the find bar.
+      this.bar.classList.add('wrapContainers')
+    }
+  }
+}

File diff ditekan karena terlalu besar
+ 453 - 433
src/main/frontend/extensions/pdf/highlights.cljs


+ 320 - 38
src/main/frontend/extensions/pdf/pdf.css

@@ -1,11 +1,13 @@
 @import "_viewer.css";
 
 :root {
-  --ph-highlight-color-blue: var(--color-blue-100);
-  --ph-highlight-color-green: var(--color-green-100);
-  --ph-highlight-color-red: var(--color-red-100);
-  --ph-highlight-color-purple: var(--color-purple-100);
-  --ph-highlight-color-yellow: var(--color-yellow-100);
+  --ph-highlight-color-blue: var(--color-blue-300);
+  --ph-highlight-color-green: var(--color-green-300);
+  --ph-highlight-color-red: var(--color-red-300);
+  --ph-highlight-color-purple: var(--color-purple-300);
+  --ph-highlight-color-yellow: var(--color-yellow-300);
+
+  --ph-link-color: #106ba3;
 
   --ph-highlight-scroll-into-color: rgba(255, 75, 93, 0.67);
 
@@ -23,6 +25,31 @@ input::-webkit-inner-spin-button {
 
   &-container {
     display: flex;
+
+    *[data-color=yellow] {
+      background-color: var(--ph-highlight-color-yellow);
+      border-color: var(--ph-highlight-color-yellow);
+    }
+
+    *[data-color=blue] {
+      background-color: var(--ph-highlight-color-blue);
+      border-color: var(--ph-highlight-color-blue);
+    }
+
+    *[data-color=green] {
+      background-color: var(--ph-highlight-color-green);
+      border-color: var(--ph-highlight-color-green);
+    }
+
+    *[data-color=red] {
+      background-color: var(--ph-highlight-color-red);
+      border-color: var(--ph-highlight-color-red);
+    }
+
+    *[data-color=purple] {
+      background-color: var(--ph-highlight-color-purple);
+      border-color: var(--ph-highlight-color-purple);
+    }
   }
 
   &-loader {
@@ -158,11 +185,11 @@ input::-webkit-inner-spin-button {
           }
 
           &:hover {
-            color: #106ba3;
+            color: var(--ph-link-color);
           }
 
           &:active, &:focus {
-            background-color: #106ba3;
+            background-color: var(--ph-link-color);
             color: white;
 
             > i {
@@ -178,6 +205,58 @@ input::-webkit-inner-spin-button {
         padding-left: 12px;
       }
     }
+
+    &-tabs {
+      @apply flex justify-center py-2.5 space-x-1;
+
+      border-bottom: 1px solid #d9d9d9;
+
+      > .inner {
+        display: flex;
+        border: 1px solid #d5d5d5;
+        border-radius: 4px;
+      }
+
+      button {
+        font-size: 12px;
+        padding: 5px 6px;
+        line-height: 1;
+        border: none;
+        min-width: 90px;
+        border-radius: 3px;
+        color: black;
+
+        &:hover {
+          border: none;
+          opacity: .8;
+          color: black;
+          border-radius: 3px;
+        }
+
+        &:active {
+          opacity: 1;
+          background-color: #c5c5c5;
+        }
+
+        &.active {
+          background-color: #c5c5c5;
+        }
+      }
+    }
+
+    &-panels {
+      max-height: 80vh;
+      overflow-y: auto;
+      padding: 5px 0 5px 10px;
+    }
+
+    &-wrap.hls-popup-overlay {
+      right: -6px;
+      left: unset;
+      bottom: unset;
+      height: auto;
+      width: auto;
+    }
   }
 
   &-settings {
@@ -190,6 +269,7 @@ input::-webkit-inner-spin-button {
 
     &-item {
       display: flex;
+      color: rgb(115, 115, 115);
 
       &.theme-picker {
         justify-content: center;
@@ -233,6 +313,84 @@ input::-webkit-inner-spin-button {
         margin-top: 13px;
         opacity: 0.8;
         border-top: 1px solid #ccc;
+
+        &.is-between {
+          padding-top: 0;
+          border-top: none;
+        }
+      }
+    }
+  }
+
+  &-finder {
+    &-wrap.hls-popup-overlay {
+      right: -6px;
+      left: unset;
+      bottom: unset;
+      height: auto;
+      width: auto;
+    }
+
+    &.hls-popup-box {
+      min-width: 360px;
+      width: auto;
+
+      > .input-inner {
+        @apply p-2 relative;
+
+        .input-wrap {
+          @apply mr-1.5 border border-gray-300;
+          border-radius: 4px;
+
+          &:active, &:focus-within {
+            border-color: transparent;
+          }
+        }
+
+        input {
+          @apply flex-1 bg-gray-200;
+
+          padding: 1px 5px;
+          border-radius: 4px;
+          color: black;
+          outline: none;
+          border: 2px solid transparent;
+
+          &:focus {
+            border: 2px solid rgba(16, 107, 163, 0.75);
+          }
+        }
+
+        .ui__button {
+          margin-top: unset;
+          border-radius: 4px;
+          color: black;
+          padding: 4px;
+
+          .ti {
+            font-weight: bold;
+            color: #989898;
+          }
+
+          &.active .ti, &:hover .ti {
+            color: #1f1f1f !important;
+          }
+
+          &.icon-enter {
+            @apply absolute opacity-80;
+
+            right: -1px;
+            top: 2px;
+          }
+        }
+
+        > .ui__button {
+          margin-left: 8px;
+        }
+      }
+
+      > .result-inner {
+        @apply text-gray-800;
       }
     }
   }
@@ -282,31 +440,6 @@ input::-webkit-inner-spin-button {
     mix-blend-mode: multiply;
     touch-action: none;
     border-style: dashed;
-
-    &[data-color=yellow] {
-      background-color: var(--ph-highlight-color-yellow);
-      border-color: var(--ph-highlight-color-yellow);
-    }
-
-    &[data-color=blue] {
-      background-color: var(--ph-highlight-color-blue);
-      border-color: var(--ph-highlight-color-blue);
-    }
-
-    &[data-color=green] {
-      background-color: var(--ph-highlight-color-green);
-      border-color: var(--ph-highlight-color-green);
-    }
-
-    &[data-color=red] {
-      background-color: var(--ph-highlight-color-red);
-      border-color: var(--ph-highlight-color-red);
-    }
-
-    &[data-color=purple] {
-      background-color: var(--ph-highlight-color-purple);
-      border-color: var(--ph-highlight-color-purple);
-    }
   }
 
   &-viewer.is-area-dashed {
@@ -416,11 +549,97 @@ input::-webkit-inner-spin-button {
       mix-blend-mode: multiply;
     }
   }
+
+  &-highlights-list-item {
+    @apply active:opacity-100 mr-2;
+
+    user-select: none;
+    font-size: 12px;
+    padding-top: 8px;
+    padding-bottom: 2px;
+    padding-left: 6px;
+    border-radius: 4px;
+    margin-bottom: 3px;
+
+    &:first-child {
+      margin-top: 5px;
+    }
+
+    > h6 {
+      @apply flex items-center justify-between pr-2 relative;
+
+      font-size: 10px;
+      line-height: 1em;
+      color: #696969;
+      -webkit-font-smoothing: antialiased;
+
+      small {
+        width: 8px;
+        height: 8px;
+        border-radius: 100%;
+        margin-right: 6px;
+        border-color: #cccccc !important;
+      }
+
+      button {
+        @apply absolute right-0 top-[-6px] hover:opacity-80 active:opacity-100 hidden;
+
+        padding-right: 8px;
+        padding-top: 4px;
+
+        .ti {
+          font-size: 14px;
+        }
+      }
+    }
+
+    > p {
+      color: #646464;
+      padding-top: 6px;
+      padding-bottom: 2px;
+      margin: 0;
+
+      &.text-wrap {
+        display: -webkit-box;
+        -webkit-line-clamp: 3;
+        -webkit-box-orient: vertical;
+        overflow: hidden;
+        line-height: 1.24em;
+      }
+
+      &.area-wrap {
+        display: flex;
+        align-items: center;
+        padding-right: 6px;
+      }
+
+      &:hover {
+        color: var(--ph-link-color);
+      }
+    }
+
+    &.active {
+      background-color: var(--ph-link-color);
+
+      > h6, > p {
+        color: white;
+
+        button {
+          @apply inline;
+        }
+      }
+    }
+
+    &:hover:not(.active) {
+      opacity: .9;
+    }
+  }
 }
 
 .hls-text-region-item {
   cursor: pointer;
   position: absolute;
+  z-index: 2;
   transition: background 0.3s;
 
   background-color: rgba(252, 219, 97, 0.7);
@@ -448,7 +667,7 @@ input::-webkit-inner-spin-button {
 }
 
 .hls-popup {
-  &-wrap {
+  &-overlay {
     position: absolute;
     top: 40px;
     right: 0;
@@ -468,7 +687,7 @@ input::-webkit-inner-spin-button {
     border-radius: 4px;
     width: 320px;
     overflow-y: auto;
-    background-color: #e9e9e9;
+    background-color: #e3e7e8;
     outline: none;
     box-shadow: 0 2px 4px 0 rgba(134, 134, 134, 0.59);
 
@@ -576,11 +795,12 @@ input::-webkit-inner-spin-button {
 
 .block-content {
   &[data-type=annotation] {
-    a.prefix-link {
+    .prefix-link {
       display: inline-flex;
       align-items: center;
       padding-right: 4px;
       cursor: alias;
+      color: var(--ls-link-ref-text-color);
 
       &:before {
         content: "📌 ";
@@ -592,7 +812,7 @@ input::-webkit-inner-spin-button {
       margin-bottom: 10px;
       flex-direction: column;
 
-      a.prefix-link {
+      .prefix-link {
         display: inline;
       }
 
@@ -609,13 +829,63 @@ input::-webkit-inner-spin-button {
     }
 
     .hl-area {
-      display: block;
+      @apply relative;
+
+      display: inline-block;
       cursor: text;
+      border: 1px solid #eee;
+      border-radius: 4px;
+      overflow: hidden;
+      margin-top: 4px;
 
       img {
         margin: 0;
         box-shadow: none;
-        max-width: 80%;
+        max-width: 100%;
+        max-height: 420px;
+        cursor: alias;
+      }
+    }
+  }
+}
+
+.ls-hl-colored .block-content {
+  &[data-hl-color=green] {
+    .prefix-link {
+      &:before {
+        content: "🟢 ";
+      }
+    }
+  }
+
+  &[data-hl-color=purple] {
+    .prefix-link {
+      &:before {
+        content: "🟣 ";
+      }
+    }
+  }
+
+  &[data-hl-color=blue] {
+    .prefix-link {
+      &:before {
+        content: "🔵 ";
+      }
+    }
+  }
+
+  &[data-hl-color=yellow] {
+    .prefix-link {
+      &:before {
+        content: "🟡 ";
+      }
+    }
+  }
+
+  &[data-hl-color=red] {
+    .prefix-link {
+      &:before {
+        content: "🔴 ";
       }
     }
   }
@@ -713,3 +983,15 @@ body.is-pdf-active {
   mix-blend-mode: multiply;
   color: unset;
 }
+
+.textLayer .highlight {
+  background-color: rgb(206 255 162);
+  border: 1px solid transparent;
+  border-radius: 0;
+  padding: 0 2px;
+
+  &.selected {
+    background-color: rgb(206 255 162);
+    border: 2px dashed #ff3434;
+  }
+}

+ 546 - 0
src/main/frontend/extensions/pdf/toolbar.cljs

@@ -0,0 +1,546 @@
+(ns frontend.extensions.pdf.toolbar
+  (:require [cljs-bean.core :as bean]
+            [clojure.string :as string]
+            [frontend.context.i18n :refer [t]]
+            [rum.core :as rum]
+            [promesa.core :as p]
+            [frontend.rum :refer [use-atom]]
+            [frontend.state :as state]
+            [frontend.util :as util]
+            [frontend.storage :as storage]
+            [frontend.ui :as ui]
+            [frontend.components.svg :as svg]
+            [frontend.extensions.pdf.assets :as pdf-assets]
+            [frontend.handler.editor :as editor-handler]
+            [frontend.extensions.pdf.utils :as pdf-utils]
+            [frontend.handler.notification :as notification]))
+
+(declare make-docinfo-in-modal)
+
+(def *area-dashed? (atom ((fnil identity false) (storage/get (str "ls-pdf-area-is-dashed")))))
+(def *area-mode? (atom false))
+(def *highlight-mode? (atom false))
+(rum/defcontext *highlights-ctx* )
+
+(rum/defc pdf-settings
+  [^js viewer theme {:keys [hide-settings! select-theme! t]}]
+
+  (let [*el-popup (rum/use-ref nil)
+        [area-dashed? set-area-dashed?] (use-atom *area-dashed?)
+        [hl-block-colored? set-hl-block-colored?] (rum/use-state (state/sub :pdf/block-highlight-colored?))]
+
+    (rum/use-effect!
+     (fn []
+       (let [el-popup (rum/deref *el-popup)
+             cb       (fn [^js e]
+                        (and (= (.-which e) 27) (hide-settings!)))]
+
+         (js/setTimeout #(.focus el-popup))
+         (.addEventListener el-popup "keyup" cb)
+         #(.removeEventListener el-popup "keyup" cb)))
+     [])
+
+    (rum/use-effect!
+     (fn []
+       (storage/set "ls-pdf-area-is-dashed" (boolean area-dashed?)))
+     [area-dashed?])
+
+    (rum/use-effect!
+     (fn []
+       (let [b (boolean hl-block-colored?)]
+         (state/set-state! :pdf/block-highlight-colored? b)
+         (storage/set "ls-pdf-hl-block-is-colored" b)))
+     [hl-block-colored?])
+
+    [:div.extensions__pdf-settings.hls-popup-overlay.visible
+     {:on-click (fn [^js/MouseEvent e]
+                  (let [target (.-target e)]
+                    (when-not (.contains (rum/deref *el-popup) target)
+                      (hide-settings!))))}
+
+     [:div.extensions__pdf-settings-inner.hls-popup-box
+      {:ref       *el-popup
+       :tab-index -1}
+
+      [:div.extensions__pdf-settings-item.theme-picker
+       (map (fn [it]
+              [:button.flex.items-center.justify-center
+               {:key it :class it :on-click #(select-theme! it)}
+               (when (= theme it) (svg/check))])
+            ["light", "warm", "dark"])]
+
+      [:div.extensions__pdf-settings-item.toggle-input
+       [:label (t :pdf/toggle-dashed)]
+       (ui/toggle area-dashed? #(set-area-dashed? (not area-dashed?)) true)]
+
+      [:div.extensions__pdf-settings-item.toggle-input.is-between
+       [:label (t :pdf/hl-block-colored)]
+       (ui/toggle hl-block-colored? #(set-hl-block-colored? (not hl-block-colored?)) true)]
+
+      [:div.extensions__pdf-settings-item.toggle-input
+       [:a.is-info.w-full.text-gray-500
+        {:title    (t :pdf/doc-metadata)
+         :on-click #(p/let [ret (pdf-utils/get-meta-data$ viewer)]
+                      (state/set-modal! (make-docinfo-in-modal ret)))}
+
+        [:span.flex.items-center.justify-between.w-full
+         (t :pdf/doc-metadata)
+         (svg/icon-info)]]]
+      ]]))
+
+(rum/defc docinfo-display
+  [info close-fn!]
+  [:div#pdf-docinfo.extensions__pdf-doc-info
+   [:div.inner-text
+    (for [[k v] info
+          :let [k (str (string/replace-first (pr-str k) #"^\:" "") "::")]]
+      [:p {:key k} [:strong k] "  " [:i (pr-str v)]])]
+
+   [:div.flex.items-center.justify-center.pt-2.pb--2
+    (ui/button "Copy all"
+               :on-click
+               (fn []
+                 (let [text (.-innerText (js/document.querySelector "#pdf-docinfo > .inner-text"))
+                       text (string/replace text #"[\n\t]+" "\n")]
+                   (util/copy-to-clipboard! text)
+                   (notification/show! "Copied!" :success)
+                   (close-fn!))))]])
+
+(defn make-docinfo-in-modal
+  [info]
+  (fn [close-fn!]
+    (docinfo-display info close-fn!)))
+
+(defonce find-status
+  {0 ::found
+   1 ::not-found
+   2 ::wrapped
+   3 ::pending})
+
+(rum/defc ^:large-vars/data-var pdf-finder
+  [^js viewer {:keys [hide-finder!]}]
+
+  (let [*el-finder    (rum/use-ref nil)
+        *el-input     (rum/use-ref nil)
+        ^js bus       (.-eventBus viewer)
+        [case-sensitive?, set-case-sensitive?] (rum/use-state nil)
+        [input, set-input!] (rum/use-state "")
+        [matches, set-matches!] (rum/use-state {:current 0 :total 0})
+        [find-state, set-find-state!] (rum/use-state {:status nil :current 0 :total 0 :query ""})
+        [entered-active0?, set-entered-active0?] (rum/use-state false)
+        [entered-active?, set-entered-active?] (rum/use-state false)
+
+        reset-finder! (fn []
+                        (.dispatch bus "findbarclose" nil)
+                        (set-matches! nil)
+                        (set-find-state! nil)
+                        (set-entered-active? false)
+                        (set-entered-active0? false))
+
+        close-finder! (fn []
+                        (reset-finder!)
+                        (hide-finder!))
+
+        do-find!      (fn [{:keys [type prev?] :as opts}]
+                        (when-let [type (if (keyword? opts) opts type)]
+                          (.dispatch bus "find"
+                                     #js {:source          nil
+                                          :type            (name type)
+                                          :query           input
+                                          :phraseSearch    true
+                                          :caseSensitive   case-sensitive?
+                                          :highlightAll    true
+                                          :findPrevious    prev?
+                                          :matchDiacritics false})))]
+
+    (rum/use-effect!
+     (fn []
+       (when-let [^js el-viewer (and viewer (js/document.querySelector "#pdf-layout-container"))]
+         (let [handler (fn [^js e]
+                         (when-let [^js target (and (string/blank? (.-value (rum/deref *el-input)))
+                                                    (.-target e))]
+                           (when (and (not= "Search" (.-title target))
+                                      (not (.contains (rum/deref *el-finder) target)))
+                             (close-finder!))))]
+           (.addEventListener el-viewer "click" handler)
+           #(.removeEventListener el-viewer "click" handler))))
+     [viewer])
+
+    (rum/use-effect!
+     (fn []
+       (when-let [^js bus (.-eventBus viewer)]
+         (.on bus "updatefindmatchescount" (fn [^js e]
+                                             (let [matches (bean/->clj (.-matchesCount e))]
+                                               (set-matches! matches)
+                                               (set-find-state! (fn [s] (merge s matches))))))
+         (.on bus "updatefindcontrolstate" (fn [^js e]
+                                             (set-find-state!
+                                              (merge
+                                               {:status (get find-status (.-state e))
+                                                :query  (.-rawQuery e)}
+                                               (bean/->clj (.-matchesCount e))))))))
+     [viewer])
+
+    (rum/use-effect!
+     (fn []
+       (when-not (nil? case-sensitive?)
+         (do-find! :casesensitivitychange)))
+     [case-sensitive?])
+
+    [:div.extensions__pdf-finder-wrap.hls-popup-overlay.visible
+     {:on-click #()}
+
+     [:div.extensions__pdf-finder.hls-popup-box
+      {:ref       *el-finder
+       :tab-index -1}
+
+      [:div.input-inner.flex.items-center
+       [:div.input-wrap.relative
+        [:input
+         {:placeholder "search"
+          :type        "text"
+          :ref         *el-input
+          :auto-focus  true
+          :value       input
+          :on-change   (fn [^js e]
+                         (let [val (.-value (.-target e))]
+                           (set-input! val)
+                           (set-entered-active0? (not (string/blank? (util/trim-safe val))))
+                           (set-entered-active? false)))
+
+          :on-key-up   (fn [^js e]
+                         (case (.-which e)
+                           13                               ;; enter
+                           (do
+                             (do-find! :again)
+                             (set-entered-active? true))
+
+                           27                               ;; esc
+                           (if (string/blank? input)
+                             (close-finder!)
+                             (do
+                               (reset-finder!)
+                               (set-input! "")))
+
+                           :dune))}]
+
+        (when entered-active0?
+          (ui/button (ui/icon "arrow-back") :title "Enter to search" :class "icon-enter" :intent "true" :small? true))]
+
+       (ui/button (ui/icon "letter-case")
+                  :class (string/join " " (util/classnames [{:active case-sensitive?}]))
+                  :intent "true" :small? true :on-click #(set-case-sensitive? (not case-sensitive?)))
+       (ui/button (ui/icon "chevron-up") :intent "true" :small? true :on-click #(do (do-find! {:type :again :prev? true}) (util/stop %)))
+       (ui/button (ui/icon "chevron-down") :intent "true" :small? true :on-click #(do (do-find! {:type :again}) (util/stop %)))
+       (ui/button (ui/icon "x") :intent "true" :small? true :on-click close-finder!)]
+
+      [:div.result-inner
+       (when-let [status (and entered-active?
+                              (not (string/blank? input))
+                              (:status find-state))]
+         (if-not (= ::not-found status)
+           [:div.flex.px-3.py-3.text-xs.opacity-90
+            (apply max (map :current [find-state matches])) " of "
+            (:total find-state)
+            (str " matches (\"" (:query find-state) "\")")]
+           [:div.px-3.py-3.text-xs.opacity-80.text-red-600 "Not found."])
+         )
+       ]]]))
+
+(rum/defc pdf-outline-item
+  [^js viewer
+   {:keys [title items parent dest expanded]}
+   {:keys [upt-outline-node!] :as ops}]
+  (let [has-child? (seq items)
+        expanded?  (boolean expanded)]
+
+    [:div.extensions__pdf-outline-item
+     {:class (util/classnames [{:has-children has-child? :is-expand expanded?}])}
+     [:div.inner
+      [:a
+       {:data-dest (js/JSON.stringify (bean/->js dest))
+        :on-click  (fn [^js/MouseEvent e]
+                     (let [target (.-target e)]
+                       (if (.closest target "i")
+                         (let [path (map #(if (re-find #"\d+" %) (int %) (keyword %))
+                                         (string/split parent #"\-"))]
+                           (.preventDefault e)
+                           (upt-outline-node! path {:expanded (not expanded?)}))
+                         (when-let [^js dest (and dest (bean/->js dest))]
+                           (.goToDestination (.-linkService viewer) dest)))))}
+
+       [:i.arrow svg/arrow-right-v2]
+       [:span title]]]
+
+     ;; children
+     (when (and has-child? expanded?)
+       [:div.children
+        (map-indexed
+         (fn [idx itm]
+           (let [parent (str parent "-items-" idx)]
+             (rum/with-key
+              (pdf-outline-item
+               viewer
+               (merge itm {:parent parent})
+               ops) parent))) items)])]))
+
+(rum/defc pdf-outline
+  [^js viewer _visible? set-visible!]
+  (when-let [^js pdf-doc (and viewer (.-pdfDocument viewer))]
+    (let [*el-outline       (rum/use-ref nil)
+          [outline-data, set-outline-data!] (rum/use-state [])
+          upt-outline-node! (rum/use-callback
+                             (fn [path attrs]
+                               (set-outline-data! (update-in outline-data path merge attrs)))
+                             [outline-data])]
+
+      (rum/use-effect!
+       (fn []
+         (p/catch
+          (p/let [^js data (.getOutline pdf-doc)]
+            #_:clj-kondo/ignore
+            (when-let [data (and data (.map data (fn [^js it]
+                                                   (set! (.-href it) (.. viewer -linkService (getDestinationHash (.-dest it))))
+                                                   (set! (.-expanded it) false)
+                                                   it)))])
+            (set-outline-data! (bean/->clj data)))
+
+          (fn [e]
+            (js/console.error "[Load outline Error]" e))))
+       [pdf-doc])
+
+      (rum/use-effect!
+       (fn []
+         (let [el-outline (rum/deref *el-outline)
+               cb         (fn [^js e]
+                            (and (= (.-which e) 27) (set-visible! false)))]
+
+           (js/setTimeout #(.focus el-outline))
+           (.addEventListener el-outline "keyup" cb)
+           #(.removeEventListener el-outline "keyup" cb)))
+       [])
+
+      [:div.extensions__pdf-outline-list-content
+       {:ref       *el-outline
+        :tab-index -1}
+       (if (seq outline-data)
+         [:section
+          (map-indexed (fn [idx itm]
+                         (rum/with-key
+                          (pdf-outline-item
+                           viewer
+                           (merge itm {:parent idx})
+                           {:upt-outline-node! upt-outline-node!})
+                          idx))
+                       outline-data)]
+         [:section.is-empty "No outlines"])])))
+
+(rum/defc pdf-highlights-list
+  [^js viewer]
+
+  (let [[active, set-active!] (rum/use-state false)]
+    (rum/with-context
+     [hls-state *highlights-ctx*]
+     (let [hls (sort-by :page (or (seq (:initial-hls hls-state))
+                                  (:latest-hls hls-state)))]
+
+       (for [{:keys [id content properties page] :as hl} hls
+             :let [goto-ref! #(pdf-assets/goto-block-ref! hl)]]
+         [:div.extensions__pdf-highlights-list-item
+          {:key      id
+           :class (when (= active id) "active")
+           :on-click (fn []
+                       (pdf-utils/scroll-to-highlight viewer hl)
+                       (set-active! id))
+           :on-double-click goto-ref!}
+          [:h6.flex
+           [:span.flex.items-center
+            [:small {:data-color (:color properties)}]
+            [:strong "Page " page]]
+
+           [:button
+            {:title (t :pdf/linked-ref)
+             :on-click goto-ref!}
+            (ui/icon "external-link")]]
+
+
+          (if-let [img-stamp (:image content)]
+            (let [fpath (pdf-assets/resolve-area-image-file
+                         img-stamp (state/get-current-pdf) hl)
+                  fpath (editor-handler/make-asset-url fpath)]
+              [:p.area-wrap
+               [:img {:src fpath}]])
+            [:p.text-wrap (:text content)])])))))
+
+(rum/defc pdf-outline-&-highlights
+  [^js viewer visible? set-visible!]
+  (let [*el-container (rum/create-ref)
+        [active-tab, set-active-tab!] (rum/use-state "contents")
+        set-outline-visible! #(set-active-tab! "contents")
+        contents? (= active-tab "contents")]
+
+    (rum/use-effect!
+     (fn []
+       (when-let [^js el-viewer (and viewer (js/document.querySelector "#pdf-layout-container"))]
+         (let [handler (fn [^js e]
+                         (when-let [^js target (.-target e)]
+                           (when (and
+                                  (not= "Outline" (.-title target))
+                                  (not (.contains (rum/deref *el-container) target)))
+                             (set-visible! false)
+                             (set-outline-visible!))))]
+           (.addEventListener el-viewer "click" handler)
+           #(.removeEventListener el-viewer "click" handler))))
+     [viewer *el-container])
+
+    [:div.extensions__pdf-outline-wrap.hls-popup-overlay
+     {:class    (util/classnames [{:visible visible?}])}
+
+     [:div.extensions__pdf-outline.hls-popup-box
+      {:ref       *el-container
+       :tab-index -1}
+
+      [:div.extensions__pdf-outline-tabs
+       [:div.inner
+        [:button {:class (when contents? "active")
+                  :on-click #(set-active-tab! "contents")} "Contents"]
+        [:button {:class (when-not contents? "active")
+                  :on-click #(set-active-tab! "highlights")} "Highlights"]]]
+
+      [:div.extensions__pdf-outline-panels
+       (if contents?
+         (pdf-outline viewer contents? set-outline-visible!)
+         (pdf-highlights-list viewer))]]]))
+
+(rum/defc ^:large-vars/cleanup-todo pdf-toolbar
+  [^js viewer]
+  (let [[area-mode?, set-area-mode!] (use-atom *area-mode?)
+        [outline-visible?, set-outline-visible!] (rum/use-state false)
+        [finder-visible?, set-finder-visible!] (rum/use-state false)
+        [highlight-mode?, set-highlight-mode!] (use-atom *highlight-mode?)
+        [settings-visible?, set-settings-visible!] (rum/use-state false)
+        *page-ref (rum/use-ref nil)
+        [current-page-num, set-current-page-num!] (rum/use-state 1)
+        [total-page-num, set-total-page-num!] (rum/use-state 1)
+        [viewer-theme, set-viewer-theme!] (rum/use-state (or (storage/get "ls-pdf-viewer-theme") "light"))]
+
+    ;; themes hooks
+    (rum/use-effect!
+     (fn []
+       (when-let [^js el (js/document.getElementById "pdf-layout-container")]
+         (set! (. (. el -dataset) -theme) viewer-theme)
+         (storage/set "ls-pdf-viewer-theme" viewer-theme)
+         #(js-delete (. el -dataset) "theme")))
+     [viewer-theme])
+
+    ;; pager hooks
+    (rum/use-effect!
+     (fn []
+       (when-let [total (and viewer (.-numPages (.-pdfDocument viewer)))]
+         (let [^js bus (.-eventBus viewer)
+               page-fn (fn [^js evt]
+                         (let [num (.-pageNumber evt)]
+                           (set-current-page-num! num)))]
+
+           (set-total-page-num! total)
+           (set-current-page-num! (.-currentPageNumber viewer))
+           (.on bus "pagechanging" page-fn)
+           #(.off bus "pagechanging" page-fn))))
+     [viewer])
+
+    (rum/use-effect!
+     (fn []
+       (let [^js input (rum/deref *page-ref)]
+         (set! (. input -value) current-page-num)))
+     [current-page-num])
+
+    [:div.extensions__pdf-toolbar
+     [:div.inner
+      [:div.r.flex.buttons
+
+       ;; appearance
+       [:a.button
+        {:title    "More settings"
+         :on-click #(set-settings-visible! (not settings-visible?))}
+        (svg/adjustments 18)]
+
+       ;; selection
+       [:a.button
+        {:title    (str "Area highlight (" (if util/mac? "⌘" "Shift") ")")
+         :class    (when area-mode? "is-active")
+         :on-click #(set-area-mode! (not area-mode?))}
+        (svg/icon-area 18)]
+
+       [:a.button
+        {:title    "Highlight mode"
+         :class    (when highlight-mode? "is-active")
+         :on-click #(set-highlight-mode! (not highlight-mode?))}
+        (svg/highlighter 16)]
+
+       ;; zoom
+       [:a.button
+        {:title    "Zoom out"
+         :on-click (partial pdf-utils/zoom-out-viewer viewer)}
+        (svg/zoom-out 18)]
+
+       [:a.button
+        {:title    "Zoom in"
+         :on-click (partial pdf-utils/zoom-in-viewer viewer)}
+        (svg/zoom-in 18)]
+
+       [:a.button
+        {:title    "Outline"
+         :on-click #(set-outline-visible! (not outline-visible?))}
+        (svg/view-list 16)]
+
+       ;; search
+       [:a.button
+        {:title    "Search"
+         :on-click #(set-finder-visible! (not finder-visible?))}
+        (svg/search2 19)]
+
+       ;; annotations
+       [:a.button
+        {:title    "Annotations page"
+         :on-click #(pdf-assets/goto-annotations-page! (:pdf/current @state/state))}
+        (svg/annotations 16)]
+
+       ;; pager
+       [:div.pager.flex.items-center.ml-1
+
+        [:span.nu.flex.items-center.opacity-70
+         [:input {:ref            *page-ref
+                  :type           "number"
+                  :default-value  current-page-num
+                  :on-mouse-enter #(.select ^js (.-target %))
+                  :on-key-up      (fn [^js e]
+                                    (let [^js input (.-target e)
+                                          value     (util/safe-parse-int (.-value input))]
+                                      (when (and (= (.-keyCode e) 13) value (> value 0))
+                                        (set! (. viewer -currentPageNumber)
+                                              (if (> value total-page-num) total-page-num value)))))}]
+         [:small "/ " total-page-num]]
+
+        [:span.ct.flex.items-center
+         [:a.button {:on-click #(. viewer previousPage)} (svg/up-narrow)]
+         [:a.button {:on-click #(. viewer nextPage)} (svg/down-narrow)]]]
+
+       [:a.button
+        {:on-click #(state/set-state! :pdf/current nil)}
+        (t :close)]]]
+
+     ;; contents outline
+     (pdf-outline-&-highlights viewer outline-visible? set-outline-visible!)
+
+     ;; finder
+     (when finder-visible?
+       (pdf-finder viewer {:hide-finder! #(set-finder-visible! false)}))
+
+     ;; settings
+     (when settings-visible?
+       (pdf-settings
+        viewer
+        viewer-theme
+        {:t              t
+         :hide-settings! #(set-settings-visible! false)
+         :select-theme!  #(set-viewer-theme! %)}))]))

+ 28 - 16
src/main/frontend/extensions/pdf/utils.cljs

@@ -3,14 +3,30 @@
             [cljs-bean.core :as bean]
             [frontend.util :as util]
             ["/frontend/extensions/pdf/utils" :as js-utils]
-            [frontend.db :as db]
-            [frontend.loader :refer [load]]
+            [datascript.core :as d]
+            [logseq.graph-parser.config :as gp-config]
             [clojure.string :as string]))
 
 (defonce MAX-SCALE 5.0)
 (defonce MIN-SCALE 0.25)
 (defonce DELTA_SCALE 1.05)
 
+(defn clean-asset-path-prefix
+  [path]
+  (when (string? path)
+    (string/replace-first path #"^[.\/\\]*(assets)[\/\\]+" "")))
+
+(defn get-area-block-asset-url
+  [block page]
+  (when-some [props (and block page (:block/properties block))]
+    (when-some [uuid (:block/uuid block)]
+      (when-some [stamp (:hl-stamp props)]
+        (let [group-key      (string/replace-first (:block/original-name page) #"^hls__" "")
+              hl-page        (:hl-page props)
+              encoded-chars? (boolean (re-find #"(?i)%[0-9a-f]{2}" group-key))
+              group-key      (if encoded-chars? (js/encodeURI group-key) group-key)]
+          (str "./" gp-config/local-assets-dir "/" group-key "/" (str hl-page "_" uuid "_" stamp ".png")))))))
+
 (defn get-bounding-rect
   [rects]
   (bean/->clj (js-utils/getBoundingRect (bean/->js rects))))
@@ -115,23 +131,12 @@
     (mapv #(if (map? %) % (bean/->clj %)) its)))
 
 (defn gen-uuid []
-  (db/new-block-id))
-
-(defn js-load$
-  [url]
-  (p/create
-    (fn [resolve]
-      (load url resolve))))
-
-(def PDFJS_ROOT
-  (if (= js/location.protocol "file:")
-    "./js"
-    "./static/js"))
+  (d/squuid))
 
 (defn load-base-assets$
   []
-  (p/let [_ (js-load$ (str PDFJS_ROOT "/pdfjs/pdf.js"))
-          _ (js-load$ (str PDFJS_ROOT "/pdfjs/pdf_viewer.js"))]))
+  (p/let [_ (util/js-load$ (str util/JS_ROOT "/pdfjs/pdf.js"))
+          _ (util/js-load$ (str util/JS_ROOT "/pdfjs/pdf_viewer.js"))]))
 
 (defn get-page-from-el
   [^js/HTMLElement el]
@@ -181,6 +186,13 @@
     (js-invoke js/window.lsPdfViewer "previousPage")
     (catch :default _e nil)))
 
+(defn open-finder
+  []
+  (try
+    (when-let [^js el (js/document.querySelector ".extensions__pdf-toolbar a[title=Search]")]
+      (.click el))
+    (catch js/Error _e nil)))
+
 (comment
  (fix-selection-text-breakline "this is a\ntest paragraph")
  (fix-selection-text-breakline "he is 1\n8 years old")

+ 185 - 0
src/main/frontend/extensions/pdf/utils.js

@@ -1,3 +1,10 @@
+/**
+ * @returns {*}
+ */
+export const getPdfjsLib = () => {
+  return window.pdfjsLib
+}
+
 export const viewportToScaled = (
   rect,
   { width, height }
@@ -185,4 +192,182 @@ export const optimizeClientRects = (clientRects) => {
   }
 
   return firstPass.filter(rect => !toRemove.has(rect))
+}
+
+/**
+ * Use binary search to find the index of the first item in a given array which
+ * passes a given condition. The items are expected to be sorted in the sense
+ * that if the condition is true for one item in the array, then it is also true
+ * for all following items.
+ *
+ * @returns {number} Index of the first array element to pass the test,
+ * or |items.length| if no such element exists.
+ */
+export function binarySearchFirstItem (items, condition, start = 0) {
+  let minIndex = start
+  let maxIndex = items.length - 1
+
+  if (maxIndex < 0 || !condition(items[maxIndex])) {
+    return items.length
+  }
+  if (condition(items[minIndex])) {
+    return minIndex
+  }
+
+  while (minIndex < maxIndex) {
+    const currentIndex = (minIndex + maxIndex) >> 1
+    const currentItem = items[currentIndex]
+    if (condition(currentItem)) {
+      maxIndex = currentIndex
+    } else {
+      minIndex = currentIndex + 1
+    }
+  }
+  return minIndex /* === maxIndex */
+}
+
+/**
+ * Scrolls specified element into view of its parent.
+ * @param {Object} element - The element to be visible.
+ * @param {Object} spot - An object with optional top and left properties,
+ *   specifying the offset from the top left edge.
+ * @param {boolean} [scrollMatches] - When scrolling search results into view,
+ *   ignore elements that either: Contains marked content identifiers,
+ *   or have the CSS-rule `overflow: hidden;` set. The default value is `false`.
+ */
+function scrollIntoView (element, spot, scrollMatches = false) {
+  // Assuming offsetParent is available (it's not available when viewer is in
+  // hidden iframe or object). We have to scroll: if the offsetParent is not set
+  // producing the error. See also animationStarted.
+  let parent = element.offsetParent
+  if (!parent) {
+    console.error('offsetParent is not set -- cannot scroll')
+    return
+  }
+  let offsetY = element.offsetTop + element.clientTop
+  let offsetX = element.offsetLeft + element.clientLeft
+  while (
+    (parent.clientHeight === parent.scrollHeight &&
+      parent.clientWidth === parent.scrollWidth) ||
+    (scrollMatches &&
+      (parent.classList.contains('markedContent') ||
+        getComputedStyle(parent).overflow === 'hidden'))
+    ) {
+    offsetY += parent.offsetTop
+    offsetX += parent.offsetLeft
+
+    parent = parent.offsetParent
+    if (!parent) {
+      return // no need to scroll
+    }
+  }
+  if (spot) {
+    if (spot.top !== undefined) {
+      offsetY += spot.top
+    }
+    if (spot.left !== undefined) {
+      offsetX += spot.left
+      parent.scrollLeft = offsetX
+    }
+  }
+  parent.scrollTop = offsetY
+}
+
+export const CharacterType = {
+  SPACE: 0,
+  ALPHA_LETTER: 1,
+  PUNCT: 2,
+  HAN_LETTER: 3,
+  KATAKANA_LETTER: 4,
+  HIRAGANA_LETTER: 5,
+  HALFWIDTH_KATAKANA_LETTER: 6,
+  THAI_LETTER: 7,
+}
+
+function isAlphabeticalScript (charCode) {
+  return charCode < 0x2e80
+}
+
+function isAscii (charCode) {
+  return (charCode & 0xff80) === 0
+}
+
+function isAsciiAlpha (charCode) {
+  return (
+    (charCode >= /* a = */ 0x61 && charCode <= /* z = */ 0x7a) ||
+    (charCode >= /* A = */ 0x41 && charCode <= /* Z = */ 0x5a)
+  )
+}
+
+function isAsciiDigit (charCode) {
+  return charCode >= /* 0 = */ 0x30 && charCode <= /* 9 = */ 0x39
+}
+
+function isAsciiSpace (charCode) {
+  return (
+    charCode === /* SPACE = */ 0x20 ||
+    charCode === /* TAB = */ 0x09 ||
+    charCode === /* CR = */ 0x0d ||
+    charCode === /* LF = */ 0x0a
+  )
+}
+
+function isHan (charCode) {
+  return (
+    (charCode >= 0x3400 && charCode <= 0x9fff) ||
+    (charCode >= 0xf900 && charCode <= 0xfaff)
+  )
+}
+
+function isKatakana (charCode) {
+  return charCode >= 0x30a0 && charCode <= 0x30ff
+}
+
+function isHiragana (charCode) {
+  return charCode >= 0x3040 && charCode <= 0x309f
+}
+
+function isHalfwidthKatakana (charCode) {
+  return charCode >= 0xff60 && charCode <= 0xff9f
+}
+
+function isThai (charCode) {
+  return (charCode & 0xff80) === 0x0e00
+}
+
+/**
+ * This function is based on the word-break detection implemented in:
+ * https://hg.mozilla.org/mozilla-central/file/tip/intl/lwbrk/WordBreaker.cpp
+ */
+export function getCharacterType (charCode) {
+  if (isAlphabeticalScript(charCode)) {
+    if (isAscii(charCode)) {
+      if (isAsciiSpace(charCode)) {
+        return CharacterType.SPACE
+      } else if (
+        isAsciiAlpha(charCode) ||
+        isAsciiDigit(charCode) ||
+        charCode === /* UNDERSCORE = */ 0x5f
+      ) {
+        return CharacterType.ALPHA_LETTER
+      }
+      return CharacterType.PUNCT
+    } else if (isThai(charCode)) {
+      return CharacterType.THAI_LETTER
+    } else if (charCode === /* NBSP = */ 0xa0) {
+      return CharacterType.SPACE
+    }
+    return CharacterType.ALPHA_LETTER
+  }
+
+  if (isHan(charCode)) {
+    return CharacterType.HAN_LETTER
+  } else if (isKatakana(charCode)) {
+    return CharacterType.KATAKANA_LETTER
+  } else if (isHiragana(charCode)) {
+    return CharacterType.HIRAGANA_LETTER
+  } else if (isHalfwidthKatakana(charCode)) {
+    return CharacterType.HALFWIDTH_KATAKANA_LETTER
+  }
+  return CharacterType.ALPHA_LETTER
 }

+ 5 - 1
src/main/frontend/fs/capacitor_fs.cljs

@@ -223,7 +223,11 @@
           path
 
           :else
-          (str dir "/" path))))
+          (let [encoded-chars? (boolean (re-find #"(?i)%[0-9a-f]{2}" path))
+                path' (cond-> path
+                        (not encoded-chars?)
+                        (js/encodeURI path))]
+            (str dir "/" path')))))
 
 (defn- local-container-path?
   "Check whether `path' is logseq's container `localDocumentsPath' on iOS"

+ 183 - 184
src/main/frontend/fs/sync.cljs

@@ -107,8 +107,8 @@
 (s/def ::TXContent-from-path (s/or :some string? :none nil?))
 (s/def ::TXContent-checksum (s/or :some string? :none nil?))
 (s/def ::TXContent-item (s/tuple ::TXContent-to-path
-                                     ::TXContent-from-path
-                                     ::TXContent-checksum))
+                                 ::TXContent-from-path
+                                 ::TXContent-checksum))
 (s/def ::TXContent (s/coll-of ::TXContent-item))
 (s/def ::diff (s/keys :req-un [::TXId ::TXType ::TXContent]))
 
@@ -295,9 +295,9 @@
 (defn <request [api-name & args]
   (let [name (str api-name (.now js/Date))]
     (go (swap! *on-flying-request conj name)
-        (let [r (<! (apply <request* api-name args))]
-          (swap! *on-flying-request disj name)
-          r))))
+      (let [r (<! (apply <request* api-name args))]
+        (swap! *on-flying-request disj name)
+        r))))
 
 (defn- remove-dir-prefix [dir path]
   (let [r (string/replace path (js/RegExp. (str "^" (gstring/regExpEscape dir))) "")]
@@ -336,7 +336,7 @@
   (-stop! [this]))
 (defprotocol IStopped?
   (-stopped? [this]))
-                                        ;from-path, to-path is relative path
+;from-path, to-path is relative path
 (deftype FileTxn [from-path to-path updated? deleted? txid checksum]
   Object
   (renamed? [_]
@@ -381,19 +381,19 @@
   (let [update? (= "update_files" TXType)
         delete? (= "delete_files" TXType)
         update-xf
-        (comp
-         (remove #(or (empty? (first %))
-                      (empty? (last %))))
-         (map #(->FileTxn (first %) (first %) update? delete? TXId (last %))))
+                (comp
+                 (remove #(or (empty? (first %))
+                              (empty? (last %))))
+                 (map #(->FileTxn (first %) (first %) update? delete? TXId (last %))))
         delete-xf
-        (comp
-         (remove #(empty? (first %)))
-         (map #(->FileTxn (first %) (first %) update? delete? TXId nil)))
+                (comp
+                 (remove #(empty? (first %)))
+                 (map #(->FileTxn (first %) (first %) update? delete? TXId nil)))
         rename-xf
-        (comp
-         (remove #(or (empty? (first %))
-                      (empty? (second %))))
-         (map #(->FileTxn (second %) (first %) false false TXId nil)))
+                (comp
+                 (remove #(or (empty? (first %))
+                              (empty? (second %))))
+                 (map #(->FileTxn (second %) (first %) false false TXId nil)))
         xf (case TXType
              "delete_files" delete-xf
              "update_files" update-xf
@@ -579,21 +579,21 @@
    #{} s1))
 
 (comment
-  (defn map->FileMetadata [m]
-    (apply ->FileMetadata ((juxt :size :etag :path :encrypted-path :last-modified :remote? (constantly nil)) m)))
-
-  (assert
-   (=
-    #{(map->FileMetadata {:size 1 :etag 2 :path 2 :encrypted-path 2 :last-modified 2})}
-    (diff-file-metadata-sets
-     (into #{}
-           (map map->FileMetadata)
-           [{:size 1 :etag 1 :path 1 :encrypted-path 1 :last-modified 1}
-            {:size 1 :etag 2 :path 2 :encrypted-path 2 :last-modified 2}])
-     (into #{}
-           (map map->FileMetadata)
-           [{:size 1 :etag 1 :path 1 :encrypted-path 1 :last-modified 1}
-            {:size 1 :etag 1 :path 2 :encrypted-path 2 :last-modified 1}])))))
+ (defn map->FileMetadata [m]
+   (apply ->FileMetadata ((juxt :size :etag :path :encrypted-path :last-modified :remote? (constantly nil)) m)))
+
+ (assert
+  (=
+   #{(map->FileMetadata {:size 1 :etag 2 :path 2 :encrypted-path 2 :last-modified 2})}
+   (diff-file-metadata-sets
+    (into #{}
+          (map map->FileMetadata)
+          [{:size 1 :etag 1 :path 1 :encrypted-path 1 :last-modified 1}
+           {:size 1 :etag 2 :path 2 :encrypted-path 2 :last-modified 2}])
+    (into #{}
+          (map map->FileMetadata)
+          [{:size 1 :etag 1 :path 1 :encrypted-path 1 :last-modified 1}
+           {:size 1 :etag 1 :path 2 :encrypted-path 2 :last-modified 1}])))))
 
 (extend-protocol IChecksum
   FileMetadata
@@ -807,7 +807,6 @@
   (<cancel-all-requests [_]
     (p->c (ipc/ipc "cancel-all-requests"))))
 
-
 (deftype ^:large-vars/cleanup-todo CapacitorAPI [^:mutable graph-uuid' ^:mutable private-key ^:mutable public-key']
   IToken
   (<get-token [this]
@@ -1018,8 +1017,8 @@
      (go-loop []
        (let [{:keys [val stop]}
              (async/alt!
-               debug-print-sync-events-loop-stop-chan {:stop true}
-               out-ch ([v] {:val v}))]
+              debug-print-sync-events-loop-stop-chan {:stop true}
+              out-ch ([v] {:val v}))]
          (cond
            stop (do (async/unmix-all out-mix)
                     (doseq [[topic ch] topic&chs]
@@ -1035,28 +1034,28 @@
 
 
 (comment
-  ;; sub one type event example:
-  (def c1 (chan 10))
-  (async/sub sync-events-publication :created-local-version-file c1)
-  (offer! sync-events-chan {:event :created-local-version-file :data :xxx})
-  (poll! c1)
-
-  ;; sub multiple type events example:
-  ;; sub :created-local-version-file and :finished-remote->local events,
-  ;; output into channel c4-out
-  (def c2 (chan 10))
-  (def c3 (chan 10))
-  (def c4-out (chan 10))
-  (def mix-out (async/mix c4-out))
-  (async/admix mix-out c2)
-  (async/admix mix-out c3)
-  (async/sub sync-events-publication :created-local-version-file c2)
-  (async/sub sync-events-publication :finished-remote->local c3)
-  (offer! sync-events-chan {:event :created-local-version-file :data :xxx})
-  (offer! sync-events-chan {:event :finished-remote->local :data :xxx})
-  (poll! c4-out)
-  (poll! c4-out)
-  )
+ ;; sub one type event example:
+ (def c1 (chan 10))
+ (async/sub sync-events-publication :created-local-version-file c1)
+ (offer! sync-events-chan {:event :created-local-version-file :data :xxx})
+ (poll! c1)
+
+ ;; sub multiple type events example:
+ ;; sub :created-local-version-file and :finished-remote->local events,
+ ;; output into channel c4-out
+ (def c2 (chan 10))
+ (def c3 (chan 10))
+ (def c4-out (chan 10))
+ (def mix-out (async/mix c4-out))
+ (async/admix mix-out c2)
+ (async/admix mix-out c3)
+ (async/sub sync-events-publication :created-local-version-file c2)
+ (async/sub sync-events-publication :finished-remote->local c3)
+ (offer! sync-events-chan {:event :created-local-version-file :data :xxx})
+ (offer! sync-events-chan {:event :finished-remote->local :data :xxx})
+ (poll! c4-out)
+ (poll! c4-out)
+ )
 
 ;;; sync events ends
 
@@ -1114,27 +1113,27 @@
       (let [file-meta-list      (transient #{})
             encrypted-path-list (transient [])
             exp-r
-            (<!
-             (go-loop [continuation-token nil]
-               (let [r (<! (.<request this "get_all_files"
-                                      (into
-                                       {}
-                                       (remove (comp nil? second)
-                                               {:GraphUUID graph-uuid :ContinuationToken continuation-token}))))]
-                 (if (instance? ExceptionInfo r)
-                   r
-                   (let [next-continuation-token (:NextContinuationToken r)
-                         objs                    (:Objects r)]
-                     (apply conj! encrypted-path-list (map (comp remove-user-graph-uuid-prefix :Key) objs))
-                     (apply conj! file-meta-list
-                            (map
-                             #(hash-map :checksum (:checksum %)
-                                        :encrypted-path (remove-user-graph-uuid-prefix (:Key %))
-                                        :size (:Size %)
-                                        :last-modified (:LastModified %))
-                             objs))
-                     (when-not (empty? next-continuation-token)
-                       (recur next-continuation-token)))))))]
+                                (<!
+                                 (go-loop [continuation-token nil]
+                                   (let [r (<! (.<request this "get_all_files"
+                                                          (into
+                                                           {}
+                                                           (remove (comp nil? second)
+                                                                   {:GraphUUID graph-uuid :ContinuationToken continuation-token}))))]
+                                     (if (instance? ExceptionInfo r)
+                                       r
+                                       (let [next-continuation-token (:NextContinuationToken r)
+                                             objs                    (:Objects r)]
+                                         (apply conj! encrypted-path-list (map (comp remove-user-graph-uuid-prefix :Key) objs))
+                                         (apply conj! file-meta-list
+                                                (map
+                                                 #(hash-map :checksum (:checksum %)
+                                                            :encrypted-path (remove-user-graph-uuid-prefix (:Key %))
+                                                            :size (:Size %)
+                                                            :last-modified (:LastModified %))
+                                                 objs))
+                                         (when-not (empty? next-continuation-token)
+                                           (recur next-continuation-token)))))))]
         (if (instance? ExceptionInfo exp-r)
           exp-r
           (let [file-meta-list*      (persistent! file-meta-list)
@@ -1198,58 +1197,58 @@
           (let [txns-with-encrypted-paths (mapv #(update % :path remove-user-graph-uuid-prefix) (:Transactions r))
                 encrypted-paths           (mapv :path txns-with-encrypted-paths)
                 encrypted-path->path-map
-                (zipmap
-                 encrypted-paths
-                 (<! (<decrypt-fnames rsapi graph-uuid encrypted-paths)))
+                                          (zipmap
+                                           encrypted-paths
+                                           (<! (<decrypt-fnames rsapi graph-uuid encrypted-paths)))
                 txns
-                (mapv
-                 (fn [txn] (update txn :path #(get encrypted-path->path-map %)))
-                 txns-with-encrypted-paths)]
+                                          (mapv
+                                           (fn [txn] (update txn :path #(get encrypted-path->path-map %)))
+                                           txns-with-encrypted-paths)]
             txns)))))
 
   (<get-diff [this graph-uuid from-txid]
-    ;; TODO: path in transactions should be relative path(now s3 key, which includes graph-uuid and user-uuid)
+   ;; TODO: path in transactions should be relative path(now s3 key, which includes graph-uuid and user-uuid)
     (go
       (let [r (<! (.<request this "get_diff" {:GraphUUID graph-uuid :FromTXId from-txid}))]
         (if (instance? ExceptionInfo r)
           r
           (let [txns-with-encrypted-paths (sort-by :TXId (:Transactions r))
                 txns-with-encrypted-paths*
-                (mapv
-                 (fn [txn]
-                   (assoc txn :TXContent
-                          (mapv
-                           (fn [[to-path from-path checksum]]
-                             [(remove-user-graph-uuid-prefix to-path)
-                              (some-> from-path remove-user-graph-uuid-prefix)
-                              checksum])
-                           (:TXContent txn))))
-                 txns-with-encrypted-paths)
+                                          (mapv
+                                           (fn [txn]
+                                             (assoc txn :TXContent
+                                                    (mapv
+                                                     (fn [[to-path from-path checksum]]
+                                                       [(remove-user-graph-uuid-prefix to-path)
+                                                        (some-> from-path remove-user-graph-uuid-prefix)
+                                                        checksum])
+                                                     (:TXContent txn))))
+                                           txns-with-encrypted-paths)
                 encrypted-paths
-                (mapcat
-                 (fn [txn]
-                   (remove
-                    #(or (nil? %) (not (string/starts-with? % "e.")))
-                    (mapcat
-                     (fn [[to-path from-path _checksum]] [to-path from-path])
-                     (:TXContent txn))))
-                 txns-with-encrypted-paths*)
+                                          (mapcat
+                                           (fn [txn]
+                                             (remove
+                                              #(or (nil? %) (not (string/starts-with? % "e.")))
+                                              (mapcat
+                                               (fn [[to-path from-path _checksum]] [to-path from-path])
+                                               (:TXContent txn))))
+                                           txns-with-encrypted-paths*)
                 encrypted-path->path-map
-                (zipmap
-                 encrypted-paths
-                 (<! (<decrypt-fnames rsapi graph-uuid encrypted-paths)))
+                                          (zipmap
+                                           encrypted-paths
+                                           (<! (<decrypt-fnames rsapi graph-uuid encrypted-paths)))
                 txns
-                (mapv
-                 (fn [txn]
-                   (assoc
-                    txn :TXContent
-                    (mapv
-                     (fn [[to-path from-path checksum]]
-                       [(get encrypted-path->path-map to-path to-path)
-                        (some->> from-path (get encrypted-path->path-map))
-                        checksum])
-                     (:TXContent txn))))
-                 txns-with-encrypted-paths*)]
+                                          (mapv
+                                           (fn [txn]
+                                             (assoc
+                                               txn :TXContent
+                                               (mapv
+                                                (fn [[to-path from-path checksum]]
+                                                  [(get encrypted-path->path-map to-path to-path)
+                                                   (some->> from-path (get encrypted-path->path-map))
+                                                   checksum])
+                                                (:TXContent txn))))
+                                           txns-with-encrypted-paths*)]
             [txns
              (:TXId (last txns))
              (:TXId (first txns))])))))
@@ -1448,8 +1447,8 @@
                      [recent-remote->local-file-item])
               (<! (<delete-local-files rsapi graph-uuid base-path [relative-p*]))
               (go (<! (timeout 5000))
-                  (swap! *sync-state sync-state--remove-recent-remote->local-files
-                         [recent-remote->local-file-item])))))
+                (swap! *sync-state sync-state--remove-recent-remote->local-files
+                       [recent-remote->local-file-item])))))
 
         (let [update-local-files-ch (<update-local-files rsapi graph-uuid base-path (map relative-path filetxns))
               r (<! (<with-pause update-local-files-ch *paused))]
@@ -1498,8 +1497,8 @@
                                                      (not (instance? ExceptionInfo r)))]
           ;; remove these recent-remote->local-file-items 5s later
           (go (<! (timeout 5000))
-              (swap! *sync-state sync-state--remove-recent-remote->local-files
-                     recent-remote->local-file-items))
+            (swap! *sync-state sync-state--remove-recent-remote->local-files
+                   recent-remote->local-file-items))
           (cond
             (instance? ExceptionInfo r) r
             @*paused                    {:pause true}
@@ -1599,7 +1598,7 @@
           path (relative-path e)]
       {:remote->local-type tp
        :checksum (if (= tp :delete) nil
-                     (val (first (<! (get-local-files-checksum graph-uuid (.-dir e) [path])))))
+                                    (val (first (<! (get-local-files-checksum graph-uuid (.-dir e) [path])))))
        :path path})))
 
 (defn- distinct-file-change-events-xf
@@ -1660,8 +1659,8 @@
     (go-loop []
       (let [{:keys [rename-event local-change]}
             (async/alt!
-              rename-page-event-chan ([v] {:rename-event v}) ;; {:repo X :old-path X :new-path}
-              local-changes-chan ([v] {:local-change v}))]
+             rename-page-event-chan ([v] {:rename-event v}) ;; {:repo X :old-path X :new-path}
+             local-changes-chan ([v] {:local-change v}))]
         (cond
           rename-event
           (let [repo-dir (config/get-repo-dir (:repo rename-event))
@@ -1674,7 +1673,7 @@
             (swap! *rename-events conj k1 k2)
             ;; remove rename-events after 2s
             (go (<! (timeout 3000))
-                (swap! *rename-events disj k1 k2))
+              (swap! *rename-events disj k1 k2))
             ;; add 2 simulated file-watcher events
             (>! ch (->FileChangeEvent "unlink" repo-dir (:old-path rename-event*) nil nil))
             (>! ch (->FileChangeEvent "add" repo-dir (:new-path rename-event*)
@@ -1820,7 +1819,7 @@
                                {:GraphUUID graph-uuid
                                 :init-graph-keys init-graph-keys
                                 :after-input-password #(go (<! (restore-pwd! graph-uuid))
-                                                           (offer! <restored-pwd {:graph-uuid graph-uuid :value true}))}])
+                                                         (offer! <restored-pwd {:graph-uuid graph-uuid :value true}))}])
             nil)
         pwd))))
 
@@ -2139,8 +2138,8 @@
                                           :full-sync? true
                                           :epoch      (tc/to-epoch (t/now))}})
                 (<! (apply-filetxns-partitions
-                    *sync-state user-uuid graph-uuid base-path partitioned-filetxns repo
-                    nil *stopped *paused))))]
+                     *sync-state user-uuid graph-uuid base-path partitioned-filetxns repo
+                     nil *stopped *paused))))]
         (cond
           (instance? ExceptionInfo r) {:unknown r}
           @*stopped                   {:stop true}
@@ -2205,8 +2204,8 @@
                 diff-remote-files     (diff-file-metadata-sets remote-all-files-meta local-all-files-meta)
                 recent-10-days-range  ((juxt #(tc/to-long (t/minus % (t/days 10))) #(tc/to-long %)) (t/today))
                 sorted-diff-remote-files
-                (sort-by
-                 (sort-file-metadata-fn :recent-days-range recent-10-days-range) > diff-remote-files)
+                                      (sort-by
+                                       (sort-file-metadata-fn :recent-days-range recent-10-days-range) > diff-remote-files)
                 latest-txid           (:TXId (<! (<get-remote-graph remoteapi nil graph-uuid)))]
             (println "[full-sync(remote->local)]" (count sorted-diff-remote-files) "files need to sync")
             (swap! *sync-state #(sync-state-reset-full-remote->local-files % sorted-diff-remote-files))
@@ -2303,10 +2302,10 @@
 
 
 (defrecord ^:large-vars/cleanup-todo
-    Local->RemoteSyncer [user-uuid graph-uuid base-path repo *sync-state remoteapi
-                         ^:mutable rate *txid ^:mutable remote->local-syncer stop-chan *stopped *paused
-                         ;; control chans
-                         private-immediately-local->remote-chan private-recent-edited-chan]
+  Local->RemoteSyncer [user-uuid graph-uuid base-path repo *sync-state remoteapi
+                       ^:mutable rate *txid ^:mutable remote->local-syncer stop-chan *stopped *paused
+                       ;; control chans
+                       private-immediately-local->remote-chan private-recent-edited-chan]
   Object
   (filter-file-change-events-fn [_]
     (fn [^FileChangeEvent e]
@@ -2443,13 +2442,13 @@
                 diff-local-files      (->> (diff-file-metadata-sets local-all-files-meta remote-all-files-meta)
                                            (sort-by (sort-file-metadata-fn :recent-days-range recent-10-days-range) >))
                 change-events
-                (sequence
-                 (comp
-                  ;; convert to FileChangeEvent
-                  (map #(->FileChangeEvent "change" base-path (.get-normalized-path ^FileMetadata %)
-                                           {:size (:size %)} (:etag %)))
-                  (remove ignored?))
-                 diff-local-files)
+                                      (sequence
+                                       (comp
+                                        ;; convert to FileChangeEvent
+                                        (map #(->FileChangeEvent "change" base-path (.get-normalized-path ^FileMetadata %)
+                                                                 {:size (:size %)} (:etag %)))
+                                        (remove ignored?))
+                                       diff-local-files)
                 distinct-change-events (distinct-file-change-events change-events)
                 _ (swap! *sync-state #(sync-state-reset-full-local->remote-files % distinct-change-events))
                 change-events-partitions
@@ -2477,8 +2476,8 @@
                              [fake-recent-remote->local-file-item])
                       (<! (<delete-local-files rsapi graph-uuid base-path [(relative-path f)]))
                       (go (<! (timeout 5000))
-                          (swap! *sync-state sync-state--remove-recent-remote->local-files
-                                 [fake-recent-remote->local-file-item])))))
+                        (swap! *sync-state sync-state--remove-recent-remote->local-files
+                               [fake-recent-remote->local-file-item])))))
                 (recur fs)))
 
             ;; 2. upload local files
@@ -2498,14 +2497,14 @@
 ;;; ### put all stuff together
 
 (defrecord ^:large-vars/cleanup-todo
- SyncManager [graph-uuid base-path *sync-state
-              ^Local->RemoteSyncer local->remote-syncer ^Remote->LocalSyncer remote->local-syncer remoteapi
-              ^:mutable ratelimit-local-changes-chan
-              *txid ^:mutable state ^:mutable remote-change-chan ^:mutable *ws *stopped? *paused?
-              ^:mutable ops-chan
-              ;; control chans
-              private-full-sync-chan private-stop-sync-chan private-remote->local-sync-chan
-              private-remote->local-full-sync-chan private-pause-resume-chan]
+  SyncManager [graph-uuid base-path *sync-state
+               ^Local->RemoteSyncer local->remote-syncer ^Remote->LocalSyncer remote->local-syncer remoteapi
+               ^:mutable ratelimit-local-changes-chan
+               *txid ^:mutable state ^:mutable remote-change-chan ^:mutable *ws *stopped? *paused?
+               ^:mutable ops-chan
+               ;; control chans
+               private-full-sync-chan private-stop-sync-chan private-remote->local-sync-chan
+               private-remote->local-full-sync-chan private-pause-resume-chan]
   Object
   (schedule [this next-state args reason]
     {:pre [(s/valid? ::state next-state)]}
@@ -2546,19 +2545,19 @@
     (go-loop []
       (let [{:keys [stop remote->local remote->local-full-sync local->remote-full-sync local->remote resume pause]}
             (async/alt!
-              private-stop-sync-chan {:stop true}
-              private-remote->local-full-sync-chan {:remote->local-full-sync true}
-              private-remote->local-sync-chan {:remote->local true}
-              private-full-sync-chan {:local->remote-full-sync true}
-              private-pause-resume-chan ([v] (if v {:resume true} {:pause true}))
-              remote-change-chan ([v] (println "remote change:" v) {:remote->local v})
-              ratelimit-local-changes-chan ([v]
-                                            (let [rest-v (util/drain-chan ratelimit-local-changes-chan)
-                                                  vs     (cons v rest-v)]
-                                              (println "local changes:" vs)
-                                              {:local->remote vs}))
-              (timeout (* 20 60 1000)) {:local->remote-full-sync true}
-              :priority true)]
+             private-stop-sync-chan {:stop true}
+             private-remote->local-full-sync-chan {:remote->local-full-sync true}
+             private-remote->local-sync-chan {:remote->local true}
+             private-full-sync-chan {:local->remote-full-sync true}
+             private-pause-resume-chan ([v] (if v {:resume true} {:pause true}))
+             remote-change-chan ([v] (println "remote change:" v) {:remote->local v})
+             ratelimit-local-changes-chan ([v]
+                                           (let [rest-v (util/drain-chan ratelimit-local-changes-chan)
+                                                 vs     (cons v rest-v)]
+                                             (println "local changes:" vs)
+                                             {:local->remote vs}))
+             (timeout (* 20 60 1000)) {:local->remote-full-sync true}
+             :priority true)]
         (cond
           stop
           (do (util/drain-chan ops-chan)
@@ -2598,12 +2597,12 @@
         (assert (s/valid? ::state next-state) next-state)
         (when (= next-state ::idle)
           (<! (<ensure-set-env&keys graph-uuid *stopped?))
-            ;; wait seconds to receive all file change events,
-            ;; and then drop all of them.
-            ;; WHY: when opening a graph(or switching to another graph),
-            ;;      file-watcher will send a lot of file-change-events,
-            ;;      actually, each file corresponds to a file-change-event,
-            ;;      we need to ignore all of them.
+          ;; wait seconds to receive all file change events,
+          ;; and then drop all of them.
+          ;; WHY: when opening a graph(or switching to another graph),
+          ;;      file-watcher will send a lot of file-change-events,
+          ;;      actually, each file corresponds to a file-change-event,
+          ;;      we need to ignore all of them.
           (<! (timeout 3000))
           (println :drain-local-changes-chan-at-starting
                    (count (util/drain-chan local-changes-revised-chan))))
@@ -2748,13 +2747,13 @@
                 (.schedule this ::idle nil nil)))))))
 
   (local->remote [this {local-changes :local}]
-    ;; local-changes:: list of FileChangeEvent
+   ;; local-changes:: list of FileChangeEvent
     (assert (some? local-changes) local-changes)
     (go
       (let [distincted-local-changes (distinct-file-change-events local-changes)
             _ (swap! *sync-state #(sync-state-reset-full-local->remote-files % distincted-local-changes))
             change-events-partitions
-            (sequence (partition-file-change-events upload-batch-size) distincted-local-changes)
+                                     (sequence (partition-file-change-events upload-batch-size) distincted-local-changes)
             _ (put-sync-event! {:event :start
                                 :data  {:type       :local->remote
                                         :graph-uuid graph-uuid
@@ -2895,15 +2894,15 @@
   (go
     (let [r (<! (<list-remote-graphs remoteapi))
           result
-          (or
-           ;; if api call failed, assume this remote graph still exists
-           (instance? ExceptionInfo r)
-           (and
-            (contains? r :Graphs)
-            (->> (:Graphs r)
-                 (mapv :GraphUUID)
-                 set
-                 (#(contains? % local-graph-uuid)))))]
+            (or
+             ;; if api call failed, assume this remote graph still exists
+             (instance? ExceptionInfo r)
+             (and
+              (contains? r :Graphs)
+              (->> (:Graphs r)
+                   (mapv :GraphUUID)
+                   set
+                   (#(contains? % local-graph-uuid)))))]
 
       (when-not result
         (notification/show! (t :file-sync/graph-deleted) :warning false))
@@ -3015,7 +3014,7 @@
 
 ;;; add-tap
 (comment
-  (def *x (atom nil))
-  (add-tap (fn [v] (reset! *x v)))
+ (def *x (atom nil))
+ (add-tap (fn [v] (reset! *x v)))
 
-  )
+ )

+ 131 - 0
src/main/frontend/handler/assets.cljs

@@ -0,0 +1,131 @@
+(ns ^:no-doc frontend.handler.assets
+  (:require [frontend.state :as state]
+            [medley.core :as medley]
+            [frontend.util :as util]
+            [frontend.config :as config]
+            [frontend.mobile.util :as mobile-util]
+            [logseq.graph-parser.config :as gp-config]
+            [clojure.string :as string]))
+
+(defn alias-enabled?
+  []
+  (and (util/electron?)
+       (:assets/alias-enabled? @state/state)))
+
+(defn encode-to-protect-assets-schema-path
+  [schema-path]
+  (cond-> schema-path
+    (string? schema-path)
+    (->
+     (string/replace #"\\+" "/")
+     (string/replace ":/" "/logseq__colon/"))))
+
+(defn decode-protected-assets-schema-path
+  [schema-path]
+  (cond-> schema-path
+    (string? schema-path)
+    (string/replace "/logseq__colon/" ":/")))
+
+(defn clean-path-prefix
+  [path]
+  (when (string? path)
+    (string/replace-first path #"^[.\/\\]*(assets)[\/\\]+" "")))
+
+(defn check-alias-path?
+  [path]
+  (and (string? path)
+       (some-> path
+               (clean-path-prefix)
+               (string/starts-with? "@"))))
+
+(defn get-alias-dirs
+  []
+  (:assets/alias-dirs @state/state))
+
+(defn get-alias-by-dir
+  [dir]
+  (when-let [alias-dirs (and (alias-enabled?) (seq (get-alias-dirs)))]
+    (medley/find-first #(= dir (:dir (second %1)))
+                       (medley/indexed alias-dirs))))
+
+(defn get-alias-by-name
+  [name]
+  (when-let [alias-dirs (and (alias-enabled?) (seq (get-alias-dirs)))]
+    (medley/find-first #(= name (:name (second %1)))
+                       (medley/indexed alias-dirs))))
+
+(defn convert-platform-protocol
+  [full-path]
+
+  (cond-> full-path
+    (and (string? full-path)
+         (mobile-util/native-platform?))
+    (string/replace-first
+     #"^(file://|assets://)" gp-config/capacitor-protocol-with-prefix)))
+
+(defn resolve-asset-real-path-url
+  [repo full-path]
+  (when-let [full-path (and (string? full-path)
+                            (string/replace full-path #"^[.\/\\]+" ""))]
+    (if config/publishing?
+      (str "./" full-path)
+      (let [ret (let [full-path      (if-not (string/starts-with? full-path gp-config/local-assets-dir)
+                                       (util/node-path.join gp-config/local-assets-dir full-path)
+                                       full-path)
+                      encoded-chars? (boolean (re-find #"(?i)%[0-9a-f]{2}" full-path))
+                      full-path      (if encoded-chars? full-path (js/encodeURI full-path))
+                      graph-root     (config/get-repo-dir repo)
+                      has-schema?    (string/starts-with? graph-root "file:")]
+
+                  (if-let [[full-path' alias]
+                           (and (alias-enabled?)
+                                (let [full-path' (string/replace full-path (re-pattern (str "^" gp-config/local-assets-dir "[\\/\\\\]+")) "")]
+                                  (and
+                                   (string/starts-with? full-path' "@")
+                                   (some->> (and (seq (get-alias-dirs))
+                                                 (second (get-alias-by-name (second (re-find #"^@([^\/]+)" full-path')))))
+                                            (vector full-path')))))]
+
+                    (str "assets://"
+                         (encode-to-protect-assets-schema-path
+                          (string/replace full-path' (str "@" (:name alias)) (:dir alias))))
+
+                    (str (if has-schema? "" "file://")
+                         (util/node-path.join graph-root full-path))))]
+        (convert-platform-protocol ret)))))
+
+(defn normalize-asset-resource-url
+  ;; try to convert resource file to url asset link
+  [full-path]
+  (let [_filename      (util/node-path.basename full-path)
+        protocol-link? (->> #{:file :http :https :assets}
+                            (some #(string/starts-with? full-path (str (name %) ":/"))))]
+
+    (cond
+      protocol-link?
+      full-path
+
+      (util/absolute-path? full-path)
+      (str "file://" full-path)
+
+      :else
+      (resolve-asset-real-path-url (state/get-current-repo) full-path))))
+
+(defn get-matched-alias-by-ext
+  [ext]
+  (when-let [ext (and (alias-enabled?)
+                      (string? ext)
+                      (not (string/blank? ext))
+                      (util/safe-lower-case ext))]
+
+    (let [alias (medley/find-first
+                 (fn [{:keys [exts]}]
+                   (some #(string/ends-with? ext %) exts))
+                 (get-alias-dirs))]
+      alias)))
+
+(comment
+ (normalize-asset-resource-url "https://x.com/a.pdf")
+ (normalize-asset-resource-url "./a/b.pdf")
+ (normalize-asset-resource-url "assets/a/b.pdf")
+ (normalize-asset-resource-url "@图书/a/b.pdf"))

+ 69 - 36
src/main/frontend/handler/editor.cljs

@@ -21,6 +21,7 @@
             [frontend.handler.notification :as notification]
             [frontend.handler.repeated :as repeated]
             [frontend.handler.route :as route-handler]
+            [frontend.handler.assets :as assets-handler]
             [frontend.idb :as idb]
             [frontend.image :as image]
             [frontend.mobile.util :as mobile-util]
@@ -1370,50 +1371,75 @@
     (for [[index ^js file] (map-indexed vector files)]
       ;; WARN file name maybe fully qualified path when paste file
       (let [file-name (util/node-path.basename (.-name file))
-            [file-base ext] (if file-name
-                              (let [last-dot-index (string/last-index-of file-name ".")]
-                                [(subs file-name 0 last-dot-index)
-                                 (subs file-name last-dot-index)])
-                              ["" ""])
-            filename (str (gen-filename index file-base) ext)
-            filename (str path "/" filename)]
-                                        ;(js/console.debug "Write asset #" dir filename file)
+            [file-base ext-full ext-base] (if file-name
+                              (let [ext-base (util/node-path.extname file-name)
+                                    ext-full (if-not (config/extname-of-supported? ext-base)
+                                               (util/full-path-extname file-name) ext-base)]
+                                [(subs file-name 0 (- (count file-name)
+                                                      (count ext-full))) ext-full ext-base])
+                              ["" "" ""])
+            filename  (str (gen-filename index file-base) ext-full)
+            filename  (str path "/" filename)
+            matched-alias (assets-handler/get-matched-alias-by-ext ext-base)
+              filename (cond-> filename
+                         (not (nil? matched-alias))
+                         (string/replace #"^[.\/\\]*assets[\/\\]+" ""))
+              dir (or (:dir matched-alias) dir)]
+
         (if (util/electron?)
           (let [from (.-path file)
                 from (if (string/blank? from) nil from)]
-            (p/then (js/window.apis.copyFileToAssets dir filename from)
-                    #(p/resolved [filename (if (string? %) (js/File. #js[] %) file) (.join util/node-path dir filename)])))
+
+            (js/console.debug "Debug: Copy Asset #" dir filename from)
+
+            (-> (js/window.apis.copyFileToAssets dir filename from)
+                (p/then
+                 (fn [dest]
+                   [filename
+                    (if (string? dest) (js/File. #js[] dest) file)
+                    (.join util/node-path dir filename)
+                    matched-alias]))
+                (p/catch #(js/console.error "Debug: Copy Asset Error#" %))))
+
           (p/then (fs/write-file! repo dir filename (.stream file) nil)
-                  #(p/resolved [filename file]))))))))
+                  #(p/resolved [filename file nil matched-alias]))))))))
 
 (defonce *assets-url-cache (atom {}))
 
 (defn make-asset-url
   [path] ;; path start with "/assets" or compatible for "../assets"
-  (let [repo-dir (config/get-repo-dir (state/get-current-repo))
-        path (string/replace path "../" "/")
-        data-url? (string/starts-with? path "data:")]
-    (cond
-      data-url?
-      path ;; just return the original
-      
-      (util/electron?)
-      (str "assets://" repo-dir path)
+  (if config/publishing? path
+    (let [repo      (state/get-current-repo)
+          repo-dir  (config/get-repo-dir repo)
+          path      (string/replace path "../" "/")
+          full-path (util/node-path.join repo-dir path)
+          data-url? (string/starts-with? path "data:")]
+      (cond
+        data-url?
+        path ;; just return the original
 
-      (mobile-util/native-platform?)
-      (mobile-util/convert-file-src (str repo-dir path))
+        (and (assets-handler/alias-enabled?)
+             (assets-handler/check-alias-path? path))
+        (assets-handler/resolve-asset-real-path-url (state/get-current-repo) path)
 
-      :else
-      (let [handle-path (str "handle" repo-dir path)
-            cached-url (get @*assets-url-cache (keyword handle-path))]
-        (if cached-url
-          (p/resolved cached-url)
-          (p/let [handle (idb/get-item handle-path)
-                  file (and handle (.getFile handle))]
-            (when file
-              (p/let [url (js/URL.createObjectURL file)]
-                (swap! *assets-url-cache assoc (keyword handle-path) url)
-                url))))))))
+        (util/electron?)
+        (str "assets://"
+             (assets-handler/encode-to-protect-assets-schema-path full-path))
+
+        (mobile-util/native-platform?)
+        (mobile-util/convert-file-src full-path)
+
+        :else
+        (let [handle-path (str "handle" full-path)
+              cached-url  (get @*assets-url-cache (keyword handle-path))]
+          (if cached-url
+            (p/resolved cached-url)
+            (p/let [handle (idb/get-item handle-path)
+                    file   (and handle (.getFile handle))]
+              (when file
+                (p/let [url (js/URL.createObjectURL file)]
+                  (swap! *assets-url-cache assoc (keyword handle-path) url)
+                  url)))))))))
 
 (defn delete-asset-of-block!
   [{:keys [repo href full-text block-id local? delete-local?] :as _opts}]
@@ -1424,7 +1450,9 @@
     (save-block! repo block content)
     (when (and local? delete-local?)
       ;; FIXME: should be relative to current block page path
-      (when-let [href (if (util/electron?) href (second (re-find #"\((.+)\)$" full-text)))]
+      (when-let [href (if (util/electron?)
+                        (assets-handler/decode-protected-assets-schema-path href)
+                        (second (re-find #"\((.+)\)$" full-text)))]
         (fs/unlink! repo
                     (config/get-repo-path
                      repo (-> href
@@ -1451,11 +1479,16 @@
       (-> (save-assets! block repo (js->clj files))
           (p/then
            (fn [res]
-             (when-let [[asset-file-name file full-file-path] (and (seq res) (first res))]
+             (when-let [[asset-file-name file full-file-path matched-alias] (and (seq res) (first res))]
                (let [image? (util/ext-of-image? asset-file-name)]
                  (insert-command!
                   id
-                  (get-asset-file-link format (resolve-relative-path (or full-file-path asset-file-name))
+                  (get-asset-file-link format
+                                       (if matched-alias
+                                         (str
+                                          (if image? "../assets/" "")
+                                          "@" (:name matched-alias) "/" asset-file-name)
+                                         (resolve-relative-path (or full-file-path asset-file-name)))
                                        (if file (.-name file) (if image? "image" "asset"))
                                        image?)
                   format

+ 1 - 1
src/main/frontend/mobile/intent.cljs

@@ -120,7 +120,7 @@
                     (gp-config/mldoc-support? application-type)
                     (embed-text-file url title)
 
-                    (contains? (set/union (config/doc-formats) config/media-formats)
+                    (contains? (set/union config/doc-formats config/media-formats)
                                (keyword application-type))
                     (embed-asset-file url format)
 

+ 13 - 1
src/main/frontend/modules/shortcut/config.cljs

@@ -12,6 +12,7 @@
             [frontend.handler.search :as search-handler]
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.export :as export-handler]
             [frontend.handler.whiteboard :as whiteboard-handler]
             [frontend.modules.shortcut.dicts :as dicts]
             [frontend.modules.shortcut.before :as m]
@@ -57,6 +58,9 @@
    :pdf/close                    {:binding "alt+x"
                                   :fn      #(state/set-state! :pdf/current nil)}
 
+   :pdf/find                     {:binding "alt+f"
+                                  :fn      pdf-utils/open-finder}
+
    :auto-complete/complete       {:binding "enter"
                                   :fn      ui-handler/auto-complete-complete}
 
@@ -294,6 +298,10 @@
                                                 (editor-handler/escape-editing)
                                                 (state/toggle! :ui/command-palette-open?))}
 
+   :graph/export-as-html           {:fn #(export-handler/export-repo-as-html!
+                                          (state/get-current-repo))
+                                    :binding false}
+
    :graph/open                     {:fn      #(do
                                                 (editor-handler/escape-editing)
                                                 (state/set-state! :ui/open-select :graph-open))
@@ -434,7 +442,8 @@
     :shortcut.handler/pdf
     (-> (build-category-map [:pdf/previous-page
                              :pdf/next-page
-                             :pdf/close])
+                             :pdf/close
+                             :pdf/find])
         (with-meta {:before m/enable-when-not-editing-mode!}))
 
     :shortcut.handler/auto-complete
@@ -484,6 +493,7 @@
     (->
      (build-category-map [:command/run
                           :command-palette/toggle
+                          :graph/export-as-html
                           :graph/open
                           :graph/remove
                           :graph/add
@@ -677,9 +687,11 @@
    [:pdf/previous-page
     :pdf/next-page
     :pdf/close
+    :pdf/find
     :command/toggle-favorite
     :command/run
     :command-palette/toggle
+    :graph/export-as-html
     :graph/open
     :graph/remove
     :graph/add

+ 5 - 3
src/main/frontend/modules/shortcut/dicts.cljc

@@ -12,9 +12,10 @@
    :date-picker/next-day         "Date picker: Select next day"
    :date-picker/prev-week        "Date picker: Select previous week"
    :date-picker/next-week        "Date picker: Select next week"
-   :pdf/previous-page            "Previous page of current pdf doc"
-   :pdf/next-page                "Next page of current pdf doc"
-   :pdf/close                    "Close current pdf viewer"
+   :pdf/previous-page            "Pdf: Previous page of current pdf doc"
+   :pdf/next-page                "Pdf: Next page of current pdf doc"
+   :pdf/close                    "Pdf: Close current pdf doc"
+   :pdf/find                     "Pdf: Search text of current pdf doc"
    :auto-complete/complete       "Auto-complete: Choose selected item"
    :auto-complete/prev           "Auto-complete: Select previous item"
    :auto-complete/next           "Auto-complete: Select next item"
@@ -90,6 +91,7 @@
    :sidebar/clear                  "Clear all in the right sidebar"
    :misc/copy                      "mod+c"
    :command-palette/toggle         "Toggle command palette"
+   :graph/export-as-html           "Export public graph pages as html"
    :graph/open                     "Select graph to open"
    :graph/remove                   "Remove a graph"
    :graph/add                      "Add a graph"

+ 2 - 0
src/main/frontend/spec/storage.cljc

@@ -11,6 +11,7 @@
 (s/def ::lsp-core-enabled boolean?)
 (s/def ::instrument-disabled boolean?)
 (s/def ::ls-pdf-area-is-dashed boolean?)
+(s/def ::ls-pdf-hl-block-is-colored boolean?)
 (s/def ::ls-pdf-viewer-theme string?)
 (s/def :zotero/api-key-v2 map?)
 (s/def :zotero/setting-profile string?)
@@ -43,6 +44,7 @@
             ::lsp-core-enabled
             ::instrument-disabled
             ::ls-pdf-area-is-dashed
+            ::ls-pdf-hl-block-is-colored
             ::ls-pdf-viewer-theme
             :zotero/api-key-v2
             :zotero/setting-profile

+ 27 - 6
src/main/frontend/state.cljs

@@ -153,6 +153,10 @@
      :electron/updater                      {}
      :electron/user-cfgs                    nil
 
+     ;; assets
+     :assets/alias-enabled?                 (or (storage/get :assets/alias-enabled?) false)
+     :assets/alias-dirs                     (or (storage/get :assets/alias-dirs) [])
+
      ;; mobile
      :mobile/show-action-bar?               false
      :mobile/actioned-block                 nil
@@ -193,6 +197,7 @@
      ;; pdf
      :pdf/current                           nil
      :pdf/ref-highlight                     nil
+     :pdf/block-highlight-colored?          (or (storage/get "ls-pdf-hl-block-is-colored") true)
 
      ;; all notification contents as k-v pairs
      :notification/contents                 {}
@@ -287,6 +292,10 @@
 ;;  (re-)fetches get-current-repo needlessly
 ;; TODO: Add consistent validation. Only a few config options validate at get time
 
+(defn get-current-pdf
+  []
+  (:pdf/current @state))
+
 (def default-config
   "Default config for a repo-specific, user config"
   {:feature/enable-search-remove-accents? true
@@ -296,7 +305,7 @@
    :file/name-format :legacy})
 
 ;; State that most user config is dependent on
-(declare get-current-repo)
+(declare get-current-repo sub set-state!)
 
 (defn merge-configs
   "Merges user configs in given orders. All values are overriden except for maps
@@ -343,6 +352,17 @@ should be done through this fn in order to get global config and config defaults
     built-in-macros
     (:macros (get-config))))
 
+(defn set-assets-alias-enabled!
+  [v]
+  (set-state! :assets/alias-enabled? (boolean v))
+  (storage/set :assets/alias-enabled? (boolean v)))
+
+(defn set-assets-alias-dirs!
+  [dirs]
+  (when dirs
+    (set-state! :assets/alias-dirs dirs)
+    (storage/set :assets/alias-dirs dirs)))
+
 (defn get-custom-css-link
   []
   (:custom-css-url (get-config)))
@@ -1125,11 +1145,12 @@ Similar to re-frame subscriptions"
 (defn load-app-user-cfgs
   ([] (load-app-user-cfgs false))
   ([refresh?]
-   (p/let [cfgs (if (or refresh? (nil? (:electron/user-cfgs @state)))
-                  (ipc/ipc "userAppCfgs")
-                  (:electron/user-cfgs @state))
-           cfgs (if (object? cfgs) (bean/->clj cfgs) cfgs)]
-          (set-state! :electron/user-cfgs cfgs))))
+   (when (util/electron?)
+     (p/let [cfgs (if (or refresh? (nil? (:electron/user-cfgs @state)))
+                    (ipc/ipc :userAppCfgs)
+                    (:electron/user-cfgs @state))
+             cfgs (if (object? cfgs) (bean/->clj cfgs) cfgs)]
+       (set-state! :electron/user-cfgs cfgs)))))
 
 (defn setup-electron-updater!
   []

+ 2 - 0
src/main/frontend/ui.cljs

@@ -32,6 +32,7 @@
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
             [medley.core :as medley]
+            [frontend.config :as config]
             [promesa.core :as p]
             [rum.core :as rum]))
 
@@ -328,6 +329,7 @@
 (defn inject-document-devices-envs!
   []
   (let [^js cl (.-classList js/document.documentElement)]
+    (when config/publishing? (.add cl "is-publish-mode"))
     (when util/mac? (.add cl "is-mac"))
     (when util/win32? (.add cl "is-win32"))
     (when (util/electron?) (.add cl "is-electron"))

+ 16 - 1
src/main/frontend/util.cljc

@@ -9,6 +9,7 @@
             ["grapheme-splitter" :as GraphemeSplitter]
             ["remove-accents" :as removeAccents]
             ["check-password-strength" :refer [passwordStrength]]
+            [frontend.loader :refer [load]]
             [cljs-bean.core :as bean]
             [cljs-time.coerce :as tc]
             [cljs-time.core :as t]
@@ -40,6 +41,7 @@
        (-write writer (str "\"" (.toString sym) "\"")))))
 
 #?(:cljs (defonce ^js node-path utils/nodePath))
+#?(:cljs (defonce ^js full-path-extname utils/fullPathExtname))
 #?(:cljs (defn app-scroll-container-node
            ([]
             (gdom/getElement "main-content-container"))
@@ -1207,7 +1209,7 @@
              #(if (map? %)
                 (for [[k v] %]
                   (when v (name k)))
-                [(name %)])
+                (when-not (nil? %) [(name %)]))
              args)))
 
 #?(:cljs
@@ -1377,3 +1379,16 @@
                Math/floor
                int
                (#(str % " " (:name unit) (when (> % 1) "s") " ago"))))))))
+#?(:cljs
+   (def JS_ROOT
+     (when-not node-test?
+       (if (= js/location.protocol "file:")
+         "./js"
+         "./static/js"))))
+
+#?(:cljs
+   (defn js-load$
+     [url]
+     (p/create
+      (fn [resolve]
+        (load url resolve)))))

+ 0 - 9
src/main/frontend/utils.js

@@ -291,15 +291,6 @@ export const toPosixPath = (input) => {
   return input && input.replace(/\\+/g, '/')
 }
 
-// Delegation of Path.js but unified into POXIS style
-// https://nodejs.org/api/path.html#pathparsepath
-// path.parse('/home/user/dir/file.txt');
-// Returns:
-// { root: '/',
-//   dir: '/home/user/dir',
-//   base: 'file.txt',
-//   ext: '.txt',
-//   name: 'file' }
 export const nodePath = Object.assign({}, path, {
   basename (input) {
     input = toPosixPath(input)

+ 1 - 1
src/test/frontend/extensions/pdf/assets_test.cljs

@@ -6,7 +6,7 @@
   (testing "matched filenames"
     (are [x y] (= y (assets/fix-local-asset-pagename x))
       "2015_Book_Intertwingled_1659920114630_0" "2015 Book Intertwingled"
-      "hls__2015_Book_Intertwingled_1659920114630_0" "📒2015 Book Intertwingled"
+      "hls__2015_Book_Intertwingled_1659920114630_0" "2015 Book Intertwingled"
       "hls/2015_Book_Intertwingled_1659920114630_0" "hls/2015 Book Intertwingled"))
   (testing "non matched filenames"
     (are [x y] (= y (assets/fix-local-asset-pagename x))

+ 5 - 0
yarn.lock

@@ -5225,6 +5225,11 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a"
   integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==
 
[email protected]:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/path-complete-extname/-/path-complete-extname-1.0.0.tgz#f889985dc91000c815515c0bfed06c5acda0752b"
+  integrity sha512-CVjiWcMRdGU8ubs08YQVzhutOR5DEfO97ipRIlOGMK5Bek5nQySknBpuxVAVJ36hseTNs+vdIcv57ZrWxH7zvg==
+
 path-dirname@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini