Share code snippet - a clickable table of contents sidebar

This is a sidebar that displays the page’s table of contents structure and supports navigation. There are some plugins in the community that can display the table of contents, but I wanted one that could be fixed on the right side and support hiding, so I used AI to write one.

demo-ezgif.com-resize

I’d like to share this code. I’m not familiar with Clojure and Logseq code, so I used AI to help me write it. The code may not follow best practices and is based on version 0.10.15, specifically this commit.

Check out and apply this git patch:

Subject: [PATCH] sidebar with toc, clickable
---
Index: src/main/frontend/components/container.cljs
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/main/frontend/components/container.cljs b/src/main/frontend/components/container.cljs
--- a/src/main/frontend/components/container.cljs	(revision 79b6af91c081a47d415eae7a3045b11d9ec07278)
+++ b/src/main/frontend/components/container.cljs	(revision e489b9999e2146db9d663696db798c5395a64ed7)
@@ -594,6 +594,120 @@
     {:style {:bottom (+ @util/keyboard-height 45)}}
     (footer/audio-record-cp)]])

+;; ========================================
+;; Start - current page heading sidebar
+;; ========================================
+(defn- current-page-name-from-route
+  [route-match]
+  (let [route-name (get-in route-match [:data :name])
+        page-name (get-in route-match [:path-params :name])]
+    (when (and (#{:page :page-block} route-name)
+               (string? page-name))
+      (if (util/uuid-string? page-name)
+        (some-> (db/entity [:block/uuid (uuid page-name)])
+                :block/page
+                :block/name)
+        (util/safe-page-name-sanity-lc page-name)))))
+
+(defn- heading-level
+  [block]
+  (let [heading (get-in block [:block/properties :heading])]
+    (cond
+      (number? heading)
+      (-> heading int (max 1) (min 6))
+
+      (true? heading)
+      (-> (or (:block/level block) 0) inc (max 1) (min 6))
+
+      :else
+      nil)))
+
+(defn- heading-title
+  [content]
+  (some-> content
+          (string/split #"\n" 2)
+          first
+          (string/replace #"^#{1,6}\s*" "")
+          string/trim
+          not-empty))
+
+(defn- current-page-headings
+  [route-match]
+  (let [repo (state/get-current-repo)
+        page-name (current-page-name-from-route route-match)
+        page-entity (when page-name (db/entity [:block/name page-name]))
+        blocks (when-let [page-id (:db/id page-entity)]
+                 (db/get-paginated-blocks repo page-id))]
+    (->> blocks
+         (keep (fn [block]
+                 (when-let [level (heading-level block)]
+                   (when-let [title (heading-title (:block/content block))]
+                     {:uuid (:block/uuid block)
+                      :title title
+                      :level level})))))))
+
+(defn- jump-to-page-heading!
+  [page-name block-uuid]
+  (when (and page-name block-uuid)
+    (route-handler/redirect-to-page!
+     page-name
+     {:anchor (str "block-content-" block-uuid)
+      :push false})))
+
+(rum/defc current-page-heading-sidebar < rum/reactive
+  [route-match custom-fixed-right]
+  (let [page-name (current-page-name-from-route route-match)
+        headings (current-page-headings route-match)]
+    (when (and page-name (seq headings))
+      [:div#custom-fixed-div
+       {:style {:position "fixed"
+                :top "50%"
+                :right custom-fixed-right
+                :transform "translateY(-50%)"
+                :z-index 50}}
+       [:div.group.relative.flex.items-center.justify-end
+        {:style {:height "50vh"}}
+
+        ;; Collapsed handle: left arrow triangle
+        [:div.flex.items-center
+         {:style {:padding "4px 4px 4px 4px"}}
+         [:span
+          {:aria-hidden true
+           :style {:width 0
+                   :height 0
+                   :border-top "8px solid transparent"
+                   :border-bottom "8px solid transparent"
+                   :border-right "10px solid var(--ls-secondary-text-color)"
+                   :opacity 0.4}}]]
+
+        [:div
+         {:class "absolute top-1/2 transition-all duration-250 -translate-y-1/2 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
+          :style {:right "2px"
+                  :width "220px"
+                  :max-height "50vh"
+                  :overflow "auto"
+                  :padding "8px"
+                  :background-color "var(--ls-primary-background-color)"
+                  :border-radius "10px"
+                  :border "1px solid var(--ls-border-color)"
+                  :box-shadow "0 4px 12px rgba(0,0,0,0.12)"}}
+         [:div.text-xs.text-center.font-medium.opacity-70.mb-2 "目录"]
+         [:div.flex.flex-col.gap-1
+          (for [{:keys [uuid title level]} headings]
+            [:a.text-sm
+             {:key (str "heading-" uuid)
+              :class "truncate hover:underline"
+              :style {:padding-left (str (* (dec level) 10) "px")}
+              :on-click (fn [e]
+                          (util/stop e)
+                          (jump-to-page-heading! page-name uuid))}
+             title])]
+        ]
+       ]])))
+;; ========================================
+;; End -  current page heading sidebar
+;; ========================================
+
 (rum/defc main <
   {:did-mount (fn [state]
                 (when-let [element (gdom/getElement "app-container")]
@@ -617,13 +731,18 @@
         onboarding-and-home? (and (or (nil? (state/get-current-repo)) (config/demo-graph?))
                                   (not config/publishing?)
                                   (= :home route-name))
-        margin-less-pages?   (or (and (mobile-util/native-platform?) onboarding-and-home?) margin-less-pages?)]
-    [:div#main-container.cp__sidebar-main-layout.flex-1.flex
-     {:class (util/classnames [{:is-left-sidebar-open left-sidebar-open?}])}
+        margin-less-pages?   (or (and (mobile-util/native-platform?) onboarding-and-home?) margin-less-pages?)
+        right-sidebar-open?   (state/sub :ui/sidebar-open?)
+        right-sidebar-width    (state/sub :ui/sidebar-width)
+        custom-fixed-right     (if right-sidebar-open?
+                                 (str "calc(" right-sidebar-width " + 12px)")
+                                 "12px")]
+     [:div#main-container.cp__sidebar-main-layout.flex-1.flex
+      {:class (util/classnames [{:is-left-sidebar-open left-sidebar-open?}])}

-     ;; desktop left sidebar layout
-     (left-sidebar {:left-sidebar-open? left-sidebar-open?
-                    :route-match        route-match})
+      ;; desktop left sidebar layout
+      (left-sidebar {:left-sidebar-open? left-sidebar-open?
+                     :route-match        route-match})

      [:div#main-content-container.scrollbar-spacing.w-full.flex.justify-center.flex-row.outline-none.relative

@@ -667,7 +786,12 @@
           main-content])

        (when onboarding-and-home?
-         (onboarding/intro onboarding-and-home?))]]]))
+         (onboarding/intro onboarding-and-home?))
+
+       ;; ------- side bar --------
+       (current-page-heading-sidebar route-match custom-fixed-right)
+
+         ]]]))

 (defonce sidebar-inited? (atom false))
 ;; TODO: simplify logic
@@ -995,3 +1119,4 @@
       (when (and (not config/mobile?)
                  (not config/publishing?))
         (help-button))])))
+

I initially used the master version, which is already at 2.0, and wrote something similar. However, after trying it out, I found that I wasn’t quite used to the new version, so I recreated this based on version 0.10.15. The development took about two to three hours in total, plus some additional time to resolve compilation errors and packaging issues. The AI I used was Github Copilot’s agent model, specifically the GPT Codex 5.3 model. I recommend everyone try using AI to develop features they want. For some simple features, using AI can help implement them without spending too much time, and if it’s just for personal use, it doesn’t need to be perfectly polished.