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
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,194 @@ export const ReadOnly: Story = {
await expect(canvas.queryByTitle('Bold')).toBeNull();
},
};

/**
* Provider stub seeding enough content that both split panes overflow and
* can actually scroll (the short default seed cannot).
*/
const longProviderFactory: CollabProviderFactory = (options) => {
const ytext = options.document.getText('content');
if (!ytext.length) {
const paragraphs = Array.from(
{ length: 120 },
(_, i) =>
`\n\nParagraph ${i + 1}: lorem ipsum dolor sit amet, consectetur adipiscing elit.`,
).join('');
ytext.insert(0, `# Long Document${paragraphs}`);
}
return { destroy: () => {} };
};

/**
* Resolves after n animation frames so effects and rAF callbacks have run.
*/
const rafFrames = (n: number) =>
new Promise<void>((resolve) => {
const step = () => (--n <= 0 ? resolve() : requestAnimationFrame(step));
requestAnimationFrame(step);
});

/**
* Resolves on the element's next scroll event; rejects after timeoutMs so a
* swallowed mirror update fails fast instead of hanging the run.
*/
const nextScrollEvent = (el: HTMLElement, timeoutMs = 1000) =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
el.removeEventListener('scroll', onScroll);
reject(new Error(`no scroll event within ${timeoutMs}ms`));
}, timeoutMs);
const onScroll = () => {
clearTimeout(timer);
el.removeEventListener('scroll', onScroll);
resolve();
};
el.addEventListener('scroll', onScroll);
});

const renderSplitWithLongDoc: Story['render'] = function RenderedStory() {
return (
<div className="h-96">
<DocumentMain
docId="story-doc-long"
mode="both"
doc={mockDoc}
setDoc={fn()}
createProvider={longProviderFactory}
/>
</div>
);
};

/** Enables synced scrolling via the handle overlay button. */
async function enableSyncScroll(canvasElement: HTMLElement) {
const toggle = canvasElement.querySelector(
'[data-panel-resize-handle-id] div.absolute',
) as HTMLElement | null;
if (!toggle) throw new Error('scroll-sync toggle not found');
toggle.click();
await rafFrames(3);
}

export const SplitSyncMirrorsScroll: Story = {
render: renderSplitWithLongDoc,
play: async ({ canvasElement }) => {
await enableSyncScroll(canvasElement);
const editor = canvasElement
.querySelector('.cm-editor')
?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement;
const preview = canvasElement.querySelector(
'.markdown-previewer',
) as HTMLElement;

// Editor -> preview. Expectations are computed against live geometry:
// CodeMirror's scrollHeight can still grow during deep scrolls.
const edMax = editor.scrollHeight - editor.clientHeight;
const pvMax = preview.scrollHeight - preview.clientHeight;

editor.scrollTop = edMax * 0.4;
await nextScrollEvent(preview);
await rafFrames(2);
expect(
Math.abs(
preview.scrollTop -
(editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) *
(preview.scrollHeight - preview.clientHeight),
),
).toBeLessThan(pvMax * 0.05);

// Preview -> editor
const pvMax2 = preview.scrollHeight - preview.clientHeight;
preview.scrollTop = pvMax2 * 0.8;
await nextScrollEvent(editor);
await rafFrames(2);
expect(
Math.abs(
editor.scrollTop -
(preview.scrollTop / (preview.scrollHeight - preview.clientHeight)) *
(editor.scrollHeight - editor.clientHeight),
),
).toBeLessThan(edMax * 0.05);
},
};

export const SplitSyncSurvivesLateEcho: Story = {
render: renderSplitWithLongDoc,
play: async ({ canvasElement }) => {
await enableSyncScroll(canvasElement);
const editor = canvasElement
.querySelector('.cm-editor')
?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement;
const preview = canvasElement.querySelector(
'.markdown-previewer',
) as HTMLElement;

const edMax = editor.scrollHeight - editor.clientHeight;
const pvMax = preview.scrollHeight - preview.clientHeight;

// A normal mirrored scroll leaves both panes aligned...
editor.scrollTop = edMax * 0.5;
await nextScrollEvent(preview);
await rafFrames(2);

// ...then the mirrored pane's own scroll event arrives one frame LATE
// (Firefox delivers it after the syncing flag was already reset). It must
// be recognized as an echo, not consume the suppression state.
preview.dispatchEvent(new Event('scroll'));

// And a genuine editor scroll in the SAME task must still be mirrored.
editor.scrollTop = edMax * 0.75;
await rafFrames(5);
expect(
Math.abs(
preview.scrollTop -
(editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) *
(preview.scrollHeight - preview.clientHeight),
),
).toBeLessThan(pvMax * 0.05);
},
};

export const SplitSyncMirrorsLatestUnderBurst: Story = {
render: renderSplitWithLongDoc,
play: async ({ canvasElement }) => {
await enableSyncScroll(canvasElement);
const editor = canvasElement
.querySelector('.cm-editor')
?.closest('div.custom-scrollbar.overflow-y-scroll') as HTMLElement;
const preview = canvasElement.querySelector(
'.markdown-previewer',
) as HTMLElement;

const edMax = editor.scrollHeight - editor.clientHeight;
const pvMax = preview.scrollHeight - preview.clientHeight;

// Two scroll updates land within one frame but are delivered separately
// (Firefox dispatches a scroll event per wheel-tick write instead of
// coalescing them). The second must not be swallowed by suppression
// state left behind by the first: the mirror has to end up at the
// LATEST position once the frame flushes.
editor.scrollTop = edMax * 0.2;
await nextScrollEvent(editor);
editor.scrollTop = edMax * 0.6;
await rafFrames(3);
expect(
Math.abs(
preview.scrollTop -
(editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) *
(preview.scrollHeight - preview.clientHeight),
),
).toBeLessThan(pvMax * 0.05);

// ...and a third update after the burst still mirrors.
editor.scrollTop = edMax * 0.85;
await rafFrames(3);
expect(
Math.abs(
preview.scrollTop -
(editor.scrollTop / (editor.scrollHeight - editor.clientHeight)) *
(preview.scrollHeight - preview.clientHeight),
),
).toBeLessThan(pvMax * 0.05);
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { cn } from '@/utils/cn';

import { MarkdownEditor } from './MarkdownEditor';
import { MarkdownPreview } from './MarkdownPreview';
import { useScrollSync } from './use-scroll-sync';

/**
* Wires collaboration state into either MarkdownEditor or MarkdownPreview per view mode.
Expand Down Expand Up @@ -44,8 +45,12 @@ export function DocumentMain({
);
const editorScrollRef = useRef<HTMLDivElement>(null);
const previewScrollRef = useRef<HTMLDivElement>(null);
const isSyncingRef = useRef(false);
const [syncScroll, setSyncScroll] = useState(false);
const { handleEditorScroll, handlePreviewScroll } = useScrollSync({
enabled: syncScroll,
editorRef: editorScrollRef,
previewRef: previewScrollRef,
});

useEffect(() => {
if (doc && text !== doc.content) {
Expand All @@ -70,60 +75,6 @@ export function DocumentMain({
percent * (previewEl.scrollHeight - previewEl.clientHeight);
}, [syncScroll]);

const handleEditorScroll = () => {
if (!syncScroll) return;
if (
!syncScroll ||
isSyncingRef.current ||
!editorScrollRef.current ||
!previewScrollRef.current
) {
return;
}

isSyncingRef.current = true;

const editor = editorScrollRef.current;
const preview = previewScrollRef.current;

const scrollRatio =
editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
preview.scrollTop =
scrollRatio * (preview.scrollHeight - preview.clientHeight);

// Use a shorter timeout and requestAnimationFrame
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
};

const handlePreviewScroll = () => {
if (!syncScroll) return;

if (
!syncScroll ||
isSyncingRef.current ||
!editorScrollRef.current ||
!previewScrollRef.current
) {
return;
}

isSyncingRef.current = true;

const editor = editorScrollRef.current;
const preview = previewScrollRef.current;

const scrollRatio =
preview.scrollTop / (preview.scrollHeight - preview.clientHeight);
editor.scrollTop =
scrollRatio * (editor.scrollHeight - editor.clientHeight);

// Use a shorter timeout and requestAnimationFrame
requestAnimationFrame(() => {
isSyncingRef.current = false;
});
};
if (!docId || !isReady || !ydoc || !ytext || !provider) {
return (
<div className="flex items-center justify-center h-screen w-full text-muted">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef } from 'react';

type PaneKey = 'editor' | 'preview';

/**
* Race-free bidirectional scroll syncing between two panes.
*
* Mirrors scroll positions once per animation frame (latest wins) and
* recognizes its own mirrored writes by position: an event whose target is
* already at the last-written offset is an echo and is ignored. This replaces
* timing-flag suppression, which browsers deliver in different orders
* (Firefox dispatches the mirrored pane's scroll event after the reset frame,
* swallowing every other genuine update).
*
* @param args - Hook arguments.
* @param args.enabled - Mirrors only run while true.
* @param args.editorRef - Scrollable editor container.
* @param args.previewRef - Scrollable preview container.
* @returns Stable scroll handlers to attach to each pane's container.
*/
export function useScrollSync(args: {
/** Mirrors only run while true. */
enabled: boolean;
/** Scrollable editor container. */
editorRef: React.RefObject<HTMLElement | null>;
/** Scrollable preview container. */
previewRef: React.RefObject<HTMLElement | null>;
}) {
const { enabled, editorRef, previewRef } = args;
const enabledRef = useRef(enabled);
const lastWritten = useRef<Record<PaneKey, number>>({
editor: Number.NaN,
preview: Number.NaN,
});
const frameRef = useRef<number | null>(null);
const pendingSource = useRef<PaneKey | null>(null);

useEffect(() => {
enabledRef.current = enabled;
if (!enabled) {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
pendingSource.current = null;
lastWritten.current = { editor: Number.NaN, preview: Number.NaN };
}
return () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
};
}, [enabled]);

const applyMirror = useCallback(() => {
frameRef.current = null;
const source = pendingSource.current;
pendingSource.current = null;
if (!source || !enabledRef.current) return;

const from = source === 'editor' ? editorRef.current : previewRef.current;
const to = source === 'editor' ? previewRef.current : editorRef.current;
if (!from || !to) return;

const fromRange = from.scrollHeight - from.clientHeight;
if (fromRange <= 0) return;

const top =
(from.scrollTop / fromRange) * (to.scrollHeight - to.clientHeight);
lastWritten.current[source === 'editor' ? 'preview' : 'editor'] = top;
to.scrollTop = top;
}, [editorRef, previewRef]);

const queueMirror = useCallback(
(which: PaneKey) => {
if (!enabledRef.current) return;
const el = which === 'editor' ? editorRef.current : previewRef.current;
if (!el) return;

const written = lastWritten.current[which];
if (!Number.isNaN(written) && Math.abs(el.scrollTop - written) < 1) {
return;
}

pendingSource.current = which;
if (frameRef.current === null) {
frameRef.current = requestAnimationFrame(applyMirror);
}
},
[editorRef, previewRef, applyMirror],
);

const handleEditorScroll = useCallback(
() => queueMirror('editor'),
[queueMirror],
);
const handlePreviewScroll = useCallback(
() => queueMirror('preview'),
[queueMirror],
);

return { handleEditorScroll, handlePreviewScroll };
}
Loading