diff --git a/CHANGELOG.md b/CHANGELOG.md index 2588ac26..9d478caf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index a8dac918..36da8191 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -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; @@ -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); @@ -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); @@ -16887,7 +16890,21 @@ ${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; @@ -16895,6 +16912,7 @@ ${selector} .arrowheadPath { if (!sourceDocumentId || previewLastRenderedTabId !== sourceDocumentId) return; isEditorScrolling = true; + clearTimeout(scrollSyncReleaseTimeout); if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); scrollSyncTimeout = requestAnimationFrame(function() { scrollSyncTimeout = null; @@ -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; @@ -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; @@ -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; @@ -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; diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 478aac7d..49a6e2f6 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -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); @@ -9202,6 +9203,7 @@ button.live-share-participant-overflow { } .dropdown-menu.show { + display: block; opacity: 1; visibility: visible; pointer-events: auto; @@ -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; diff --git a/script.js b/script.js index a8dac918..36da8191 100644 --- a/script.js +++ b/script.js @@ -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; @@ -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); @@ -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); @@ -16887,7 +16890,21 @@ ${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; @@ -16895,6 +16912,7 @@ ${selector} .arrowheadPath { if (!sourceDocumentId || previewLastRenderedTabId !== sourceDocumentId) return; isEditorScrolling = true; + clearTimeout(scrollSyncReleaseTimeout); if (scrollSyncTimeout) cancelAnimationFrame(scrollSyncTimeout); scrollSyncTimeout = requestAnimationFrame(function() { scrollSyncTimeout = null; @@ -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; @@ -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; @@ -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; @@ -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; diff --git a/styles.css b/styles.css index 478aac7d..49a6e2f6 100644 --- a/styles.css +++ b/styles.css @@ -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); @@ -9202,6 +9203,7 @@ button.live-share-participant-overflow { } .dropdown-menu.show { + display: block; opacity: 1; visibility: visible; pointer-events: auto; @@ -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; diff --git a/tests/e2e/editor-page-navigation.spec.js b/tests/e2e/editor-page-navigation.spec.js new file mode 100644 index 00000000..9fcc9601 --- /dev/null +++ b/tests/e2e/editor-page-navigation.spec.js @@ -0,0 +1,262 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +async function workspacePosition(page) { + return page.evaluate(() => { + const app = document.querySelector('.app-container'); + const bounds = selector => { + const rect = document.querySelector(selector).getBoundingClientRect(); + return { left: rect.left, right: rect.right }; + }; + return { + windowLeft: window.scrollX, + appLeft: app.scrollLeft, + header: bounds('.app-header'), + workspace: bounds('#document-workspace-shell'), + editor: bounds('#markdown-editor'), + preview: bounds('.preview-pane') + }; + }); +} + +async function recordPaging(editor, key) { + const keys = Array.isArray(key) ? key : [key]; + await editor.evaluate((element, pagingKey) => { + element.pagingResult = new Promise(resolve => { + element.addEventListener('keydown', function start(event) { + if (event.key !== pagingKey) return; + element.removeEventListener('keydown', start); + const frames = []; + const initialTop = element.scrollTop; + const started = performance.now(); + let previousTop = initialTop; + let stableFrames = 0; + function sample() { + const top = element.scrollTop; + frames.push({ top, appLeft: document.querySelector('.app-container').scrollLeft }); + stableFrames = top === previousTop ? stableFrames + 1 : 0; + previousTop = top; + if ((performance.now() - started > 100 && stableFrames >= 6) || performance.now() - started > 2500) { + resolve({ frames, selectionStart: element.selectionStart, selectionEnd: element.selectionEnd }); + } else { + requestAnimationFrame(sample); + } + } + sample(); + }); + }); + }, keys[0].split('+').at(-1)); + for (const pressedKey of keys) await editor.press(pressedKey); + return editor.evaluate(element => element.pagingResult); +} + +function expectSmoothPaging(result, direction) { + const positions = [...new Set(result.frames.map(frame => frame.top))]; + expect((positions.at(-1) - positions[0]) * direction).toBeGreaterThan(0); + // Firefox can extend a selection with only a one-pixel viewport adjustment; + // integer scrollTop readings cannot expose an intermediate position there. + if (Math.abs(positions.at(-1) - positions[0]) > 1) { + expect(positions.length, `paging should paint intermediate scroll positions: ${positions.join(', ')}`).toBeGreaterThan(2); + } + for (let i = 1; i < positions.length; i++) { + expect((positions[i] - positions[i - 1]) * direction, `scroll sync must not reverse the animation: ${positions.join(', ')}`).toBeGreaterThanOrEqual(-1); + } + expect(result.frames.every(frame => frame.appLeft === 0)).toBe(true); +} + +const longDocument = Array.from({ length: 120 }, (_, i) => `Paragraph ${i + 1}: keyboard navigation remains native.`).join('\n\n'); + +async function waitForScrollIdle(page) { + await page.evaluate(() => new Promise(resolve => { + let previous = ''; + let stableFrames = 0; + function sample() { + const positions = ['#markdown-editor', '.preview-pane', '#document-split-editor'].map(selector => document.querySelector(selector).scrollTop).join(','); + stableFrames = positions === previous ? stableFrames + 1 : 0; + previous = positions; + if (stableFrames >= 6) resolve(); + else requestAnimationFrame(sample); + } + sample(); + })); +} + +async function prepareLongDocument(page) { + await setEditorContent(page, longDocument); + await expect(page.locator('#markdown-preview')).toContainText('Paragraph 120'); + await waitForScrollIdle(page); + const editor = page.locator('#markdown-editor'); + await editor.evaluate(element => { + element.focus(); + element.setSelectionRange(0, 0); + element.scrollTop = 0; + }); + await waitForScrollIdle(page); + return editor; +} + +for (const width of [1280, 1912]) { + test.describe(`editor page navigation at ${width}px`, () => { + test.use({ viewport: { width, height: 948 }, deviceScaleFactor: 1 }); + + for (const key of ['PageDown', 'PageUp']) { + test(`${key} keeps the Welcome split view horizontally aligned`, async ({ page }) => { + await openApp(page); + const editor = page.getByRole('textbox', { name: 'Markdown editor input with live preview' }); + await expect(page.locator('.content-container')).toHaveClass(/view-split/); + const original = await workspacePosition(page); + const content = await editor.inputValue(); + + await editor.evaluate((element, startAtEnd) => { + element.focus(); + const position = startAtEnd ? element.value.length : 0; + element.setSelectionRange(position, position); + element.scrollTop = startAtEnd ? element.scrollHeight : 0; + }, key === 'PageUp'); + const before = await editor.evaluate(element => element.selectionStart); + await editor.press(key); + + await expect.poll(() => editor.evaluate(element => element.selectionStart)).not.toBe(before); + await expect.poll(() => workspacePosition(page)).toEqual(original); + await expect(editor).toBeFocused(); + await expect(editor).toHaveValue(content); + }); + } + }); +} + +test('page navigation and selection still work after opening and closing Settings', async ({ page }) => { + await openApp(page); + await setEditorContent(page, longDocument); + const settings = page.locator('#workspaceSettingsDropdown'); + const menu = page.locator('[aria-labelledby="workspaceSettingsDropdown"]'); + await settings.click(); + await expect(menu).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(menu).toBeHidden(); + // Bootstrap removes Popper's placement when it closes the menu. It must not + // leave a hidden menu extending the app's horizontal scroll area afterward. + await expect.poll(() => page.locator('.app-container').evaluate(app => app.scrollWidth - app.clientWidth)).toBe(0); + + const editor = page.getByRole('textbox', { name: 'Markdown editor input with live preview' }); + const original = await workspacePosition(page); + await editor.evaluate(element => { + element.focus(); + element.setSelectionRange(0, 0); + element.scrollTop = 0; + }); + await recordPaging(editor, 'PageDown'); + await expect.poll(() => editor.evaluate(element => element.scrollTop)).toBeGreaterThan(0); + const caret = await editor.evaluate(element => element.selectionStart); + expect(caret).toBeGreaterThan(0); + const scrollTop = await editor.evaluate(element => element.scrollTop); + + await recordPaging(editor, 'Shift+PageDown'); + await expect.poll(() => editor.evaluate(element => element.selectionEnd - element.selectionStart)).toBeGreaterThan(0); + await expect.poll(() => workspacePosition(page)).toEqual(original); + + await recordPaging(editor, 'PageUp'); + await recordPaging(editor, 'PageUp'); + await expect.poll(() => editor.evaluate(element => element.scrollTop)).toBeLessThan(scrollTop); + await expect.poll(() => workspacePosition(page)).toEqual(original); +}); + +test.describe('smooth Editor paging', () => { + test.use({ reducedMotion: 'no-preference' }); + + for (const sync of [true, false]) { + test(`Page Up/Down and Shift selection animate with sync ${sync ? 'on' : 'off'}`, async ({ page }) => { + await openApp(page); + if (!sync) await page.locator('#toggle-sync').click(); + const editor = await prepareLongDocument(page); + const originalPreviewTop = await page.locator('.preview-pane').evaluate(element => element.scrollTop); + + const down = await recordPaging(editor, 'PageDown'); + expectSmoothPaging(down, 1); + expect(down.selectionStart).toBeGreaterThan(0); + expect(down.selectionEnd).toBe(down.selectionStart); + const selected = await recordPaging(editor, 'Shift+PageDown'); + expect(selected.selectionStart).toBe(down.selectionStart); + expect(selected.selectionEnd).toBeGreaterThan(selected.selectionStart); + // The first extension can fit in the visible page. Extend again to + // exercise scrolling without changing the browser's selection behavior. + const extended = await recordPaging(editor, 'Shift+PageDown'); + expectSmoothPaging(extended, 1); + expect(extended.selectionEnd).toBeGreaterThan(selected.selectionEnd); + const up = await recordPaging(editor, 'PageUp'); + expectSmoothPaging(up, -1); + expect(up.selectionEnd).toBe(up.selectionStart); + await expect(editor).toBeFocused(); + await expect(editor).toHaveValue(longDocument); + + const ratios = await page.evaluate(() => { + const editor = document.querySelector('#markdown-editor'); + const preview = document.querySelector('.preview-pane'); + return { + editor: editor.scrollTop / (editor.scrollHeight - editor.clientHeight), + preview: preview.scrollTop / (preview.scrollHeight - preview.clientHeight) + }; + }); + if (sync) expect(Math.abs(ratios.editor - ratios.preview)).toBeLessThan(0.005); + else expect(await page.locator('.preview-pane').evaluate(element => element.scrollTop)).toBe(originalPreviewTop); + }); + } + + test('repeated paging reaches the next page and preserves native cursor movement', async ({ page }) => { + await openApp(page); + const editor = await prepareLongDocument(page); + const down = await recordPaging(editor, 'PageDown'); + const repeated = await recordPaging(editor, ['PageDown', 'PageDown']); + expectSmoothPaging(repeated, 1); + expect(repeated.selectionStart).toBeGreaterThan(down.selectionStart); + expect(repeated.selectionEnd).toBe(repeated.selectionStart); + await expect(editor).toHaveValue(longDocument); + }); + + test('the secondary document Editor also animates paging', async ({ page }) => { + await openApp(page); + await prepareLongDocument(page); + const firstTab = page.locator('#tab-list .tab-item').first(); + await page.locator('#tab-new-btn').click(); + await prepareLongDocument(page); + const secondId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); + await firstTab.click({ button: 'right' }); + await page.getByRole('menuitem', { name: 'Open in split view' }).click(); + await page.locator('#document-split-destination').selectOption(secondId); + await page.locator('#document-split-modal-confirm').click(); + + const editor = page.locator('#document-split-editor'); + await expect(editor).toBeVisible(); + await editor.evaluate(element => { + element.focus(); + element.setSelectionRange(0, 0); + element.scrollTop = 0; + }); + expectSmoothPaging(await recordPaging(editor, 'PageDown'), 1); + expectSmoothPaging(await recordPaging(editor, 'PageUp'), -1); + await expect(editor).toHaveValue(longDocument); + await expect(page.locator('#markdown-editor')).toHaveValue(longDocument); + }); + + test('reduced motion leaves immediate native Page Up unchanged', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await openApp(page); + const editor = await prepareLongDocument(page); + const down = await recordPaging(editor, 'PageDown'); + const up = await recordPaging(editor, 'PageUp'); + const positions = [...new Set(up.frames.map(frame => frame.top))]; + expect(positions.length).toBe(2); + expect(positions[1]).toBeLessThan(positions[0]); + expect(up.selectionStart).toBeLessThan(down.selectionStart); + await expect(editor).toHaveValue(longDocument); + }); + + test('paging remains smooth at a mobile viewport width', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openApp(page); + const editor = await prepareLongDocument(page); + expectSmoothPaging(await recordPaging(editor, 'PageDown'), 1); + expectSmoothPaging(await recordPaging(editor, 'PageUp'), -1); + await expect(editor).toHaveValue(longDocument); + }); +}); diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index db3af361..fedcfd3d 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -84,6 +84,8 @@ The toolbar can: The editor also supports list continuation on Enter, two-space indent on Tab, outdent on Shift+Tab, and custom undo/redo. View-only shared tabs block editing actions and keep reading, find, help, and fullscreen available. +With the Editor focused, **Page Up** and **Page Down** scroll smoothly without shifting the workspace sideways. Hold **Shift** while paging to extend the text selection. This also works in the secondary document Editor. The app respects your device's reduced-motion preference and keeps synchronized panes aligned during paging. + ## Comments Use Comments when you want to leave anchored feedback without editing the Markdown source.