Merge branch 'master' into enhance/assets-improvements

This commit is contained in:
charlie
2025-12-08 15:58:52 +08:00
22 changed files with 724 additions and 136 deletions

View File

@@ -133,7 +133,11 @@
(when-let [f @*transact-invalid-callback]
(f tx-report errors))
(throw (ex-info "DB write failed with invalid data" {:tx-data tx-data
:pipeline-tx-data (:tx-data tx-report)}))))
:errors errors
:pipeline-tx-data (map
(fn [[e a v t]]
[e a v t])
(:tx-data tx-report))}))))
tx-report)
(d/transact! conn tx-data tx-meta)))
(catch :default e

View File

@@ -8,15 +8,73 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
var window: UIWindow?
var navController: UINavigationController?
// ---------------------------------------------------------
// MARK: Multi-stack routing state
// ---------------------------------------------------------
/// Currently active logical stack id (must match CLJS :stack, e.g. "home", "capture", "goto").
private var activeStackId: String = "home"
/// Per-stack path stacks, including the active one.
/// Example: ["home": ["/", "/page/A"], "capture": ["/__stack__/capture"]]
private var stackPathStacks: [String: [String]] = [
"home": ["/"]
]
/// Mirror of the active stack's paths.
private var pathStack: [String] = ["/"]
private var ignoreRoutePopCount = 0
/// Used to ignore JS-driven pops when we're popping in response to a native gesture.
private var ignoreRoutePopCount: Int = 0
/// Temporary snapshot image for smooth pop transitions.
private var popSnapshotView: UIView?
// Each stack has its own native VC stack, just like paths.
private var stackViewControllerStacks: [String: [UIViewController]] = [:]
// ---------------------------------------------------------
// MARK: Helpers
// ---------------------------------------------------------
private func normalizedPath(_ raw: String?) -> String {
guard let raw = raw, !raw.isEmpty else { return "/" }
return raw
}
/// Returns the current native path stack for a given logical stack id,
/// or initialises a sensible default if none exists yet.
private func paths(for stackId: String) -> [String] {
if let existing = stackPathStacks[stackId], !existing.isEmpty {
return existing
}
if stackId == "home" {
return ["/"]
} else {
// Virtual stacks (e.g. capture, search, goto) default to a stack-root path.
return ["/__stack__/\(stackId)"]
}
}
/// Updates the stored paths for a given stack id and keeps `pathStack`
/// consistent if this is the active stack.
private func setPaths(_ paths: [String], for stackId: String) {
stackPathStacks[stackId] = paths
if stackId == activeStackId {
pathStack = paths
}
}
private func setViewControllers(_ vcs: [UIViewController], for stackId: String) {
stackViewControllerStacks[stackId] = vcs
}
private func viewControllers(for stackId: String) -> [UIViewController] {
stackViewControllerStacks[stackId] ?? []
}
// ---------------------------------------------------------
// MARK: UIApplication lifecycle
// ---------------------------------------------------------
@@ -124,7 +182,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
}
// ---------------------------------------------------------
// MARK: Navigation operations
// MARK: Navigation operations (within active stack)
// ---------------------------------------------------------
private func emptyNavStack(path: String) {
@@ -137,10 +195,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
let vc = NativePageViewController(path: path, push: false)
pathStack = [path]
setPaths(pathStack, for: activeStackId)
nav.setViewControllers([vc], animated: false)
SharedWebViewController.instance.clearPlaceholder()
SharedWebViewController.instance.attach(to: vc)
}
private func pushIfNeeded(path: String, animated: Bool) {
@@ -154,7 +214,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
let vc = NativePageViewController(path: path, push: true)
pathStack.append(path)
setPaths(pathStack, for: activeStackId)
nav.pushViewController(vc, animated: animated)
}
private func replaceTop(path: String) {
@@ -162,9 +225,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
guard let nav = navController else { return }
_ = pathStack.popLast()
let vc = NativePageViewController(path: path, push: false)
pathStack.append(path)
setPaths(pathStack, for: activeStackId)
let vc = NativePageViewController(path: path, push: false)
var stack = nav.viewControllers
if stack.isEmpty {
stack = [vc]
@@ -172,6 +236,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
stack[stack.count - 1] = vc
}
nav.setViewControllers(stack, animated: false)
}
private func popIfNeeded(animated: Bool) {
@@ -179,6 +244,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
if nav.viewControllers.count > 1 {
_ = pathStack.popLast()
setPaths(pathStack, for: activeStackId)
nav.popViewController(animated: animated)
}
}
@@ -208,13 +274,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
if isPop {
// -----------------------------
// POP keep your existing logic
// POP update per-stack pathStack, then notify JS.
// -----------------------------
let previousStack = pathStack
if pathStack.count > 1 { _ = pathStack.popLast() }
if pathStack.count > 1 {
_ = pathStack.popLast()
}
if let last = pathStack.last, last != toVC.targetPath {
pathStack[pathStack.count - 1] = toVC.targetPath
}
setPaths(pathStack, for: activeStackId)
popSnapshotView?.removeFromSuperview()
popSnapshotView = nil
@@ -227,9 +297,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
popSnapshotView = iv
}
coordinator.animate(alongsideTransition: nil) { ctx in
coordinator.animate(alongsideTransition: nil) { [weak self] ctx in
guard let self else { return }
guard !ctx.isCancelled else {
self.pathStack = previousStack
self.setPaths(previousStack, for: self.activeStackId)
if let fromVC {
SharedWebViewController.instance.attach(to: fromVC)
}
@@ -237,12 +311,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
return
}
if let webView = SharedWebViewController.instance.bridgeController.bridge?.webView,
webView.canGoBack {
self.ignoreRoutePopCount += 1
webView.goBack()
} else {
self.ignoreRoutePopCount += 1
// 🔑 DO NOT call webView.goBack().
// Tell JS explicitly that native popped.
self.ignoreRoutePopCount += 1
if let bridge = SharedWebViewController.instance.bridgeController.bridge {
let js = "window.LogseqNative && window.LogseqNative.onNativePop && window.LogseqNative.onNativePop();"
bridge.webView?.evaluateJavaScript(js, completionHandler: nil)
}
SharedWebViewController.instance.attach(
@@ -264,8 +339,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
// -----------------------------
// PUSH / RESET
// -----------------------------
// Attach the shared webview to the *destination* page
// before/during the animation so it can start rendering immediately.
SharedWebViewController.instance.attach(
to: toVC,
leavePlaceholderInPreviousParent: fromVC != nil
@@ -273,10 +346,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
coordinator.animate(alongsideTransition: nil) { ctx in
if ctx.isCancelled, let fromVC {
// If the push is cancelled (interactive back), put the webview back.
SharedWebViewController.instance.attach(to: fromVC)
} else {
// Transition completed clear any placeholders.
SharedWebViewController.instance.clearPlaceholder()
}
}
@@ -294,7 +365,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
SharedWebViewController.instance.clearPlaceholder()
SharedWebViewController.instance.attach(to: current)
}
}
func navigationController(
@@ -308,20 +378,103 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
}
// ---------------------------------------------------------
// MARK: Route Observation
// MARK: Route Observation (JS -> Native)
// ---------------------------------------------------------
private func observeRouteChanges() {
NotificationCenter.default.addObserver(
forName: UILocalPlugin.routeChangeNotification,
object: nil,
queue: .main
forName: UILocalPlugin.routeChangeNotification,
object: nil,
queue: .main
) { [weak self] notification in
guard let self else { return }
guard let nav = self.navController else { return }
let path = self.normalizedPath(notification.userInfo?["path"] as? String)
let rawPath = notification.userInfo?["path"] as? String
let path = self.normalizedPath(rawPath)
let navigationType = (notification.userInfo?["navigationType"] as? String) ?? "push"
let stackId = (notification.userInfo?["stack"] as? String) ?? "home"
let previousStackId = self.activeStackId
// 🚫 Fast-path: ignore duplicate replace for same stack/path
if stackId == self.activeStackId,
navigationType == "replace",
path == self.pathStack.last {
return
}
// Fast-path: cancel search home root.
// We do NOT rebuild nav stack and we do NOT reattach the WebView.
// JS will just navigate the existing shared WKWebView to "/".
if previousStackId == "search",
stackId == "home"{
// Just update bookkeeping so future home pushes/pop work correctly.
self.activeStackId = "home"
self.setPaths(["/"], for: "home")
self.setPaths(["/__stack__/search"], for: "search")
nav.setViewControllers([], animated: false)
self.setViewControllers([], for: "home")
// 👈 Do NOTHING to nav.viewControllers or SharedWebViewController here.
return
}
// ============================================
// 1 Stack switch: home search capture...
// ============================================
if stackId != self.activeStackId {
self.setPaths(self.pathStack, for: previousStackId)
// Load saved paths for target stack
var newPaths = self.paths(for: stackId)
// 🔑 Special rules for shaping the new stack
if stackId == "home", path == "/" {
// 👉 ALWAYS reset home to a single root VC.
newPaths = ["/"]
} else if newPaths.isEmpty {
// First time visiting this stack
newPaths = [path]
} else if let last = newPaths.last, last != path {
// Same history, but different top path align the top.
newPaths[newPaths.count - 1] = path
}
self.activeStackId = stackId
self.pathStack = newPaths
self.setPaths(newPaths, for: stackId)
// Rebuild native stack for these paths
var vcs: [UIViewController] = []
for (idx, p) in newPaths.enumerated() {
let vc = NativePageViewController(path: p, push: idx > 0)
vcs.append(vc)
}
nav.setViewControllers(vcs, animated: false)
self.setViewControllers(vcs, for: stackId)
if let lastVC = vcs.last as? NativePageViewController {
// Defer & avoid redundant attach.
DispatchQueue.main.async {
if let bridge = SharedWebViewController.instance.bridgeController.bridge,
let webView = bridge.webView,
webView.isDescendant(of: lastVC.view) {
} else {
SharedWebViewController.instance.attach(to: lastVC)
}
SharedWebViewController.instance.clearPlaceholder()
}
}
return
}
// ============================================
// 2 Navigation *within* active stack
// ============================================
switch navigationType {
case "reset":
self.emptyNavStack(path: path)
@@ -334,7 +487,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
self.ignoreRoutePopCount -= 1
return
}
if self.pathStack.count > 1 {
self.popIfNeeded(animated: true)
}
@@ -344,6 +496,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UINavigationControllerDel
}
}
}
}
// ---------------------------------------------------------
@@ -372,6 +525,10 @@ extension NSUserActivity {
}
}
// ---------------------------------------------------------
// MARK: Convenience
// ---------------------------------------------------------
extension AppDelegate {
func donateQuickAddShortcut() {
let a = NSUserActivity.quickAdd

View File

@@ -320,7 +320,6 @@ private struct SearchTabHost26: View {
wasSearching = false
selectedTab.wrappedValue = .content(0)
store.selectedId = firstId
LiquidTabsPlugin.shared?.notifyTabSelected(id: firstId)
}
}
}

View File

@@ -644,26 +644,30 @@ private func scoreTranscript(_ text: String, locale: Locale) -> Int {
}
@objc func routeDidChange(_ call: CAPPluginCall) {
let route = call.getObject("route") as? [String: Any]
let path = call.getString("path")
let push = call.getBool("push") ?? true
let navigationType = call.getString("navigationType") ?? (push ? "push" : "replace")
let navigationType = call.getString("navigationType") ?? "push"
let push = call.getBool("push") ?? (navigationType == "push")
let path = call.getString("path") ?? "/"
var entry: [String: Any] = [:]
if let path = path {
entry["path"] = path
}
if let route = route {
entry["route"] = route
}
entry["push"] = push
entry["navigationType"] = navigationType
// read stack from JS, default to "home" only if missing
let stack = call.getString("stack") ?? "home"
NotificationCenter.default.post(
name: UILocalPlugin.routeChangeNotification,
object: nil,
userInfo: entry
)
#if DEBUG
print("📬 UILocal.routeDidChange call from JS")
print(" navigationType=\(navigationType) push=\(push) stack=\(stack) path=\(path)")
#endif
DispatchQueue.main.async {
NotificationCenter.default.post(
name: UILocalPlugin.routeChangeNotification,
object: nil,
userInfo: [
"navigationType": navigationType,
"push": push,
"stack": stack, // 👈 forward it
"path": path
]
)
}
call.resolve()
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,200 @@
(ns logseq.tasks.db-graph.create-graph-with-clojuredocs
"Script that convert clojuredocs-export.json into logseq graph data."
(:require ["fs" :as fs]
[babashka.cli :as cli]
[cljs.pprint :as pp]
[clojure.edn :as edn]
[clojure.string :as string]
[datascript.core :as d]
[logseq.db.common.sqlite-cli :as sqlite-cli]
[logseq.outliner.cli :as outliner-cli]
[nbb.classpath :as cp]
[nbb.core :as nbb]))
(def spec
"Options spec"
{:help {:alias :h
:desc "Print help"}
:config {:alias :c
:coerce edn/read-string
:desc "EDN map to add to config.edn"}
:export {:alias :e
:desc "Exports graph to clojuredocs.edn"}
:verbose {:alias :v
:desc "Verbose mode"}})
(defn example=>block
[author->block-uuid example]
(when-let [body (:body example)]
(let [author-block-uuid (author->block-uuid (:login (:author example)))]
{:block/title body
:build/tags [:logseq.class/Code-block]
:build/properties (cond-> {:logseq.property.node/display-type :code
:logseq.property.code/lang "Clojure"}
author-block-uuid
(assoc :user.property/author-X_lTJqwD [:block/uuid author-block-uuid]))})))
(defn convert-var-to-page
[type->block-uuid author->block-uuid
{:keys [ns name type see-alsos examples notes arglists doc library-url] :as _clj-var}]
{:page
{:build/properties
{:user.property/library-url-ip8W5T7M library-url
:user.property/type-Un-Aypix
[:block/uuid (type->block-uuid type)]},
:block/title (str "`" name "` (" ns ")")}
:blocks
[{:block/title "Doc",
:build/properties {:logseq.property/heading 3},
:build/children
(mapv
(fn [line]
{:block/title line
:build/properties {}})
(string/split-lines doc))}
{:block/title "Examples",
:build/properties {:logseq.property/heading 3},
:build/children
(vec (keep (partial example=>block author->block-uuid) examples))}
{:block/title "Notes",
:build/properties {:logseq.property/heading 3}
:build/children
(mapv
(fn [note]
(let [body (or (:body note) "")
author-block-uuid (author->block-uuid (:login (:author note)))]
{:block/title body
:build/tags [:logseq.class/Quote-block]
:build/properties (cond-> {:logseq.property.node/display-type :quote}
author-block-uuid
(assoc :user.property/author-X_lTJqwD [:block/uuid author-block-uuid]))}))
notes)}
{:block/title "Arglists",
:build/properties {:logseq.property/heading 3}
:build/children
(vec
(when-let [arglists-content (with-out-str (pp/pprint arglists))]
[{:block/title arglists-content
:build/tags [:logseq.class/Code-block]
:build/properties {:logseq.property.node/display-type :code
:logseq.property.code/lang "Clojure"}}]))}
;; {:block/title "See also",
;; :build/properties {:logseq.property/heading 3}
;; :build/children
;; (vec
;; (when-let [arglists-content (with-out-str (pp/pprint see-alsos))]
;; [{:block/title arglists-content
;; :build/tags [:logseq.class/Code-block]
;; :build/properties {:logseq.property.node/display-type :code
;; :logseq.property.code/lang "Clojure"}}]))}
]})
(defn convert-type-pages
"return {:pages ..., :type->block-uuid ...}"
[clj-vars]
(let [all-types (set (map :type clj-vars))
type->block-uuid
(into {} (map (fn [tp] [tp (random-uuid)])) all-types)
pages
(map
(fn [[tp block-uuid]]
{:page
{:block/uuid block-uuid
:build/keep-uuid? true
:block/title tp},
:blocks
[]})
type->block-uuid)]
{:pages pages :type->block-uuid type->block-uuid}))
(defn convert-author-pages
"return {:pages ..., :author->block-uuid ...}"
[clj-vars]
(let [author-name (comp :login :author)
author-names
(set
(mapcat
(fn [clj-var]
(set
(map
author-name
(concat (:see-alsos clj-var)
(:examples clj-var)
(:notes clj-var)))))
clj-vars))
author->block-uuid
(into {} (map (fn [author] [author (random-uuid)])) author-names)
pages
(map
(fn [[author block-uuid]]
{:page
{:block/uuid block-uuid
:build/keep-uuid? true
:block/title author}
:blocks
[]})
author->block-uuid)]
{:pages pages :author->block-uuid author->block-uuid}))
(def properties
{:user.property/type-Un-Aypix
{:logseq.property/type :node,
:block/title "type",
:db/cardinality :db.cardinality/one},
:user.property/library-url-ip8W5T7M
{:db/cardinality :db.cardinality/one,
:logseq.property/type :url,
:block/title "library-url"}
:user.property/author-X_lTJqwD
{:logseq.property/type :node
:block/title "author"
:db/cardinality :db.cardinality/one}})
(defn convert
[clj-vars]
(let [{type-pages :pages type->block-uuid :type->block-uuid} (convert-type-pages clj-vars)
{author-pages :pages author->block-uuid :author->block-uuid} (convert-author-pages clj-vars)]
{:properties properties
:classes {}
:pages-and-blocks
(vec
(concat
type-pages
author-pages
(map (partial convert-var-to-page type->block-uuid author->block-uuid) clj-vars)))}))
(comment
(def clojuredocs-json (js->clj (js/JSON.parse (fs/readFileSync "resources/clojuredocs-export.json"))
:keywordize-keys true))
(def result (convert (filter #(= "clojure.core" (:ns %)) (:vars clojuredocs-json))))
(fs/writeFileSync "cljdocs.edn" (with-out-str (pp/pprint result))))
(defn -main [args]
(let [[graph-dir] args
options (cli/parse-opts args {:spec spec})
_ (when (or (nil? graph-dir) (:help options))
(println (str "Usage: $0 GRAPH-NAME [OPTIONS]\nOptions:\n"
(cli/format-opts {:spec spec})))
(js/process.exit 1))
init-conn-args (sqlite-cli/->open-db-args graph-dir)
db-name (if (= 1 (count init-conn-args)) (first init-conn-args) (second init-conn-args))
conn (apply outliner-cli/init-conn
(conj init-conn-args {:additional-config (:config options)
:classpath (cp/get-classpath)}))
clojuredocs-json (js->clj (js/JSON.parse (fs/readFileSync "resources/clojuredocs-export.json"))
:keywordize-keys true)
clj-vars (:vars clojuredocs-json)
result-edn (convert clj-vars)]
(println "Generating" (count (:pages-and-blocks result-edn)) "pages")
(if (:export options)
(do (println "Generating clojuredocs.edn ...")
(fs/writeFileSync "clojuredocs.edn" (with-out-str (pp/pprint result-edn))))
(do (println "Transacting to graph ...")
(let [{:keys [init-tx block-props-tx]} (outliner-cli/build-blocks-tx result-edn)]
(d/transact! conn init-tx)
(d/transact! conn block-props-tx)
(println "Transacted" (count (d/datoms @conn :eavt)) "datoms")
(println "Created graph " (str "'" db-name "'") "!"))))))
(when (= nbb/*file* (nbb/invoked-file))
(-main *command-line-args*))

View File

@@ -58,11 +58,8 @@
"Quick add"]
add-button])
(if mobile?
[:main#app-container-wrapper.ls-fold-button-on-right
[:div#app-container.pt-2
[:div#main-container.flex.flex-1
[:div.w-full.mt-4
(page-blocks add-page)]]]]
[:div.w-full.mt-4
(page-blocks add-page)]
[:div.content {:class "block -ml-6"}
(page-blocks add-page)])
(when-not mobile? add-button)]))))

View File

@@ -3,7 +3,6 @@
[clojure.string :as string]
[dommy.core :refer-macros [sel by-id]]
[frontend.config :as config]
[frontend.handler.notification :as notification]
[frontend.handler.route :as route-handler]
[frontend.handler.user :as user]
[frontend.modules.shortcut.core :as shortcut]
@@ -38,15 +37,14 @@
(rum/defc user-pane
[_sign-out! user]
(let [session (:signInUserSession user)
username (:username user)]
(let [session (:signInUserSession user)]
(hooks/use-effect!
(fn []
(when session
(user/login-callback session)
(notification/show! (str "Hi, " username " :)") :success)
(shui/dialog-close!)
(shui/popup-hide!)
(when (= :user-login (state/get-current-route))
(route-handler/redirect! {:to :home}))))
[])

View File

@@ -1583,7 +1583,7 @@
"Save incoming(pasted) assets to assets directory.
Returns: asset entities"
[repo files & {:keys [pdf-area? last-edit-block]}]
[repo files & {:keys [pdf-area? last-edit-block save-to-page]}]
(p/let [[repo-dir asset-dir-rpath] (assets-handler/ensure-assets-dir! repo)
today-page-name (date/today)
today-page-e (db-model/get-journal-page today-page-name)
@@ -1598,8 +1598,12 @@
blocks (remove nil? blocks*)
edit-block (or (state/get-edit-block) last-edit-block)
insert-to-current-block-page? (and (:block/uuid edit-block) (not pdf-area?))
target (if insert-to-current-block-page?
target (cond
insert-to-current-block-page?
edit-block
save-to-page
save-to-page
:else
today-page)]
(when-not target
(throw (ex-info "invalid target" {:files files
@@ -1610,6 +1614,7 @@
(ui-outliner-tx/transact!
{:outliner-op :insert-blocks}
(outliner-op/insert-blocks! blocks target {:keep-uuid? true
:bottom? true
:sibling? (= edit-block target)
:replace-empty-target? true}))
(map (fn [b] (db/entity [:block/uuid (:block/uuid b)])) blocks)))))

View File

@@ -192,7 +192,7 @@
(js/console.error "instrument data-map should only contains [:type :payload]"))
(posthog/capture type payload))
(defmethod handle :capture-error [[_ {:keys [error payload]}]]
(defmethod handle :capture-error [[_ {:keys [error payload extra]}]]
(let [[user-uuid graph-uuid tx-id] @sync/graphs-txid
payload (merge
{:schema-version (str db-schema/version)
@@ -204,7 +204,8 @@
:db-based (config/db-based-graph? (state/get-current-repo))}
payload)]
(Sentry/captureException error
(bean/->js {:tags payload}))))
(bean/->js {:tags payload
:extra extra}))))
(defmethod handle :exec-plugin-cmd [[_ {:keys [pid cmd action]}]]
(commands/exec-plugin-simple-command! pid cmd action))

View File

@@ -931,7 +931,8 @@
[["Invalid DB!"] :error])
(worker-util/post-message :capture-error
{:error (ex-info "Invalid DB" {})
:payload {:errors (str errors)}})))
:payload {}
:extra {:errors (str errors)}})))
(defn init
"web worker entry"

View File

@@ -6,7 +6,6 @@
[frontend.state :as state]
[frontend.util :as util]
[logseq.common.util :as common-util]
[mobile.navigation :as mobile-nav]
[mobile.state :as mobile-state]))
;; Capacitor plugin instance:
@@ -98,8 +97,9 @@
(js/console.log "Native search query" q)
(reset! mobile-state/*search-input q)
(reset! mobile-state/*search-last-input-at (common-util/time-ms))
(when (= :page (state/get-current-route))
(mobile-nav/reset-route!))))
(comment
(when (= :page (state/get-current-route))
(mobile-nav/reset-route!)))))
(add-keyboard-hack-listener!)))
(defn configure

View File

@@ -30,8 +30,7 @@
(rum/defc journals
[]
(ui-component/classic-app-container-wrap
(journal/all-journals)))
(journal/all-journals))
(rum/defc home-inner < rum/static
[db-restoring?]
@@ -118,13 +117,13 @@
;; - Journals layer keeps its own scroll container and is always in the DOM.
;; - Page/other-tab layer keeps its own independent scroll container.
;; Both are absolutely positioned and stacked; we toggle visibility.
[:div.h-full.relative
;; Journals scroll container (keep-alive)
[:div.w-full.relative
;; Journals scroll container (keep-alive)
[:div#app-main-home.pl-3.pr-2.absolute.inset-0
{:class (when-not home? "invisible pointer-events-none")}
(home)]
;; Other pages: search, settings, specific page, etc.
;; Other pages: search, settings, specific page, etc.
(when-not home?
(other-page view tab route-match))]))
@@ -141,7 +140,7 @@
(when-let [element (util/app-scroll-container-node)]
(common-handler/listen-to-scroll! element)))
[])
[:div.h-full
[:<>
(mobile-header/header current-repo tab)
(main-content tab route-match)]))
@@ -156,13 +155,11 @@
show-action-bar? (state/sub :mobile/show-action-bar?)
{:keys [open? content-fn opts]} (rum/react mobile-state/*popup-data)
show-popup? (and open? content-fn)
fold-button-on-right? (state/enable-fold-button-right?)
route-match (state/sub :route-match)]
[:div#app-main.w-full.h-full
{:class (util/classnames
[{:ls-fold-button-on-right fold-button-on-right?}])}
[:div.w-full.h-full {:class (when show-popup? "invisible")}
(app current-repo route-match)]
[:main#app-container-wrapper.ls-fold-button-on-right
[:div#app-container {:class (when show-popup? "invisible")}
[:div#main-container.flex.flex-1.overflow-x-hidden
(app current-repo route-match)]]
(when show-popup?
[:div.ls-layer
(popup/popup opts content-fn)])

View File

@@ -14,7 +14,11 @@ html.is-ios {
font-size: calc(var(--ls-mobile-font-size) * var(--ls-mobile-font-scale, 1));
}
#app-main {
#app-container-wrapper, #app-container, #main-container {
@apply w-full h-full;
}
#app-container-wrapper {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
}
@@ -306,6 +310,7 @@ body, #root {
/* Both layers use their own independent scroll containers. */
#app-main-home, #main-content-container
{
@apply overflow-x-hidden;
height: 100%;
overflow-y: auto;
-webkit-overflow-scrolling: touch;

View File

@@ -5,6 +5,7 @@
[cljs-time.local :as tl]
[clojure.string :as string]
[frontend.date :as date]
[frontend.db :as db]
[frontend.db.model :as db-model]
[frontend.handler.editor :as editor-handler]
[frontend.mobile.util :as mobile-util]
@@ -12,6 +13,8 @@
[frontend.util :as util]
[goog.functions :as gfun]
[lambdaisland.glogi :as log]
[logseq.common.config :as common-config]
[logseq.db :as ldb]
[logseq.shui.hooks :as hooks]
[logseq.shui.ui :as shui]
[mobile.init :as init]
@@ -62,9 +65,12 @@
{:formatter-str audio-file-format})
"."))]
(p/let [file (js/File. [blob] filename #js {:type (.-type blob)})
result (editor-handler/db-based-save-assets! (state/get-current-repo)
[file]
{:last-edit-block @*last-edit-block})
capture? (= "capture" @mobile-state/*tab)
insert-opts (cond->
{:last-edit-block @*last-edit-block}
capture?
(assoc :save-to-page (ldb/get-built-in-page (db/get-db) common-config/quick-add-page-name)))
result (editor-handler/db-based-save-assets! (state/get-current-repo) [file] insert-opts)
asset-entity (first result)]
(when (nil? asset-entity)
(log/error ::empty-asset-entity {}))

View File

@@ -26,11 +26,12 @@
(shui/button
{:variant :default
:class "text-1xl flex flex-1 w-full my-8"
:on-click #(shui/dialog-open! login/page-impl
{:close-btn? false
:label "user-login"
:align :top
:content-props {:class "app-login-modal"}})}
:on-click #(shui/popup-show!
nil
(fn []
[:div.w-full.h-full
(login/page-impl)])
{:id :login})}
"Login")
;; Logged in: account cell
[:div.mobile-setting-item

View File

@@ -11,13 +11,6 @@
(defonce transition-group (r/adapt-class TransitionGroup))
(defonce css-transition (r/adapt-class CSSTransition))
(rum/defc classic-app-container-wrap
[content]
[:main#app-container-wrapper.ls-fold-button-on-right
[:div#app-container.pt-2
[:div#main-container.flex.flex-1.overflow-x-hidden
[:div.w-full content]]]])
(rum/defc notification-clear-all
[]
[:div.ui__notifications-content

View File

@@ -62,6 +62,7 @@
(defn set-router!
[]
(mobile-nav/install-navigation-hooks!)
(let [router (rf/router routes nil)]
(rfe/start!
router
@@ -75,7 +76,9 @@
{:route {:to route-name
:path-params (:path-params route)
:query-params (:query-params route)}
:path path})
:route-match route
:path path
:stack (mobile-nav/current-stack)})
(route-handler/set-route-match! route)))
;; set to false to enable HistoryAPI
@@ -87,6 +90,7 @@
;; so it is available even in :advanced release builds
(prn "[Mobile] init!")
(log/add-handler mobile-state/log-append!)
(mobile-nav/install-native-bridge!)
(set-router!)
(init/init!)
(fhandler/start! render!))

View File

@@ -22,6 +22,21 @@
(def *last-shared-url (atom nil))
(def *last-shared-seconds (atom 0))
(defn- handle-incoming-url!
[url]
(p/then
state/app-ready-promise
(fn []
(when (and url
(or
(string/starts-with? url "https://logseq.com/mobile/")
(string/starts-with? url "logseq://mobile/")
(not (and (= @*last-shared-url url)
(<= (- (.getSeconds (js/Date.)) @*last-shared-seconds) 1)))))
(reset! *last-shared-url url)
(reset! *last-shared-seconds (.getSeconds (js/Date.)))
(deeplink/deeplink url)))))
(defn- ios-init!
"Initialize iOS-specified event listeners"
[]
@@ -80,18 +95,14 @@
(.addListener App "appUrlOpen"
(fn [^js data]
(log/info ::app-url-open data)
(p/then
state/app-ready-promise
(fn []
(when-let [url (.-url data)]
(when (or
(string/starts-with? url "https://logseq.com/mobile/")
(string/starts-with? url "logseq://mobile/")
(not (and (= @*last-shared-url url)
(<= (- (.getSeconds (js/Date.)) @*last-shared-seconds) 1))))
(reset! *last-shared-url url)
(reset! *last-shared-seconds (.getSeconds (js/Date.)))
(deeplink/deeplink url)))))))
(when-let [url (.-url data)]
(handle-incoming-url! url))))
(-> (.getLaunchUrl App)
(p/then (fn [^js data]
(when-let [url (.-url data)]
(log/info ::launch-url data)
(handle-incoming-url! url)))))
(.addListener Keyboard "keyboardWillShow"
(fn [^js info]

View File

@@ -4,13 +4,30 @@
[frontend.handler.route :as route-handler]
[frontend.mobile.util :as mobile-util]
[lambdaisland.glogi :as log]
[promesa.core :as p]))
[promesa.core :as p]
[reitit.frontend.easy :as rfe]))
;; Each tab owns a navigation stack
(defonce navigation-source (atom nil))
(defonce ^:private initialised? (atom false))
(defonce ^:private initialised-stacks (atom {}))
(def ^:private primary-stack "home")
(defonce ^:private active-stack (atom primary-stack))
(defonce ^:private stack-history (atom {}))
(defonce ^:private pending-navigation (atom nil))
(defonce ^:private hooks-installed? (atom false))
;; Track whether the latest change came from a native back gesture / popstate.
(.addEventListener js/window "popstate" (fn [_] (reset! navigation-source :pop)))
(.addEventListener js/window "popstate" (fn [_]
(reset! navigation-source :pop)))
(defn current-stack
[]
@active-stack)
(defn set-current-stack!
[stack]
(when (some? stack)
(reset! active-stack stack)))
(defn- strip-fragment [href]
(when (string? href)
@@ -18,14 +35,129 @@
(string/replace-first #"^#/" "/")
(string/replace-first #"^#" ""))))
(defn- navigation-type [push?]
(let [src @navigation-source]
(defn- current-path
[]
(let [p (strip-fragment (.-hash js/location))]
(if (string/blank? p) "/" p)))
(defn- stack-defaults
[stack]
(let [name (keyword stack)
path (if (= stack primary-stack) "/" (str "/__stack__/" stack))]
{:path path
:route (when (= stack primary-stack)
{:to :home
:path-params {}
:query-params {}})
:route-match {:data {:name (if (= stack primary-stack) :home name)}
:parameters {:path {} :query {}}}}))
(defn- record-navigation-intent!
[{:keys [type stack]}]
(let [stack (or stack @active-stack primary-stack)]
(reset! pending-navigation {:type type
:stack stack})))
(defonce orig-push-state rfe/push-state)
(defn push-state
"Sets the new route, leaving previous route in history."
([k]
(push-state k nil nil))
([k params]
(push-state k params nil))
([k params query]
(record-navigation-intent! {:type :push
:stack @active-stack})
(orig-push-state k params query)))
(defonce orig-replace-state rfe/replace-state)
(defn- replace-state
([k]
(replace-state k nil nil))
([k params]
(replace-state k params nil))
([k params query]
(record-navigation-intent! {:type :replace
:stack @active-stack})
(orig-replace-state k params query)))
(defn install-navigation-hooks!
"Wrap reitit navigation helpers so we know whether a change was push or replace.
Also tags navigation with the active stack so native can keep per-stack history."
[]
(when (compare-and-set! hooks-installed? false true)
(set! rfe/push-state push-state)
(set! rfe/replace-state replace-state)))
(defn- consume-navigation-intent!
[]
(let [intent @pending-navigation]
(reset! pending-navigation nil)
intent))
(defn- ensure-stack
[stack]
(swap! stack-history #(if (contains? % stack)
%
(assoc % stack {:history [(stack-defaults stack)]})))
stack)
(defn- stack-top
[stack]
(-> @stack-history (get stack) :history last))
(defn- remember-route!
[stack nav-type route path route-match]
(when stack
(let [stack (ensure-stack stack)
path (or path (current-path))
entry (when path {:path path :route route :route-match route-match})
update-history
(fn [history]
(let [history (vec history)
last-path (:path (last history))]
(case nav-type
"pop" (if (> (count history) 1) (vec (butlast history)) history)
"replace" (if (seq history)
(conj (vec (butlast history)) entry)
[entry])
"push" (if (= last-path (:path entry))
(conj (vec (butlast history)) entry)
(conj history entry))
history)))]
(when entry
(swap! stack-history update stack (fn [{:keys [history]}]
{:history (update-history history)}))
(swap! initialised-stacks assoc stack true)))))
(defn reset-stack-history!
[stack]
(when stack
(swap! stack-history assoc stack {:history [(stack-defaults stack)]})
(swap! initialised-stacks dissoc stack)))
(defn- next-navigation!
[{:keys [push stack nav-type]}]
(let [src @navigation-source
intent (consume-navigation-intent!)
stack (or stack (:stack intent) @active-stack primary-stack)
first? (not (get @initialised-stacks stack))
nav-type (or nav-type
(cond
(= src :pop) "pop"
(false? push) "replace"
(= (:type intent) :replace) "replace"
first? "replace"
(= (:type intent) :push) "push"
(true? push) "push"
:else "push"))]
(reset! navigation-source nil)
(cond
(= src :pop) "pop"
(false? push?) "replace"
(compare-and-set! initialised? false true) "replace" ;; first load
:else "push")))
(when first?
(swap! initialised-stacks assoc stack true))
{:navigation-type nav-type
:push? (= nav-type "push")
:stack stack}))
(defn- notify-route-payload!
[payload]
@@ -36,23 +168,87 @@
:payload payload})))))
(defn notify-route-change!
"Inform native iOS layer of a route change to keep native stack in sync.
{route {to keyword, path-params map, query-params map}
path string ;; optional, e.g. \"/page/Today\"
push boolean? ;; optional, explicit push vs replace hint}"
[{:keys [route path push]}]
(when (and (mobile-util/native-ios?)
mobile-util/ui-local)
(let [nav-type (navigation-type push)
payload (cond-> {:navigationType nav-type
:push (not= nav-type "replace")}
route (assoc :route route)
(or path (.-hash js/location))
(assoc :path (strip-fragment (or path (.-hash js/location)))))]
(notify-route-payload! payload))))
"Inform native iOS layer of a route change to keep native stack in sync."
[{:keys [route route-match path push stack]}]
(let [{:keys [navigation-type push? stack]} (next-navigation! {:push push
:nav-type (:navigation-type route-match)
:stack (or stack (current-stack))})
stack (or stack (current-stack))
path (or path (current-path))]
(set-current-stack! stack)
(remember-route! stack navigation-type route path route-match)
(when (and (mobile-util/native-ios?)
mobile-util/ui-local)
(let [payload (cond-> {:navigationType navigation-type
:push push?
:stack stack}
route (assoc :route route)
path (assoc :path (strip-fragment path)))]
(notify-route-payload! payload)))))
(defn reset-route!
(comment
(defn reset-route!
[]
(route-handler/redirect-to-home! false)
(let [stack (current-stack)]
(reset-stack-history! stack)
(notify-route-payload!
{:navigationType "reset"
:push false
:stack stack}))))
(defn switch-stack!
"Activate a stack and restore its last known route."
[stack]
(when stack
(let [stack (ensure-stack stack)
current @active-stack]
(set-current-stack! stack)
(when-let [{:keys [path route route-match]} (stack-top stack)]
(let [route-match (or route-match (:route-match (stack-defaults stack)))
path (or path (current-path))]
(route-handler/set-route-match! route-match)
(when (= current "search")
;; reset to :home
(orig-replace-state :home nil nil))
(notify-route-change!
{:route {:to (or (get-in route [:data :name])
(get-in route-match [:data :name]))
:path-params (or (:path-params route)
(get-in route-match [:parameters :path]))
:query-params (or (:query-params route)
(get-in route-match [:parameters :query]))}
:path path
:stack stack
:push false}))))))
(defn pop-stack!
"Pop one route from the current stack, update router via replace-state.
Called when native UINavigationController pops (back gesture / back button)."
[]
(route-handler/redirect-to-home! false)
(notify-route-payload!
{:navigationType "reset"}))
(let [stack (current-stack)
{:keys [history]} (get @stack-history stack)
history (vec history)]
(when (> (count history) 1)
(let [new-history (subvec history 0 (dec (count history)))
{:keys [route-match]} (peek new-history)
route-match (or route-match (:route-match (stack-defaults stack)))
route-name (get-in route-match [:data :name])
path-params (get-in route-match [:parameters :path])
query-params (get-in route-match [:parameters :query])]
(swap! stack-history assoc stack {:history new-history})
;; Pretend this came from a pop for next-navigation!
(reset! navigation-source :pop)
;; Use *original* replace-state to avoid recording a :replace intent.
(orig-replace-state route-name path-params query-params)
(route-handler/set-route-match! route-match)))))
(defn ^:export install-native-bridge!
[]
(set! (.-LogseqNative js/window)
(clj->js
{:onNativePop (fn [] (pop-stack!))})))

View File

@@ -19,5 +19,4 @@
["/export"
{:name :export
:view (fn []
[:div.mt-8
(export/export)])}]])
(export/export))}]])

View File

@@ -1,12 +1,9 @@
(ns mobile.state
"Mobile state"
(:require [frontend.rum :as r]
[frontend.state :as state]))
[frontend.state :as state]
[mobile.navigation :as mobile-nav]))
(defonce *tab (atom "home"))
(defn set-tab! [tab]
(reset! *tab tab))
(defn use-tab [] (r/use-atom *tab))
(defonce *search-input (atom ""))
(defn use-search-input []
(r/use-atom *search-input))
@@ -14,6 +11,18 @@
(defn use-search-last-input-at []
(r/use-atom *search-last-input-at))
(defonce *tab (atom "home"))
(defn set-tab! [tab]
(let [prev @*tab]
;; When leaving the search tab, clear its stack so reopening starts fresh.
(when (and (= prev "search")
(not= tab "search"))
(reset! *search-input "")
(mobile-nav/reset-stack-history! "search"))
(reset! *tab tab)
(mobile-nav/switch-stack! tab)))
(defn use-tab [] (r/use-atom *tab))
(defonce *popup-data (atom nil))
(defn set-popup!
[data]