Skip to content

feat(toc): highlight active heading based on cursor position#4345

Open
rkristelijn wants to merge 3 commits into
marktext:developfrom
rkristelijn:feature/toc-active-heading
Open

feat(toc): highlight active heading based on cursor position#4345
rkristelijn wants to merge 3 commits into
marktext:developfrom
rkristelijn:feature/toc-active-heading

Conversation

@rkristelijn
Copy link
Copy Markdown

Closes #2421

Demo

Jun-01-2026 17-24-47

Summary

Highlights the active TOC heading in the sidebar based on cursor position. When the cursor moves into a new section, the corresponding TOC entry is highlighted in the active theme color.

How it works:
On every selectionChange event from muya, the editor queries all heading anchor elements inside the editor container, finds the last one whose offsetTop is at or above the cursor position, and stores its slug as activeHeadingSlug in the editor store. The TOC component binds this to el-tree's current-node-key to highlight the matching node.

Three non-obvious problems solved:

  1. Circular references in toc datalistToTree attaches a parent back-pointer to every TreeNode. Passing this directly to el-tree silently broke node-key / current-node-key matching. Fixed with a tocData computed that strips parent and passes only { slug, label, lvl, children }.

  2. CSS specificity — Element Plus's highlight rule is .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { background-color: var(--el-color-primary-light-9) }. Any override without .el-tree--highlight-current in the selector loses the specificity battle silently.

  3. Inherited text color.el-tree sets color via --el-tree-text-color: var(--el-text-color-regular) (#606266) which inherits into all labels. Overriding color on the content element is not enough; --el-tree-text-color must be overridden on the same element.

Theme color is applied via var(--themeColor) (defined in all theme CSS files) with color-mix(in srgb, var(--themeColor) 15%, transparent) as the background tint — consistent with how other active elements (sidebar icons, unsaved dot, open folder button) are styled throughout the app.

Type of change

  • New feature (non-breaking, adds functionality)

Test plan

  • New tests added — not added; the active-heading detection relies on DOM offsetTop values requiring a full Electron/DOM environment (e2e). The stripParent helper is trivial. Both are candidates for a follow-up e2e spec.
  • Manually tested on: macOS

Manual steps:

  1. Open a markdown file with multiple headings.
  2. Open the TOC sidebar.
  3. Click into different sections — the corresponding TOC entry highlights in the theme color.
  4. Switch themes (Preferences → Theme) — highlight color updates immediately.

Files changed

File Change
store/editor.ts Add activeHeadingSlug: string state + SET_ACTIVE_HEADING action
components/editorWithTabs/editor.vue Detect active heading on selectionChange, call SET_ACTIVE_HEADING
components/sideBar/toc.vue node-key, current-node-key, tocData computed (strips parent), theme-color CSS
docs/toc-sidebar-improvements.md Design doc with full analysis and remaining work

Not included — follow-up PRs

  • Auto-scroll TOC to keep active item visible
  • Keyboard focus on TOC sidebar + arrow key navigation
  • Persist expand/collapse state across sessions
  • Auto-collapse nodes when switching to TOC mode

Full design: docs/toc-sidebar-improvements.md


By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.

- Track activeHeadingSlug in editor store, updated on selectionChange
- Strip circular parent refs from toc data so el-tree node-key works
- Override Element Plus is-current styles with theme color
- Design doc added in docs/toc-sidebar-improvements.md
- Use setCurrentKey() imperatively instead of :current-node-key prop;
  el-tree's internal watch only fires on changes, so the first node and
  post-data-reload cases were never highlighted
- Watch tocData in addition to activeHeadingSlug so highlight is
  re-applied when a new file is loaded
- Fix tocTreeRef type (HTMLElement → InstanceType<typeof ElTree>) so
  setCurrentKey and $el are available
- Remove duplicate ref on wrapper div
- Fix TOC overflowing the titlebar: height 100% + overflow hidden on
  root, flex:1 + min-height:0 on el-tree, overflow classes moved to
  el-tree element
Copy link
Copy Markdown
Member

@Jocs Jocs left a comment

Choose a reason for hiding this comment

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

Thanks for tackling #2421 — the headline feature (active-heading highlight) works and the explanation of the three CSS / circular-ref pitfalls is genuinely useful. A few concerns before this can land:

Scope

The PR title and description are scoped to active TOC heading highlight, but the diff also adds:

  1. TOC expand-all / collapse-all toolbar buttons + expandedKeys persistence (toc.vue).
  2. File-tree expand-all / collapse-all + folder-collapse persistence (tree.vue, treeFolder.vue, new collapsedFolders.ts).
  3. Auto-scroll the active file into view in the file tree (treeFile.vue).
  4. New i18n keys.

These are four independent features. Per the PR template ("Not included — follow-up PRs") you intended to split them, but they ended up here. Please extract (2), (3) and the expand/collapse work in (1) into their own PRs so each can be reviewed and reverted independently. The active-heading-highlight change is small, focused, and a clear win on its own.

Also, docs/toc-sidebar-improvements.md is listed in the "Files changed" table of the description but isn't actually in the diff — please add it or remove the reference.

Tests

No unit or e2e tests. The active-heading detection (offsetTop <= cursorTop) is testable without a full DOM environment by stubbing the elements — at minimum the slug-picking loop should be extracted into a pure function and unit-tested. An e2e spec for the highlight reacting to cursor movement would catch regressions in the muya selectionChange contract.

i18n

Only en.json was updated. The repo ships 9 locales (de, es, fr, ja, ko, pt, zh-CN, zh-TW). Either add keys to all of them (machine-translated stubs are common) or confirm with maintainers what the fallback policy is — otherwise non-English users see the raw key.

Inline comments below for the concrete bugs / correctness issues.

const cursorTop = container.scrollTop + cursorY
let activeSlug = slugs[0]!
for (const slug of slugs) {
const el = container.querySelector(`#${slug}`)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two issues here:

1. CSS-selector injection / breakage. #${slug} builds a CSS selector by string interpolation. Slugs are derived from heading text, so any heading containing characters that are not a-z/0-9/-/_ (e.g. 1.2 Title, Foo:Bar, headings starting with a digit, non-ASCII characters that the slugifier may emit) will produce an invalid selector and throw SyntaxError, breaking all subsequent cursor-tracking logic in this listener. The headings are rendered with id=slug already, so use container.querySelector('[id="' + CSS.escape(slug) + '"]') — or, simpler and faster, document.getElementById(slug) (IDs must be unique anyway).

2. Performance. selectionChange fires on every cursor movement (every keypress in some configurations). This loop does N DOM queries per event, where N is the number of headings in the document. For a long doc, that's a measurable hit per keystroke.

Minimal fix: build a slug → element map once per tocData change (watch listToc), then in the listener just iterate the cached entries comparing offsetTop. Optionally throttle via requestAnimationFrame since cursor coordinates only need to update once per frame anyway.

}
}

watch(tocData, () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This watcher fires with { immediate: true }, so on mount it immediately sets expandedKeys.value = [] and writes [] to localStorage — wiping any persisted state that loadExpandedKeys() at line 83 just read. The result: the localStorage round-trip is dead code, and persistence across reloads doesn't actually work.

If the intent is "per-file, start collapsed and only expand ancestors of the active heading" (as the comment says), then loadExpandedKeys and the expandedKeys localStorage writes in onNodeExpand / onNodeCollapse should be removed entirely — they cost storage churn and add no behavior.

If the intent is real persistence (the PR body lists "Persist expand/collapse state across sessions" under follow-up, so probably not this PR), then the watcher needs to key by file id and not wipe across the board.

Either way the current shape is incoherent. Pick one direction.

function expandAll(): void {
const tree = tocTreeRef.value
if (!tree) return
const store = (tree as any).store
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(tree as any).store (repeated at lines 126, 167) — please drop the as any cast and reach for typed access. Element Plus does expose tree.store in its public types (TreeStore), and nodesMap is a documented field. Casting to any hides future breakage when the upstream type changes.

More importantly, mutating nodesMap[key].expanded directly is reaching into Element Plus's internal reactive state. Prefer the public methods: tree.store.setData(...), tree.setCurrentKey(...), or iterating tree.store.nodesMap and calling each node's .expand() / .collapse(). That keeps the integration on the supported surface.

// Use a local reactive state for isCollapsed that syncs with the prop
const isCollapsed = ref<boolean>(!!props.folder.isCollapsed)
// Initialise from persisted state, fall back to prop
const isCollapsed = ref<boolean>(loadCollapsed(props.folder.pathname) ?? !!props.folder.isCollapsed)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Bug: loadCollapsed (= isCollapsed in collapsedFolders.ts) returns boolean, never null/undefined. ?? only triggers on nullish values, so this fallback is dead code — props.folder.isCollapsed is silently ignored for every folder.

Concretely: any folder the project tree opens with isCollapsed: true (e.g. the persistent-state restore path the rest of the app uses) will now render expanded on first load, until the user explicitly toggles it.

Fix: make isCollapsed() in collapsedFolders.ts return boolean | undefined (return undefined when the pathname isn't in the persisted Set), then ?? works correctly. Or rename + restructure to make the intent explicit, e.g. getPersistedCollapsed(path) returning boolean | undefined.

}
bus.on('SIDEBAR::show-new-input', handleInputFocus)
bus.on('SIDEBAR::show-rename-input', focusRenameInput)
bus.on('SIDEBAR::expand-all-folders', () => { isCollapsed.value = false })
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These bus.on(...) calls register anonymous handlers with no matching bus.off(...) in onBeforeUnmount. Every time a folder is unmounted (e.g. parent collapse, project switch, file deletion) the handlers remain attached, the closures keep the captured isCollapsed ref alive, and the next emit fires against stale components.

For a project with hundreds of folders this is a real memory leak and a correctness bug (you'll see ghost folders flipping state). Mirror the existing pattern — store the handler in a const, attach in onMounted, detach in onBeforeUnmount. Same applies to the other bus.on calls above; the existing ones should probably also have bus.off counterparts but that's pre-existing.

@@ -0,0 +1,32 @@
const KEY = 'sidebar-collapsed-folders'

function load(): Set<string> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

load() calls localStorage.getItem + JSON.parse on every isCollapsed(...) / setCollapsed(...) call. Each <TreeFolder> calls loadCollapsed(props.folder.pathname) on mount, so for a project with N folders that's N parses per render of the tree. Cheap individually, but at hundreds of folders it shows up.

Cache the parsed Set in module scope and re-read only when an external write occurs (or just on first access + after setCollapsed/clearAll/collapseAll).


// from fileMixins
// Scroll this file into view when it becomes the active tab
watch(currentFile, (file) => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This watcher is registered inside every <TreeFile> instance. A project with hundreds of .md files = hundreds of watchers all comparing the same currentFile.pathname to their own. Only one of them will ever match per change.

Move this to the parent tree component (tree.vue) as a single watcher that locates the active file's DOM node by data-pathname attribute and scrolls it. Or use the existing bus to emit a single FILE-TREE::scroll-active event the active file row listens for. Either way, one watcher rather than N.

<div class="title">
{{ t('sideBar.toc.title') }}
<span v-if="tocData.length" class="toc-actions">
<button :title="t('sideBar.toc.expandAll')" class="toc-action-btn" @click="expandAll">
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Accessibility nit: the icon-only buttons rely on :title= (tooltip) but no aria-label. Screen readers don't reliably announce title on buttons — add :aria-label="t('sideBar.toc.expandAll')" (and the same for collapse).

Also, the inline SVG path strings are duplicated verbatim in tree.vue (lines 84/91). Extract these into a small <ToolbarIconButton> component, or at least move the path data into a constant — currently the same path is hand-copied four times.

(Pre-existing in this PR but worth flagging: the matching <a href="javascript:;"> pattern in tree.vue should be <button type="button"> for keyboard focus + correct semantics — the rest of the sidebar already uses real buttons.)

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds sidebar UX improvements centered around the TOC: highlighting the currently active heading based on cursor position, plus new expand/collapse controls and persisted collapse state for sidebar trees.

Changes:

  • Track an activeHeadingSlug in the editor store and update it on Muya selectionChange to drive TOC highlighting.
  • Update TOC sidebar to use el-tree current-node highlighting, add expand/collapse-all actions, and manage expanded state.
  • Add persisted collapsed-folder state for the project tree and scroll the active file into view in the sidebar.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
packages/desktop/static/locales/en.json Adds i18n strings for TOC expand/collapse actions.
packages/desktop/src/renderer/src/store/editor.ts Adds activeHeadingSlug state + SET_ACTIVE_HEADING action.
packages/desktop/src/renderer/src/components/editorWithTabs/editor.vue Computes active heading on cursor movement and updates the editor store.
packages/desktop/src/renderer/src/components/sideBar/toc.vue Adds el-tree current-node highlighting, expand/collapse actions, and expanded-state logic.
packages/desktop/src/renderer/src/components/sideBar/collapsedFolders.ts New localStorage-backed helper for persisted collapsed folder paths.
packages/desktop/src/renderer/src/components/sideBar/treeFolder.vue Initializes folder collapse state from persisted storage and updates it on toggles.
packages/desktop/src/renderer/src/components/sideBar/treeFile.vue Scrolls the active file node into view when the current tab changes.
packages/desktop/src/renderer/src/components/sideBar/tree.vue Adds expand/collapse-all UI controls for the project tree.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +253 to +255
SET_ACTIVE_HEADING(slug: string): void {
this.activeHeadingSlug = slug
},
Comment on lines +83 to +87
<a
href="javascript:;"
:title="t('sideBar.tree.expandAll')"
@click.stop="expandAllFolders"
>
Comment on lines +90 to +94
<a
href="javascript:;"
:title="t('sideBar.tree.collapseAll')"
@click.stop="collapseAllFolders"
>
Comment on lines +60 to +69
// Strip circular `parent` references so el-tree node-key matching works correctly
type PlainNode = { slug: string; label: unknown; lvl: unknown; children: PlainNode[] }
function stripParent(nodes: typeof toc.value): PlainNode[] {
return nodes.map(({ slug, label, lvl, children }) => ({
slug: slug as string,
label,
lvl,
children: stripParent(children)
}))
}
Comment on lines +163 to +171
watch(tocData, () => {
// New file loaded: start collapsed, only expand ancestors of active heading
expandedKeys.value = []
localStorage.setItem(TOC_EXPANDED_KEY, JSON.stringify([]))
nextTick(() => {
expandAncestors(activeHeadingSlug.value)
applyCurrentKey()
})
}, { immediate: true })
Comment on lines +72 to +76
const TOC_EXPANDED_KEY = 'toc-expanded-keys'

function loadExpandedKeys(): string[] {
try {
const raw = localStorage.getItem(TOC_EXPANDED_KEY)
Comment on lines +1305 to +1318
// Update active TOC heading based on cursor position
const slugs = editorStore.listToc.map((item) => item.slug as string).filter(Boolean)
if (slugs.length > 0) {
const { y: cursorY } = changes.cursorCoords as { y: number }
const cursorTop = container.scrollTop + cursorY
let activeSlug = slugs[0]!
for (const slug of slugs) {
const el = container.querySelector(`#${slug}`)
if (el && (el as HTMLElement).offsetTop <= cursorTop) {
activeSlug = slug
}
}
editorStore.SET_ACTIVE_HEADING(activeSlug)
}
Comment on lines +16 to +18
export function isCollapsed(pathname: string): boolean {
return load().has(pathname)
}
Comment on lines +42 to +50
import { computed, ref, watch, nextTick } from 'vue'
import type { ElTree } from 'element-plus'

const { t } = useI18n()

const editorStore = useEditorStore()
const preferencesStore = usePreferencesStore()

const tocTreeRef = ref<InstanceType<typeof ElTree> | null>(null)
Comment on lines +86 to 88
// Initialise from persisted state, fall back to prop
const isCollapsed = ref<boolean>(loadCollapsed(props.folder.pathname) ?? !!props.folder.isCollapsed)

@Jocs
Copy link
Copy Markdown
Member

Jocs commented Jun 3, 2026

@rkristelijn Pls solve the inline reviews and CI lint error.

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.

Sidebar collapse state memory inconsistant

3 participants