Skip to content

Reader navigation: tab reordering + heading spine#40

Open
ad-cqc wants to merge 2 commits into
nonatofabio:mainfrom
ad-cqc:ad-cqc/reader-navigation
Open

Reader navigation: tab reordering + heading spine#40
ad-cqc wants to merge 2 commits into
nonatofabio:mainfrom
ad-cqc:ad-cqc/reader-navigation

Conversation

@ad-cqc

@ad-cqc ad-cqc commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Two reader-navigation features stacked together:

  • Tab drag-and-drop reorder. Each tab is draggable; dropping on another tab inserts before it, and a wide trailing drop zone (the empty area to the right of the last tab) sends the dragged tab to the end. Per-tab + trailing zones cover every slot, including the rightmost. In-session only — there's no tab session-restore yet, so order resets on relaunch.
  • Floating heading spine on markdown reader. Right-edge column of ticks (one per heading, indented by level) tracks scroll position with a small accent dot at the active heading. Hovering the spine reveals a compact panel listing the headings; clicking a row smooth-scrolls to that section. Hidden for PDFs, the editor pane, and docs with fewer than two headings.

Internals: reader.js walks h1h6 after each render, posts the list + offsets through new headings / scrollState channels, and accepts a mindleScrollToHeading(id) from Swift. The spine maps each heading's pixel offset to a y-fraction so long chapters get more rail than short ones. Spine state clears on tab switch / file close so the outgoing tab's outline doesn't flash on the new one.

Test plan

  • Drag a tab onto another tab → drops before that tab; insertion stripe shows on the leading edge during hover.
  • Drag a tab into the empty area right of the last tab → moves to the trailing-most slot; insertion stripe shows after the last tab.
  • Open a markdown doc with several ##/### headings → spine ticks visible at the right edge, indented by level.
  • Scroll → active dot tracks position; the heading whose body is past the one-third reading line is the active one.
  • Hover spine → labeled panel slides in to the left; cursor can move from spine into panel without the panel collapsing.
  • Click a row → smooth scroll to that heading; active row highlights in accent color.
  • Open a PDF tab → spine hidden.
  • Open ⌘E editor → spine hidden.
  • Open a markdown doc with 0 or 1 headings → spine hidden.
  • Switch tabs → previous tab's headings don't flash on the new tab.

Note

The tab-reorder commit also drops .build/ and .vscode/ into .gitignore — small piggyback that landed during an earlier amend. Happy to split out if you'd rather see it on its own.

ad-cqc added 2 commits June 2, 2026 12:31
Each tab now exposes `.onDrag` (emits its UUID as a text item) and
`.onDrop` (resolves the dragged UUID and calls
`DocumentStore.moveTab(id:before:)`). The drop target shows a blue
insertion stripe on its leading edge while a tab is hovering over it.

In-session only — there's no tab session-restore yet, so the order
resets when the app is relaunched.
A right-edge column of ticks (one per heading, indented by level) tracks
position with a small accent dot at the active heading. Hovering the
spine reveals a compact panel listing the headings; clicking a row
smooth-scrolls to that section.

The reader (reader.js) walks h1–h6 after each render, posts the list +
positions over a new `headings` channel, and reports scroll geometry
through `scrollState` on every rAF tick. Swift maps each heading's
document offset to a y-fraction so the spine reflects actual section
sizes — long chapters get more rail, short ones less. `mindleScrollToHeading`
takes a slug from markdown-it-anchor and scrolls smoothly.

PDFs and the editor pane don't show the spine; documents with fewer
than two headings hide it. Spine state clears on tab switch / file
close so the outgoing tab's outline doesn't flash on the new one.
@nonatofabio nonatofabio self-requested a review June 3, 2026 20:32
@nonatofabio

Copy link
Copy Markdown
Owner

These are cool contribs! Thanks will go into next RC!

@nonatofabio nonatofabio left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — both features feel right and the implementation reads cleanly. Two well-scoped commits, comprehensive test plan, and the architecture follows the existing channel-+-coordinator patterns in WebReaderView. A few inline thoughts below, none blocking. The PR is based on the pre-rc2 main, so it'll need a rebase before merge — rc2's unread-dot work touched TabBarItem and DocumentStore.swift so there'll be some hand-merging in those files, but nothing that conflicts conceptually.

Good candidate for v3.1.0-rc4 alongside the diff-review polish wave (#38 next/prev banner, #39 scroll-anchored accept) — all three are 'navigation' features and read as a coherent bundle.

Rectangle().fill(c.rule.opacity(0.3)).frame(width: 0.5)
}
.onHover { isHovering = $0 }
.onDrag {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defensive nit: UTType.text is quite broad — any text-shaped drag from anywhere could pass validateDrop and only be filtered by the UUID(uuidString:) guard in performDrop. A custom UTType like UTType("com.fnp.mindle.tab") would scope the drop targets to actual Mindle tab drags. Current code is fine in practice; flagging as a small hardening for later.

Comment thread Resources/web/reader.js
postToSwift("headings", { headings: out });
}

function publishScrollState() {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth an early-return here when headingCache.length === 0 — the overlay short-circuits at the SwiftUI level (spineHeadings.count >= 2), but the bridge serialization + the @Published spineScroll bump still fire on every scroll frame for documents with no headings. Cheap to add:

if (!headingCache.length) return;

let track = max(0, proxy.size.height - verticalInset * 2)
let positions = trackPositions(in: track, headings: headings)
ZStack(alignment: .topTrailing) {
ForEach(Array(headings.enumerated()), id: \.element.id) { idx, h in

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a document has duplicate heading slugs (rare but possible — markdown-it-anchor should deduplicate but it depends on the configured slug function), ForEach keyed by \.element.id would collide and the second occurrence would be dropped silently. Worth a quick check that the anchor plugin's de-dup is on; failure mode is 'clicking the tick for the second “Introduction” scrolls to the first one,' which is a quiet bug.

// Slide left of the spine so the two read as connected. The
// outer overlay is `.topTrailing`, so we shift left by the
// spine's width plus a small gap.
.offset(x: -(collapsedWidth + 4), y: verticalInset - 6)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 4pt gap between spine and panel can briefly cancel both hovers when the cursor crosses it, depending on tracking speed. The panel's own .onHover re-sets isHovering when it lands, so the panel doesn't immediately collapse — but it's worth a trackpad smoke-test with quick swipes. If flicker shows up, options are (a) widen the spine column's .contentShape to overlap the gap, or (b) drop the .offset and place the panel as a sibling overlay on a shared hover-tracking container.

// both empty, which hides the overlay.
@Published private(set) var spineHeadings: [SpineHeading] = []
@Published private(set) var spineScroll: SpineScrollState = .empty
@Published var scrollToHeadingRequestedAt: Date? = nil

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two separate published vars to model one logical event (scrollToHeadingRequestedAt + scrollToHeadingID). Coupling them into a single struct would tidy this and eliminate the brief window where one updates before the other:

struct ScrollToHeadingRequest: Equatable {
    let id: String
    let token: Date
}
@Published private(set) var scrollToHeadingRequest: ScrollToHeadingRequest? = nil

The coordinator side then compares request?.token against its cached one. Purely cosmetic — works as-is.

}

/// Scroll geometry for the floating heading spine.
struct SpineScrollState: Equatable {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollTop and viewportHeight are published in the channel and stored here, but the current overlay only reads contentHeight and activeIndex. Either trim the publish payload + struct, or add a // reserved for future 'you are here' viewport indicator line so the next reader doesn't wonder. Lean toward keeping them — they're cheap and the viewport-range marker is an obvious follow-up.

/// slot is reached via `moveTabToEnd` — there's no `after` flag here
/// because per-tab drop zones only model "insert before".
/// No-op if either id is unknown or the move is a no-op.
func moveTab(id: UUID, before targetID: UUID) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a request for this PR — tracking only: a keyboard alternative for tab reorder (⌘⌥← / ⌘⌥→, Safari-style) would round this out for the no-mouse path. The drag implementation here is the right foundation; just flagging that the moveTab / moveTabToEnd API surface already supports it cleanly so a follow-up is small.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants