feat(toc): highlight active heading based on cursor position#4345
feat(toc): highlight active heading based on cursor position#4345rkristelijn wants to merge 3 commits into
Conversation
- 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
Jocs
left a comment
There was a problem hiding this comment.
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:
- TOC expand-all / collapse-all toolbar buttons +
expandedKeyspersistence (toc.vue). - File-tree expand-all / collapse-all + folder-collapse persistence (
tree.vue,treeFolder.vue, newcollapsedFolders.ts). - Auto-scroll the active file into view in the file tree (
treeFile.vue). - 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}`) |
There was a problem hiding this comment.
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, () => { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
(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) |
There was a problem hiding this comment.
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 }) |
There was a problem hiding this comment.
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> { | |||
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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"> |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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
activeHeadingSlugin the editor store and update it on MuyaselectionChangeto drive TOC highlighting. - Update TOC sidebar to use
el-treecurrent-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.
| SET_ACTIVE_HEADING(slug: string): void { | ||
| this.activeHeadingSlug = slug | ||
| }, |
| <a | ||
| href="javascript:;" | ||
| :title="t('sideBar.tree.expandAll')" | ||
| @click.stop="expandAllFolders" | ||
| > |
| <a | ||
| href="javascript:;" | ||
| :title="t('sideBar.tree.collapseAll')" | ||
| @click.stop="collapseAllFolders" | ||
| > |
| // 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) | ||
| })) | ||
| } |
| 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 }) |
| const TOC_EXPANDED_KEY = 'toc-expanded-keys' | ||
|
|
||
| function loadExpandedKeys(): string[] { | ||
| try { | ||
| const raw = localStorage.getItem(TOC_EXPANDED_KEY) |
| // 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) | ||
| } |
| export function isCollapsed(pathname: string): boolean { | ||
| return load().has(pathname) | ||
| } |
| 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) |
| // Initialise from persisted state, fall back to prop | ||
| const isCollapsed = ref<boolean>(loadCollapsed(props.folder.pathname) ?? !!props.folder.isCollapsed) | ||
|
|
|
@rkristelijn Pls solve the inline reviews and CI lint error. |
Closes #2421
Demo
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
selectionChangeevent from muya, the editor queries all heading anchor elements inside the editor container, finds the last one whoseoffsetTopis at or above the cursor position, and stores its slug asactiveHeadingSlugin the editor store. The TOC component binds this toel-tree'scurrent-node-keyto highlight the matching node.Three non-obvious problems solved:
Circular references in toc data —
listToTreeattaches aparentback-pointer to everyTreeNode. Passing this directly toel-treesilently brokenode-key/current-node-keymatching. Fixed with atocDatacomputed that stripsparentand passes only{ slug, label, lvl, children }.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-currentin the selector loses the specificity battle silently.Inherited text color —
.el-treesetscolorvia--el-tree-text-color: var(--el-text-color-regular)(#606266) which inherits into all labels. Overridingcoloron the content element is not enough;--el-tree-text-colormust be overridden on the same element.Theme color is applied via
var(--themeColor)(defined in all theme CSS files) withcolor-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
Test plan
offsetTopvalues requiring a full Electron/DOM environment (e2e). ThestripParenthelper is trivial. Both are candidates for a follow-up e2e spec.Manual steps:
Files changed
store/editor.tsactiveHeadingSlug: stringstate +SET_ACTIVE_HEADINGactioncomponents/editorWithTabs/editor.vueselectionChange, callSET_ACTIVE_HEADINGcomponents/sideBar/toc.vuenode-key,current-node-key,tocDatacomputed (stripsparent), theme-color CSSdocs/toc-sidebar-improvements.mdNot included — follow-up PRs
Full design:
docs/toc-sidebar-improvements.mdBy submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.