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
50 changes: 25 additions & 25 deletions dist/lite/markedit-preview.js

Large diffs are not rendered by default.

536 changes: 268 additions & 268 deletions dist/markedit-preview.js

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions src/scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,28 @@ export function startObserving(sourcePane: HTMLElement, targetPane: HTMLElement)
return;
}

if (states.scrollUpdater !== undefined) {
clearTimeout(states.scrollUpdater);
}

// Skip phantom scroll events where the editor didn't actually move
let lastDocument = MarkEdit.editorView.state.doc;
let lastSelection = MarkEdit.editorView.state.selection;
states.lastSourceScrollTop = sourcePane.scrollTop;

const didScroll = () => {
if (Math.abs(sourcePane.scrollTop - states.lastSourceScrollTop) < 0.5) {
const { doc, selection } = MarkEdit.editorView.state;
const selectionChanged = doc === lastDocument && !selection.eq(lastSelection);
lastDocument = doc;
lastSelection = selection;
if (!selectionChanged && Math.abs(sourcePane.scrollTop - states.lastSourceScrollTop) < 0.5) {
return;
}

states.lastSourceScrollTop = sourcePane.scrollTop;
syncScrollProgress(sourcePane, targetPane);
if (selectionChanged || !targetPane.classList.contains('overlay')) {
syncScrollProgress(sourcePane, targetPane);
Comment thread
cyanzhong marked this conversation as resolved.
}
};

if ('onscrollend' in window) {
Expand Down
40 changes: 28 additions & 12 deletions src/view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,9 @@ export function setViewMode(mode: ViewMode, needsDisplay = true) {
}

if (needsDisplay) {
renderHtmlPreview();
renderHtmlPreview(true);
Comment thread
cyanzhong marked this conversation as resolved.
} else {
++states.renderVersion;
}
}

Expand Down Expand Up @@ -197,26 +199,38 @@ export function isEditorOnlyMode() {
return mode === ViewMode.edit || mode === ViewMode.syntaxHidden;
}

export async function renderHtmlPreview() {
export async function renderHtmlPreview(syncScroll = currentViewMode() !== ViewMode.preview || !previewPane.hasChildNodes()) {
const renderVersion = ++states.renderVersion;
Comment thread
cyanzhong marked this conversation as resolved.
if (isEditorOnlyMode()) {
return;
}

const html = replaceImageURLs(await getRenderedHtml());
if (renderVersion !== states.renderVersion) {
return;
}

const offset = { top: previewPane.scrollTop, left: previewPane.scrollLeft };
previewPane.innerHTML = html;

handlePostRender(() => {
syncScrollProgress(
getEditPane(),
getPreviewPane(),
false,
);
const pageZoom = localStorage.getItem(CacheKeys.previewPageZoomKey);
if (pageZoom !== null) {
setPageZoom(pageZoom);
}

const pageZoom = localStorage.getItem(CacheKeys.previewPageZoomKey);
if (pageZoom !== null) {
setPageZoom(pageZoom);
const alignPosition = () => {
if (syncScroll && renderVersion === states.renderVersion) {
syncScrollProgress(getEditPane(), previewPane, false);
}
});
};

if (syncScroll) {
alignPosition();
} else {
previewPane.scrollTo(offset);
}

handlePostRender(alignPosition);
}

export function handlePageZoom(event: KeyboardEvent) {
Expand Down Expand Up @@ -446,7 +460,9 @@ function handleTaskItemToggle(event: MouseEvent) {
const states: {
viewMode: ViewMode;
splitter: Splitter | undefined;
renderVersion: number;
} = {
viewMode: ViewMode.edit,
splitter: undefined,
renderVersion: 0,
};
162 changes: 162 additions & 0 deletions tests/scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { ChangeSet, EditorSelection, Text } from '@codemirror/state';
import { startObserving } from '../src/scroll';

const mocks = vi.hoisted(() => ({
doc: undefined as unknown as Text,
selection: undefined as unknown as EditorSelection,
syncScroll: true,
scrollToPosition: vi.fn(),
}));

vi.mock('markedit-api', () => ({
MarkEdit: {
editorView: {
lineBlockAtHeight: () => ({ from: 0 }),
state: {
get selection() { return mocks.selection; },
get doc() { return mocks.doc; },
},
domAtPos: () => ({ node: document.createElement('div') }),
},
},
}));

vi.mock('../src/support/settings', () => ({ get syncScroll() { return mocks.syncScroll; } }));
vi.mock('../src/shared/utils', () => ({
getClosestLine: () => null,
getBlockRange: vi.fn(),
getElementTop: vi.fn(),
scrollToElement: vi.fn(),
scrollToPosition: mocks.scrollToPosition,
}));

beforeEach(() => {
vi.useFakeTimers();
mocks.doc = Text.of(['Example document']);
mocks.selection = EditorSelection.single(0);
mocks.syncScroll = true;
mocks.scrollToPosition.mockClear();
});

afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});

function scroll(source: HTMLElement, top: number) {
source.scrollTop = top;
source.dispatchEvent(new Event('onscrollend' in window ? 'scrollend' : 'scroll'));
}

describe('Editor scroll synchronization', () => {
test('ignores hidden-editor scrolling in preview mode', () => {
const source = document.createElement('div');
const target = document.createElement('div');
target.classList.add('overlay');

startObserving(source, target);
scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();

target.classList.remove('overlay');
scroll(source, 500);
vi.runAllTimers();
expect(mocks.scrollToPosition).toHaveBeenCalledWith(target, 0, true);
});

test('continues synchronizing side-by-side scrolling', () => {
const source = document.createElement('div');
const target = document.createElement('div');
startObserving(source, target);
scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).toHaveBeenCalledWith(target, 0, true);
});

test('syncs selection navigation in preview mode but ignores later corrections', () => {
const source = document.createElement('div');
const target = document.createElement('div');
target.classList.add('overlay');
startObserving(source, target);

mocks.selection = EditorSelection.single(40);
scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).toHaveBeenCalledWith(target, 0, true);

mocks.scrollToPosition.mockClear();
scroll(source, 450);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();
});

test('ignores document-mapped selections but allows subsequent navigation', () => {
const source = document.createElement('div');
const target = document.createElement('div');
target.classList.add('overlay');
mocks.selection = EditorSelection.single(8);
startObserving(source, target);

const changes = ChangeSet.of({ from: 0, insert: 'External ' }, mocks.doc.length);
mocks.selection = mocks.selection.map(changes);
mocks.doc = changes.apply(mocks.doc);
scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();

scroll(source, 450);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();

mocks.selection = EditorSelection.single(0);
scroll(source, 0);
vi.runAllTimers();
expect(mocks.scrollToPosition).toHaveBeenCalledWith(target, 0, true);
});

test('uses the reset selection as the baseline for a new observer', () => {
const source = document.createElement('div');
const target = document.createElement('div');
target.classList.add('overlay');
mocks.selection = EditorSelection.single(40);
startObserving(source, target);

scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();
});

test('ignores phantom scroll events', () => {
const source = document.createElement('div');
startObserving(source, document.createElement('div'));
scroll(source, 0);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();
});

test('cancels pending scroll synchronization when observation restarts', () => {
const source = document.createElement('div');
const target = document.createElement('div');
startObserving(source, target);
scroll(source, 400);

startObserving(document.createElement('div'), target);
mocks.scrollToPosition.mockClear();
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();
});

test('respects disabled synchronization', () => {
mocks.syncScroll = false;
const source = document.createElement('div');
startObserving(source, document.createElement('div'));

vi.runAllTimers();
scroll(source, 400);
vi.runAllTimers();
expect(mocks.scrollToPosition).not.toHaveBeenCalled();
});
});
Loading