Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ Non-code commits (documentation, planning, README-only updates) are excluded.

## Unreleased

- **Editor navigation:** Fixed Page Up/Down shifting the workspace sideways in Edge by removing closed dropdowns from the layout while preserving menu transitions and native editor navigation.
- **Editor scrolling:** Added smooth Page Up/Down scrolling when the browser jumps instantly, including the secondary document Editor. Native caret movement, Shift selection, and reduced-motion preferences are preserved; delayed synchronized scroll events no longer interrupt an ongoing animation.

---

## v3.10.3
Expand Down
87 changes: 82 additions & 5 deletions desktop-app/resources/js/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ document.addEventListener("DOMContentLoaded", async function () {
let isPreviewScrolling = false;
let isProgrammaticScrolling = false;
let scrollSyncTimeout = null;
let scrollSyncReleaseTimeout = null;
const synchronizedScrollPositions = new WeakMap();
let documentSplitSyncFrame = null;
let documentSplitScrollSource = null;
let documentSplitSyncReleaseTimeout = null;
Expand Down Expand Up @@ -12040,6 +12042,7 @@ document.addEventListener("DOMContentLoaded", async function () {

function syncDocumentSplitScroll(source, target) {
if (!syncScrollingEnabled || !secondarySplitTabId || !source || !target) return;
if (isSynchronizedScrollEvent(source)) return;
if (documentSplitScrollSource && documentSplitScrollSource !== source) return;
documentSplitScrollSource = source;
if (documentSplitSyncFrame) cancelAnimationFrame(documentSplitSyncFrame);
Expand All @@ -12050,7 +12053,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const targetRange = target.scrollHeight - target.clientHeight;
const ratio = sourceRange > 0 ? source.scrollTop / sourceRange : 0;
const targetPosition = targetRange * ratio;
if (Number.isFinite(targetPosition)) target.scrollTop = targetPosition;
if (Number.isFinite(targetPosition)) setSynchronizedScrollTop(target, targetPosition);
documentSplitSyncReleaseTimeout = setTimeout(function() {
documentSplitScrollSource = null;
}, 48);
Expand Down Expand Up @@ -16887,14 +16890,29 @@ ${selector} .arrowheadPath {
readingTimeElement.textContent = readingTimeMinutes;
}

function isSynchronizedScrollEvent(element) {
if (element.scrollTop === synchronizedScrollPositions.get(element)) return true;
synchronizedScrollPositions.delete(element);
return false;
}

function setSynchronizedScrollTop(element, top) {
element.scrollTo({ top, behavior: 'instant' });
// Remember the browser's rounded/clamped result. A delayed target scroll
// event must not echo back and interrupt the source pane's animation.
synchronizedScrollPositions.set(element, element.scrollTop);
}

function syncEditorToPreview() {
if (isSynchronizedScrollEvent(markdownEditor)) return;
// During an outline jump, the preview owns the position until the next user
// interaction. Delayed editor scroll events must not undo a layout correction.
if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling || documentOutlineScrollTarget) return;
const sourceDocumentId = activeTabId;
if (!sourceDocumentId || previewLastRenderedTabId !== sourceDocumentId) return;
isEditorScrolling = true;

clearTimeout(scrollSyncReleaseTimeout);
if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = requestAnimationFrame(function() {
scrollSyncTimeout = null;
Expand All @@ -16910,21 +16928,24 @@ ${selector} .arrowheadPath {
editorScrollRatio;

if (!isNaN(previewScrollPosition) && isFinite(previewScrollPosition)) {
previewPane.scrollTop = previewScrollPosition;
setSynchronizedScrollTop(previewPane, previewScrollPosition);
}

setTimeout(function() {
scrollSyncReleaseTimeout = setTimeout(function() {
scrollSyncReleaseTimeout = null;
isEditorScrolling = false;
}, 50);
});
}

function syncPreviewToEditor() {
if (isSynchronizedScrollEvent(previewPane)) return;
if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return;
const sourceDocumentId = previewLastRenderedTabId;
if (!sourceDocumentId || activeTabId !== sourceDocumentId) return;
isPreviewScrolling = true;

clearTimeout(scrollSyncReleaseTimeout);
if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = requestAnimationFrame(function() {
scrollSyncTimeout = null;
Expand All @@ -16940,17 +16961,22 @@ ${selector} .arrowheadPath {
previewScrollRatio;

if (!isNaN(editorScrollPosition) && isFinite(editorScrollPosition)) {
markdownEditor.scrollTop = editorScrollPosition;
setSynchronizedScrollTop(markdownEditor, editorScrollPosition);
syncEditorScrollOverlays();
}

setTimeout(function() {
scrollSyncReleaseTimeout = setTimeout(function() {
scrollSyncReleaseTimeout = null;
isPreviewScrolling = false;
}, 50);
});
}

function cancelPendingMainScrollSync() {
synchronizedScrollPositions.delete(markdownEditor);
synchronizedScrollPositions.delete(previewPane);
clearTimeout(scrollSyncReleaseTimeout);
scrollSyncReleaseTimeout = null;
if (scrollSyncTimeout) {
cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = null;
Expand All @@ -16959,6 +16985,55 @@ ${selector} .arrowheadPath {
isPreviewScrolling = false;
}

function initEditorPageNavigation(editor) {
if (!editor) return;
let pendingNavigation = null;
let navigationFrame = null;
let navigationTimeout = null;

function clearPendingNavigation() {
pendingNavigation = null;
if (navigationFrame) cancelAnimationFrame(navigationFrame);
navigationFrame = null;
clearTimeout(navigationTimeout);
navigationTimeout = null;
}

editor.addEventListener('keydown', function(event) {
clearPendingNavigation();
if (event.defaultPrevented || (event.key !== 'PageDown' && event.key !== 'PageUp') ||
event.altKey || event.ctrlKey || event.metaKey ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

pendingNavigation = { top: editor.scrollTop };
// Selection can stay within the viewport and produce no scroll event.
navigationTimeout = setTimeout(clearPendingNavigation, 200);
});

editor.addEventListener('scroll', function() {
if (!pendingNavigation || navigationFrame) return;
// Leave an existing native animation alone. Only an immediate scrollend
// before this frame needs the smooth-scrolling fallback below.
navigationFrame = requestAnimationFrame(clearPendingNavigation);
});

editor.addEventListener('scrollend', function() {
const navigation = pendingNavigation;
clearPendingNavigation();
if (!navigation || editor.scrollTop === navigation.top) return;
// Let the browser choose the destination and update the caret/selection,
// then animate that same destination before the instant jump is painted.
const top = editor.scrollTop;
const left = editor.scrollLeft;
editor.scrollTo({ top: navigation.top, left, behavior: 'instant' });
if (editor === markdownEditor) syncEditorScrollOverlays();
editor.scrollTo({ top, left, behavior: 'smooth' });
});
['blur', 'pointerdown', 'wheel'].forEach(function(type) {
editor.addEventListener(type, clearPendingNavigation, { passive: true });
});
}

function toggleSyncScrolling() {
syncScrollingEnabled = !syncScrollingEnabled;
const syncLabel = toggleSyncButton ? toggleSyncButton.querySelector('.btn-text') : null;
Expand Down Expand Up @@ -23988,6 +24063,8 @@ ${selector} .arrowheadPath {
markdownEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown);
if (documentSplitEditor) documentSplitEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown);
initSlashCommandMenu();
initEditorPageNavigation(markdownEditor);
initEditorPageNavigation(documentSplitEditor);

markdownEditor.addEventListener("scroll", function() {
cachedScrollTop = this.scrollTop;
Expand Down
14 changes: 13 additions & 1 deletion desktop-app/resources/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9171,7 +9171,8 @@ button.live-share-participant-overflow {
--app-menu-shift-x: 0px;
--app-menu-shift-y: -4px;
opacity: 0;
display: block;
/* Closed menus must not create scrollable overflow before Popper positions them. */
display: none;
visibility: hidden;
pointer-events: none;
translate: var(--app-menu-shift-x) var(--app-menu-shift-y);
Expand Down Expand Up @@ -9202,6 +9203,7 @@ button.live-share-participant-overflow {
}

.dropdown-menu.show {
display: block;
opacity: 1;
visibility: visible;
pointer-events: auto;
Expand All @@ -9210,6 +9212,16 @@ button.live-share-participant-overflow {
transition-delay: 0s;
}

/* Keep the opening transition when the menu enters the layout. Closing menus
retain .show until initDropdownMenuMotion finishes their exit transition. */
@starting-style {
.dropdown-menu.show {
opacity: 0;
translate: var(--app-menu-shift-x) var(--app-menu-shift-y);
scale: 0.985;
}
}

.dropdown-menu.show.is-closing {
opacity: 0;
visibility: visible;
Expand Down
87 changes: 82 additions & 5 deletions script.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ document.addEventListener("DOMContentLoaded", async function () {
let isPreviewScrolling = false;
let isProgrammaticScrolling = false;
let scrollSyncTimeout = null;
let scrollSyncReleaseTimeout = null;
const synchronizedScrollPositions = new WeakMap();
let documentSplitSyncFrame = null;
let documentSplitScrollSource = null;
let documentSplitSyncReleaseTimeout = null;
Expand Down Expand Up @@ -12040,6 +12042,7 @@ document.addEventListener("DOMContentLoaded", async function () {

function syncDocumentSplitScroll(source, target) {
if (!syncScrollingEnabled || !secondarySplitTabId || !source || !target) return;
if (isSynchronizedScrollEvent(source)) return;
if (documentSplitScrollSource && documentSplitScrollSource !== source) return;
documentSplitScrollSource = source;
if (documentSplitSyncFrame) cancelAnimationFrame(documentSplitSyncFrame);
Expand All @@ -12050,7 +12053,7 @@ document.addEventListener("DOMContentLoaded", async function () {
const targetRange = target.scrollHeight - target.clientHeight;
const ratio = sourceRange > 0 ? source.scrollTop / sourceRange : 0;
const targetPosition = targetRange * ratio;
if (Number.isFinite(targetPosition)) target.scrollTop = targetPosition;
if (Number.isFinite(targetPosition)) setSynchronizedScrollTop(target, targetPosition);
documentSplitSyncReleaseTimeout = setTimeout(function() {
documentSplitScrollSource = null;
}, 48);
Expand Down Expand Up @@ -16887,14 +16890,29 @@ ${selector} .arrowheadPath {
readingTimeElement.textContent = readingTimeMinutes;
}

function isSynchronizedScrollEvent(element) {
if (element.scrollTop === synchronizedScrollPositions.get(element)) return true;
synchronizedScrollPositions.delete(element);
return false;
}

function setSynchronizedScrollTop(element, top) {
element.scrollTo({ top, behavior: 'instant' });
// Remember the browser's rounded/clamped result. A delayed target scroll
// event must not echo back and interrupt the source pane's animation.
synchronizedScrollPositions.set(element, element.scrollTop);
}

function syncEditorToPreview() {
if (isSynchronizedScrollEvent(markdownEditor)) return;
// During an outline jump, the preview owns the position until the next user
// interaction. Delayed editor scroll events must not undo a layout correction.
if (!syncScrollingEnabled || isPreviewScrolling || isProgrammaticScrolling || documentOutlineScrollTarget) return;
const sourceDocumentId = activeTabId;
if (!sourceDocumentId || previewLastRenderedTabId !== sourceDocumentId) return;
isEditorScrolling = true;

clearTimeout(scrollSyncReleaseTimeout);
if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = requestAnimationFrame(function() {
scrollSyncTimeout = null;
Expand All @@ -16910,21 +16928,24 @@ ${selector} .arrowheadPath {
editorScrollRatio;

if (!isNaN(previewScrollPosition) && isFinite(previewScrollPosition)) {
previewPane.scrollTop = previewScrollPosition;
setSynchronizedScrollTop(previewPane, previewScrollPosition);
}

setTimeout(function() {
scrollSyncReleaseTimeout = setTimeout(function() {
scrollSyncReleaseTimeout = null;
isEditorScrolling = false;
}, 50);
});
}

function syncPreviewToEditor() {
if (isSynchronizedScrollEvent(previewPane)) return;
if (!syncScrollingEnabled || isEditorScrolling || isProgrammaticScrolling) return;
const sourceDocumentId = previewLastRenderedTabId;
if (!sourceDocumentId || activeTabId !== sourceDocumentId) return;
isPreviewScrolling = true;

clearTimeout(scrollSyncReleaseTimeout);
if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = requestAnimationFrame(function() {
scrollSyncTimeout = null;
Expand All @@ -16940,17 +16961,22 @@ ${selector} .arrowheadPath {
previewScrollRatio;

if (!isNaN(editorScrollPosition) && isFinite(editorScrollPosition)) {
markdownEditor.scrollTop = editorScrollPosition;
setSynchronizedScrollTop(markdownEditor, editorScrollPosition);
syncEditorScrollOverlays();
}

setTimeout(function() {
scrollSyncReleaseTimeout = setTimeout(function() {
scrollSyncReleaseTimeout = null;
isPreviewScrolling = false;
}, 50);
});
}

function cancelPendingMainScrollSync() {
synchronizedScrollPositions.delete(markdownEditor);
synchronizedScrollPositions.delete(previewPane);
clearTimeout(scrollSyncReleaseTimeout);
scrollSyncReleaseTimeout = null;
if (scrollSyncTimeout) {
cancelAnimationFrame(scrollSyncTimeout);
scrollSyncTimeout = null;
Expand All @@ -16959,6 +16985,55 @@ ${selector} .arrowheadPath {
isPreviewScrolling = false;
}

function initEditorPageNavigation(editor) {
if (!editor) return;
let pendingNavigation = null;
let navigationFrame = null;
let navigationTimeout = null;

function clearPendingNavigation() {
pendingNavigation = null;
if (navigationFrame) cancelAnimationFrame(navigationFrame);
navigationFrame = null;
clearTimeout(navigationTimeout);
navigationTimeout = null;
}

editor.addEventListener('keydown', function(event) {
clearPendingNavigation();
if (event.defaultPrevented || (event.key !== 'PageDown' && event.key !== 'PageUp') ||
event.altKey || event.ctrlKey || event.metaKey ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;

pendingNavigation = { top: editor.scrollTop };
// Selection can stay within the viewport and produce no scroll event.
navigationTimeout = setTimeout(clearPendingNavigation, 200);
});

editor.addEventListener('scroll', function() {
if (!pendingNavigation || navigationFrame) return;
// Leave an existing native animation alone. Only an immediate scrollend
// before this frame needs the smooth-scrolling fallback below.
navigationFrame = requestAnimationFrame(clearPendingNavigation);
});

editor.addEventListener('scrollend', function() {
const navigation = pendingNavigation;
clearPendingNavigation();
if (!navigation || editor.scrollTop === navigation.top) return;
// Let the browser choose the destination and update the caret/selection,
// then animate that same destination before the instant jump is painted.
const top = editor.scrollTop;
const left = editor.scrollLeft;
editor.scrollTo({ top: navigation.top, left, behavior: 'instant' });
if (editor === markdownEditor) syncEditorScrollOverlays();
editor.scrollTo({ top, left, behavior: 'smooth' });
});
['blur', 'pointerdown', 'wheel'].forEach(function(type) {
editor.addEventListener(type, clearPendingNavigation, { passive: true });
});
}

function toggleSyncScrolling() {
syncScrollingEnabled = !syncScrollingEnabled;
const syncLabel = toggleSyncButton ? toggleSyncButton.querySelector('.btn-text') : null;
Expand Down Expand Up @@ -23988,6 +24063,8 @@ ${selector} .arrowheadPath {
markdownEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown);
if (documentSplitEditor) documentSplitEditor.addEventListener('keydown', handleMarkdownEditorStructureKeydown);
initSlashCommandMenu();
initEditorPageNavigation(markdownEditor);
initEditorPageNavigation(documentSplitEditor);

markdownEditor.addEventListener("scroll", function() {
cachedScrollTop = this.scrollTop;
Expand Down
Loading
Loading