remember scroll

This commit is contained in:
Gamosoft 2026-05-04 19:50:38 +02:00
parent a7995edace
commit 984e10e30f
2 changed files with 89 additions and 9 deletions

View File

@ -6,6 +6,7 @@
- **Rich markdown editor** with live preview
- **Three view modes**: Edit, Split, Preview
- **Auto-save** - Never lose your work
- **Resume where you left off** - Editor scroll position is remembered per note within the session, so switching back to a long note returns you to the same spot
- **Undo/Redo** - Ctrl+Z / Ctrl+Y support
- **Note templates** - Create notes from templates with dynamic placeholders
- **Syntax highlighting** for code blocks (50+ languages)

View File

@ -366,6 +366,11 @@ function noteApp() {
isUndoRedo: false,
hasPendingHistoryChanges: false,
// Per-note editor scroll position, keyed by note path. Captured when leaving a note in
// loadNote() and restored when returning to the same note in the same session. Stays
// in memory only — cleared on page reload, intentionally (matches typical editor UX).
noteScrollPositions: {},
// Stats plugin state
statsPluginEnabled: false,
noteStats: null,
@ -730,9 +735,15 @@ function noteApp() {
// Watch view mode changes and auto-save
this.$watch('viewMode', (newValue) => {
this.saveViewMode();
// Scroll to top when switching modes
// Re-apply the saved scroll position to whichever pane just became visible.
// x-show toggles display:none/block; an overflow:auto element resets its
// scrollTop on that transition, so the previously-hidden pane lands at 0
// unless we explicitly restore. $nextTick waits for Alpine to flush the
// x-show change, the rAF then waits for layout so scrollHeight is valid.
this.$nextTick(() => {
this.scrollToTop();
requestAnimationFrame(() => {
this._restoreNoteScroll();
});
});
});
@ -1151,7 +1162,12 @@ function noteApp() {
}, 50); // 50ms debounce
},
// Sync overlay scroll with textarea
// Sync overlay scroll with textarea. Per-note scroll restoration is captured separately
// inside the _editorScrollHandler / _previewScrollHandler in setupScrollSync(), where
// the existing isScrolling guard ensures we only record user-driven scrolls (not the
// programmatic mirrors between panes), and where we have access to the pane the user
// actually scrolled — important because the textarea's scroll event never fires in
// preview-only mode (the textarea is display:none).
syncOverlayScroll() {
const textarea = document.getElementById('note-editor');
const overlay = document.getElementById('syntax-overlay');
@ -4008,6 +4024,10 @@ function noteApp() {
this._cachedRenderedHTML = '';
this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content;
// Note: scroll restoration happens later in the post-$nextTick block below
// (where the existing scrollToTop call used to live), via _restoreNoteScroll().
// Doing it there means a single, unified restore path instead of two racing
// ones. See the post-$nextTick block for the full timing rationale.
this.currentNoteName = notePath.split('/').pop().replace('.md', '');
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
@ -4078,7 +4098,18 @@ function noteApp() {
this.$nextTick(() => {
this.refreshDOMCache();
this.setupScrollSync();
this.scrollToTop();
// First pass: editor is ready (textarea reflows synchronously). Preview
// may still have scrollHeight=0 because the markdown render pipeline
// (marked + MathJax + Mermaid + syntax highlighting) is async.
this._restoreNoteScroll();
// Second pass on a later frame: by now the preview pane has typically
// rendered and laid out, so its scrollTop write actually sticks. The
// helper is idempotent — re-running for the editor is a no-op.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this._restoreNoteScroll();
});
});
// Apply or clear search highlighting
if (searchQuery) {
@ -5877,6 +5908,14 @@ function noteApp() {
const scrollPercentage = editor.scrollTop / scrollableHeight;
const previewScrollableHeight = preview.scrollHeight - preview.clientHeight;
// Remember the user-driven scroll position as a percentage so it restores
// correctly across view modes (edit / split / preview) where editor and
// preview have wildly different scrollHeights. Saving after the isScrolling
// guard means we only record real user input, never the programmatic mirror.
if (this.currentNote) {
this.noteScrollPositions[this.currentNote] = scrollPercentage;
}
if (previewScrollableHeight > 0) {
this.isScrolling = true;
preview.scrollTop = scrollPercentage * previewScrollableHeight;
@ -5895,6 +5934,12 @@ function noteApp() {
const scrollPercentage = preview.scrollTop / scrollableHeight;
const editorScrollableHeight = editor.scrollHeight - editor.clientHeight;
// Capture preview-driven scrolls too — this is the only path that fires in
// preview-only mode, where the textarea is display:none and never scrolls.
if (this.currentNote) {
this.noteScrollPositions[this.currentNote] = scrollPercentage;
}
if (editorScrollableHeight > 0) {
this.isScrolling = true;
editor.scrollTop = scrollPercentage * editorScrollableHeight;
@ -6387,6 +6432,40 @@ function noteApp() {
localStorage.setItem('editorWidth', this.editorWidth.toString());
},
/**
* Restore the saved scroll percentage for the current note in both panes. Called from
* loadNote() (after the new content has been laid out) and from the viewMode watcher
* (browsers reset scrollTop to 0 when an overflow:auto element transitions from
* display:none back to display:block via x-show, so newly-visible panes need
* re-positioning). Storing/applying a percentage means the same logical position is
* preserved across mode toggles even though editor and preview have different
* scrollHeights. Idempotent: safe to call multiple times. The isScrolling flag
* prevents the existing scroll-sync handlers from echoing these programmatic writes.
*/
_restoreNoteScroll() {
if (!this.currentNote || this.currentMedia) return;
const pct = this.noteScrollPositions[this.currentNote] || 0;
if (!this._domCache.editor || !this._domCache.previewContainer) {
this.refreshDOMCache();
}
const editor = this._domCache.editor;
const preview = this._domCache.previewContainer;
if (editor) {
const scrollable = editor.scrollHeight - editor.clientHeight;
if (scrollable > 0) {
this.isScrolling = true;
editor.scrollTop = pct * scrollable;
}
}
if (preview) {
const scrollable = preview.scrollHeight - preview.clientHeight;
if (scrollable > 0) {
this.isScrolling = true;
preview.scrollTop = pct * scrollable;
}
}
},
// Scroll to top of editor and preview
scrollToTop() {
// Disable scroll sync temporarily to prevent interference