Explorar o código

improve(pdf): construct highlights data

charlie %!s(int64=4) %!d(string=hai) anos
pai
achega
8ec5ce0cde

+ 242 - 0
src/main/frontend/extensions/pdf/highlights.cljs

@@ -0,0 +1,242 @@
+(ns frontend.extensions.pdf.highlights
+  (:require [rum.core :as rum]
+            [promesa.core :as p]
+            [cljs-bean.core :as bean]
+            [frontend.extensions.pdf.utils :as pdf-utils]))
+
+(defonce ACTIVE_FILE "https://phx-nine.vercel.app/clojure-hopl-iv-final.pdf")
+
+(defn dd [& args]
+  (apply js/console.debug args))
+
+(rum/defc pdf-highlights
+  [^js el ^js viewer initial-hls loaded-pages]
+
+  (let [[sel-state, set-sel-state!] (rum/use-state {:range nil :collapsed nil :point nil})
+        [highlights, set-highlights!] (rum/use-state initial-hls)]
+
+    ;; selection events
+    (rum/use-effect!
+      (fn []
+        (let [fn-selection-ok
+              (fn [^js/MouseEvent e]
+                (let [^js/Selection selection (js/document.getSelection)
+                      ^js/Range sel-range (.getRangeAt selection 0)]
+
+                  (cond
+                    (.-isCollapsed selection)
+                    (set-sel-state! {:collapsed true})
+
+                    (and sel-range (.contains el (.-commonAncestorContainer sel-range)))
+                    (set-sel-state! {:collapsed false :range sel-range :point {:x (.-clientX e) :y (.-clientY e)}}))))
+
+              fn-selection
+              (fn []
+                (let [*dirty (volatile! false)
+                      fn-dirty #(vreset! *dirty true)]
+
+                  (js/document.addEventListener "selectionchange" fn-dirty)
+                  (js/document.addEventListener "mouseup"
+                                                (fn [^js e]
+                                                  (and @*dirty (fn-selection-ok e))
+                                                  (js/document.removeEventListener "selectionchange" fn-dirty))
+                                                #js {:once true})))]
+
+          ;;(doto (.-eventBus viewer))
+
+          (doto el
+            (.addEventListener "mousedown" fn-selection))
+
+          ;; destroy
+          #(do
+             ;;(doto (.-eventBus viewer))
+
+             (doto el
+               (.removeEventListener "mousedown" fn-selection)))))
+
+      [viewer])
+
+    ;; selection context menu
+    (rum/use-effect!
+      (fn []
+        (when-let [^js sel-range (and (not (:collapsed sel-state)) (:range sel-state))]
+          (when-let [page-info (pdf-utils/get-page-from-range sel-range)]
+            (when-let [sel-rects (pdf-utils/get-range-rects<-page-cnt sel-range (:page-el page-info))]
+              (let [page (int (:page-number page-info))
+                    ^js point (:point sel-state)
+                    ^js bounding (pdf-utils/get-bounding-rect sel-rects)
+                    vw-pos {:bounding bounding :rects sel-rects :page page}
+                    sc-pos (pdf-utils/vw-to-scaled-pos viewer vw-pos)]
+
+                ;; TODO: debug
+                (js/console.debug "[VW x SC] ====>" vw-pos sc-pos)
+                (js/console.debug "[Range] ====> [" page-info "]" (.toString sel-range) point)
+                (js/console.debug "[Rects] ====>" sel-rects " [Bounding] ====>" bounding)
+
+                ;; show context menu
+                (set-highlights!
+                  (conj highlights {:id         (pdf-utils/gen-id)
+                                    :page       page
+                                    :position   sc-pos
+                                    :content    {:text (.toString sel-range)}
+                                    :properties {}}))
+
+                )))))
+
+      [(:range sel-state)])
+
+    ;; render hls
+    (rum/use-effect!
+      (fn []
+        (js/console.debug "[rebuild highlights] " (count highlights))
+
+        (when-let [grouped-hls (and (seq highlights) (group-by :page highlights))]
+
+          (dd "[hls]" grouped-hls))
+
+        ;; destroy
+        #())
+      [loaded-pages highlights])
+
+
+    [:div.extensions__pdf-highlights
+     [:pre
+      (js/JSON.stringify (bean/->js highlights) nil 2)]]))
+
+(rum/defc pdf-viewer
+  [url initial-hls ^js pdf-document]
+
+  (js/console.debug "==== render pdf-viewer ====")
+
+  (let [*el-ref (rum/create-ref)
+        [state, set-state!] (rum/use-state {:viewer nil :bus nil :link nil :el nil})
+        [ano-state, set-ano-state!] (rum/use-state {:loaded-pages []})
+        [hls-state, set-hls-state!] (rum/use-state {:dirties 0})]
+
+    ;; instant pdfjs viewer
+    (rum/use-effect!
+      (fn [] (let [^js event-bus (js/pdfjsViewer.EventBus.)
+                   ^js link-service (js/pdfjsViewer.PDFLinkService. #js {:eventBus event-bus :externalLinkTarget 2})
+                   ^js el (rum/deref *el-ref)
+                   ^js viewer (js/pdfjsViewer.PDFViewer.
+                                #js {:container            el
+                                     :eventBus             event-bus
+                                     :linkService          link-service
+                                     :enhanceTextSelection true
+                                     :removePageBorders    true})]
+               (. link-service setDocument pdf-document)
+               (. link-service setViewer viewer)
+
+               ;; TODO: debug
+               (set! (. js/window -lsPdfViewer) viewer)
+
+               (p/then (. viewer setDocument pdf-document)
+                       #(set-state! {:viewer viewer :bus event-bus :link link-service :el el})))
+
+        ;;TODO: destroy
+        #())
+      [])
+
+    ;; highlights & annotations
+    (rum/use-effect!
+      (fn []
+        (js/console.debug "[rebuild loaded pages] " (:loaded-pages ano-state))
+        ;;(set-hls-state! (update-in hls-state [:dirties] inc))
+        ;; destroy
+        #())
+      [(:loaded-pages ano-state)])
+
+    ;; interaction events
+    (rum/use-effect!
+      (fn []
+        (js/console.debug "[rebuild interaction events]" (:viewer state))
+
+        (when-let [^js viewer (:viewer state)]
+          (let [^js el (rum/deref *el-ref)
+
+                fn-textlayer-ready
+                (fn [^js p]
+                  (js/console.debug "text layer ready" p)
+                  (set-ano-state! {:loaded-pages (conj (:loaded-pages ano-state) (.-pageNumber p))}))
+
+                fn-page-ready
+                (fn []
+                  (set! (. viewer -currentScaleValue) "auto"))]
+
+            (doto (.-eventBus viewer)
+              (.on "pagesinit" fn-page-ready)
+              (.on "textlayerrendered" fn-textlayer-ready))
+
+            #(do
+               (doto (.-eventBus viewer)
+                 (.off "pagesinit" fn-page-ready)
+                 (.off "textlayerrendered" fn-textlayer-ready))))))
+
+      [(:viewer state)
+       (:loaded-pages ano-state)])
+
+    [:div.extensions__pdf-viewer-cnt
+     [:div.extensions__pdf-viewer {:ref *el-ref}
+      [:div.pdfViewer "viewer pdf"]]
+
+     (if (:viewer state)
+       (pdf-highlights
+         (:el state) (:viewer state)
+         initial-hls
+         (:loaded-pages ano-state)))]))
+
+(rum/defc pdf-loader
+  [url]
+  (let [*doc-ref (rum/use-ref nil)
+        [state set-state!] (rum/use-state {:error nil :pdf-document nil :status nil})]
+
+    ;; load
+    (rum/use-effect!
+      (fn []
+        (let [get-doc$ (fn [^js opts] (.-promise (js/pdfjsLib.getDocument opts)))
+              own-doc (rum/deref *doc-ref)
+              opts {:url           url
+                    :ownerDocument js/document
+                    ;;:cMapUrl       "./js/pdfjs/cmaps/"
+                    :cMapUrl       "https://cdn.jsdelivr.net/npm/[email protected]/cmaps/"
+                    :cMapPacked    true}]
+
+          (p/finally
+            (p/catch (p/then
+                       (do
+                         (set-state! {:status :loading})
+                         (get-doc$ (clj->js opts)))
+                       #(do (js/console.log "+++" %)
+                            (set-state! {:pdf-document %})))
+                     #(set-state! {:error %}))
+            #(set-state! {:status :completed}))
+
+          #()))
+      [url])
+
+    [:div.extensions__pdf-loader {:ref *doc-ref}
+     (if (= (:status state) :loading)
+       [:h1 "Downloading PDF #" url]
+       (pdf-viewer url [] (:pdf-document state)))
+     [:h3 (str (:error state))]]))
+
+(rum/defc container
+  []
+  (let [[prepared set-prepared!] (rum/use-state false)]
+
+    ;; load assets
+    (rum/use-effect!
+      (fn []
+        (p/then
+          (pdf-utils/load-base-assets$)
+          (fn [] (set-prepared! true))))
+      [])
+
+    [:div.extensions__pdf-container.flex
+     (if prepared
+       (pdf-loader ACTIVE_FILE))]))
+
+(rum/defc playground
+  []
+  [:div.extensions__pdf-playground
+   (container)])

+ 0 - 158
src/main/frontend/extensions/pdf/impls.cljs

@@ -1,158 +0,0 @@
-(ns frontend.extensions.pdf.impls
-  (:require [rum.core :as rum]
-            [promesa.core :as p]
-            [frontend.extensions.pdf.utils :as pdf-utils]))
-
-(defonce ACTIVE_FILE "https://phx-nine.vercel.app/clojure-hopl-iv-final.pdf")
-
-(rum/defc viewer
-  [^js pdf-document]
-
-  (let [*el-ref (rum/create-ref)
-        [state, set-state!] (rum/use-state {:viewer nil :bus nil :link nil})
-        [sel-state, set-sel-state!] (rum/use-state {:range nil :collapsed nil :point nil})]
-
-    (rum/use-effect!
-     (fn [] (let [^js event-bus (js/pdfjsViewer.EventBus.)
-                  ^js link-service (js/pdfjsViewer.PDFLinkService. #js {:eventBus event-bus :externalLinkTarget 2})
-                  ^js el (rum/deref *el-ref)
-                  ^js viewer (js/pdfjsViewer.PDFViewer.
-                              #js {:container            el
-                                   :eventBus             event-bus
-                                   :linkService          link-service
-                                   :enhanceTextSelection true
-                                   :removePageBorders    true})]
-              (. link-service setDocument pdf-document)
-              (. link-service setViewer viewer)
-
-               ;; TODO: debug
-              (set! (. js/window -lsPdfViewer) viewer)
-
-              (p/then (. viewer setDocument pdf-document)
-                      #(set-state! {:viewer viewer :bus event-bus :link link-service})))
-
-        ;;TODO: destroy
-       #())
-     [])
-
-    ;; selection context menu
-    (rum/use-effect!
-     (fn []
-       (when-let [^js sel-range (and (not (:collapsed sel-state)) (:range sel-state))]
-         (when-let [page-info (pdf-utils/get-page-from-range sel-range)]
-           (when-let [sel-rects (pdf-utils/get-range-rects<-page-cnt sel-range (:page-el page-info))]
-             (let [^js point (:point sel-state)]
-
-                ;; TODO: debug
-               (js/console.debug "[Range] ====> [" page-info "]" (.toString sel-range) point)
-               (js/console.debug "[Rects] ====>" sel-rects))))))
-
-     [(:range sel-state)])
-
-    ;; interaction events
-    (rum/use-effect!
-     (fn []
-       (when-let [^js viewer (:viewer state)]
-         (let [^js el (rum/deref *el-ref)
-
-               fn-ready
-               (fn []
-                 (set! (. viewer -currentScaleValue) "auto"))
-
-               fn-selection-ok
-               (fn [^js/MouseEvent e]
-                 (let [^js/Selection selection (js/document.getSelection)
-                       ^js/Range sel-range (.getRangeAt selection 0)]
-
-                   (cond
-                     (.-isCollapsed selection)
-                     (set-sel-state! {:collapsed true})
-
-                     (and sel-range (.contains el (.-commonAncestorContainer sel-range)))
-                     (set-sel-state! {:collapsed false :range sel-range :point {:x (.-clientX e) :y (.-clientY e)}}))))
-
-               fn-selection
-               (fn []
-                 (let [*dirty (volatile! false)
-                       fn-dirty #(vreset! *dirty true)]
-
-                   (js/document.addEventListener "selectionchange" fn-dirty)
-                   (js/document.addEventListener "mouseup"
-                                                 (fn [^js e]
-                                                   (and @*dirty (fn-selection-ok e))
-                                                   (js/document.removeEventListener "selectionchange" fn-dirty))
-                                                 #js {:once true})))]
-
-           (doto (.-eventBus viewer)
-             (.on "pagesinit" fn-ready))
-
-           (doto el
-             (.addEventListener "mousedown" fn-selection))
-
-           #(do
-              (doto (.-eventBus viewer)
-                (.off "pagesinit" fn-ready))
-
-              (doto el
-                (.removeEventListener "mousedown" fn-selection))))))
-
-     [(:viewer state)])
-
-    [:div.extensions__pdf-viewer {:ref *el-ref}
-     [:div.pdfViewer "viewer pdf"]]))
-
-(rum/defc pdf-loader
-  [url]
-  (let [*doc-ref (rum/use-ref nil)
-        [state set-state!] (rum/use-state {:error nil :pdf-document nil :status nil})]
-
-    ;; load
-    (rum/use-effect!
-     (fn []
-       (let [get-doc$ (fn [^js opts] (.-promise (js/pdfjsLib.getDocument opts)))
-             own-doc (rum/deref *doc-ref)
-             opts {:url           url
-                   :ownerDocument js/document
-                    ;;:cMapUrl       "./js/pdfjs/cmaps/"
-                   :cMapUrl       "https://cdn.jsdelivr.net/npm/[email protected]/cmaps/"
-                   :cMapPacked    true}]
-
-         (p/finally
-           (p/catch (p/then
-                     (do
-                       (set-state! {:status :loading})
-                       (get-doc$ (clj->js opts)))
-                     #(do (js/console.log "+++" %)
-                          (set-state! {:pdf-document %})))
-                    #(set-state! {:error %}))
-           #(set-state! {:status :completed}))
-
-         #()))
-     [url])
-
-    [:div.extensions__pdf-loader {:ref *doc-ref}
-     (if (= (:status state) :loading)
-       [:h1 "Downloading PDF #" url]
-       (viewer (:pdf-document state)))
-     [:h3 (str (:error state))]]))
-
-(rum/defc container
-  []
-  (let [[prepared set-prepared!] (rum/use-state false)]
-
-    ;; load assets
-    (rum/use-effect!
-     (fn []
-       (p/then
-        (pdf-utils/load-base-assets$)
-        (fn [] (set-prepared! true))))
-     [])
-
-    [:div.extensions__pdf-container.flex
-     (if prepared
-       (pdf-loader ACTIVE_FILE))]))
-
-(rum/defc playground
-  []
-  [:div.extensions__pdf-playground
-   (container)])

+ 20 - 2
src/main/frontend/extensions/pdf/pdf.css

@@ -28,10 +28,28 @@ body[data-page=home] {
   }
 
   .extensions__pdf-loader {
-    width: 900px;
+    width: 100%;
   }
 
-  .extensions__pdf-container {
+  .extensions__pdf {
+    &-viewer {
+      left: 0;
+      width: 900px;
+    }
+
+    &-viewer-cnt {
+      display: flex;
+      padding-left: 950px;
+      border-top: 1px solid #eee;
+    }
+
+    &-highlights {
+      padding-right: 40px;
+      pre {
+        white-space: pre-wrap;
+        font-size: 12px;
+      }
+    }
   }
 }
 

+ 38 - 4
src/main/frontend/extensions/pdf/utils.cljs

@@ -1,12 +1,46 @@
 (ns frontend.extensions.pdf.utils
   (:require [promesa.core :as p]
+            [cljs-bean.core :as bean]
+            ["/frontend/extensions/pdf/utils" :as js-utils]
             [frontend.loader :refer [load]]))
 
+(defn get-bounding-rect
+  [rects]
+  (bean/->clj (js-utils/getBoundingRect (bean/->js rects))))
+
+(defn viewport-to-scaled
+  [bounding ^js viewport]
+  (bean/->clj (js-utils/viewportToScaled (bean/->js bounding) viewport)))
+
+(defn scaled-to-viewport
+  [bounding ^js viewport]
+  (bean/->clj (js-utils/scaledToViewport (bean/->js bounding) viewport)))
+
+
+(defn vw-to-scaled-pos
+  [^js viewer {:keys [page bounding rects]}]
+  (when-let [^js viewport (.. viewer (getPageView (dec page)) -viewport)]
+    {:bounding (viewport-to-scaled bounding viewport)
+     :rects    (for [rect rects] (viewport-to-scaled rect viewport))
+     :page     page}))
+
+(defn scaled-to-vw-pos
+  [^js viewer {:keys [page bounding rects]}]
+  (when-let [^js viewport (.. viewer (getPageView (dec page)) -viewport)]
+    {:bounding (scaled-to-viewport bounding viewport)
+     :rects    (for [rect rects] (scaled-to-viewport rect viewport))
+     :page     page}))
+
+
+(defn gen-id []
+  (str (.toString (js/Date.now) 36)
+       (.. (js/Math.random) (toString 36) (substr 2 4))))
+
 (defn js-load$
   [url]
   (p/create
-   (fn [resolve]
-     (load url resolve))))
+    (fn [resolve]
+      (load url resolve))))
 
 (defn load-base-assets$
   []
@@ -16,8 +50,8 @@
 (defn get-page-from-el
   [^js/HTMLElement el]
   (when-let [^js page-el (and el (.closest el ".page"))]
-    {:page-number  (.. page-el -dataset -pageNumber)
-     :page-el page-el}))
+    {:page-number (.. page-el -dataset -pageNumber)
+     :page-el     page-el}))
 
 (defn get-page-from-range
   [^js/Range r]

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

@@ -0,0 +1,94 @@
+export const viewportToScaled = (
+  rect,
+  { width, height }
+) => {
+  return {
+    x1: rect.left,
+    y1: rect.top,
+
+    x2: rect.left + rect.width,
+    y2: rect.top + rect.height,
+
+    width,
+    height,
+  }
+}
+
+const pdfToViewport = (pdf, viewport) => {
+  const [x1, y1, x2, y2] = viewport.convertToViewportRectangle([
+    pdf.x1,
+    pdf.y1,
+    pdf.x2,
+    pdf.y2,
+  ])
+
+  return {
+    left: x1,
+    top: y1,
+
+    width: x2 - x1,
+    height: y1 - y2,
+  }
+}
+
+export const scaledToViewport = (
+  scaled,
+  viewport,
+  usePdfCoordinates = false
+) => {
+  const { width, height } = viewport
+
+  if (usePdfCoordinates) {
+    return pdfToViewport(scaled, viewport)
+  }
+
+  if (scaled.x1 === undefined) {
+    throw new Error('You are using old position format, please update')
+  }
+
+  const x1 = (width * scaled.x1) / scaled.width
+  const y1 = (height * scaled.y1) / scaled.height
+
+  const x2 = (width * scaled.x2) / scaled.width
+  const y2 = (height * scaled.y2) / scaled.height
+
+  return {
+    left: x1,
+    top: y1,
+    width: x2 - x1,
+    height: y2 - y1,
+  }
+}
+
+export const getBoundingRect = (clientRects) => {
+  const rects = Array.from(clientRects).map(rect => {
+    const { left, top, width, height } = rect
+
+    const X0 = left
+    const X1 = left + width
+
+    const Y0 = top
+    const Y1 = top + height
+
+    return { X0, X1, Y0, Y1 }
+  })
+
+  const optimal = rects.reduce((res, rect) => {
+    return {
+      X0: Math.min(res.X0, rect.X0),
+      X1: Math.max(res.X1, rect.X1),
+
+      Y0: Math.min(res.Y0, rect.Y0),
+      Y1: Math.max(res.Y1, rect.Y1),
+    }
+  }, rects[0])
+
+  const { X0, X1, Y0, Y1 } = optimal
+
+  return {
+    left: X0,
+    top: Y0,
+    width: X1 - X0,
+    height: Y1 - Y0,
+  }
+}