Skip to content

Commit

Permalink
Desktop: Resolves #2242: Implements Sync-Scroll for Markdown Editor a…
Browse files Browse the repository at this point in the history
…nd Viewer (#5512)
  • Loading branch information
ken1kob authored Nov 3, 2021
1 parent 725abbc commit 630a400
Show file tree
Hide file tree
Showing 12 changed files with 386 additions and 108 deletions.
9 changes: 9 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js.
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
Expand Down Expand Up @@ -678,6 +681,9 @@ packages/app-desktop/gui/style/StyledTextInput.js.map
packages/app-desktop/gui/utils/NoteListUtils.d.ts
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/NoteListUtils.js.map
packages/app-desktop/gui/utils/SyncScrollMap.d.ts
packages/app-desktop/gui/utils/SyncScrollMap.js
packages/app-desktop/gui/utils/SyncScrollMap.js.map
packages/app-desktop/gui/utils/convertToScreenCoordinates.d.ts
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
Expand Down Expand Up @@ -1848,6 +1854,9 @@ packages/renderer/MdToHtml/rules/mermaid.js.map
packages/renderer/MdToHtml/rules/sanitize_html.d.ts
packages/renderer/MdToHtml/rules/sanitize_html.js
packages/renderer/MdToHtml/rules/sanitize_html.js.map
packages/renderer/MdToHtml/rules/source_map.d.ts
packages/renderer/MdToHtml/rules/source_map.js
packages/renderer/MdToHtml/rules/source_map.js.map
packages/renderer/MdToHtml/setupLinkify.d.ts
packages/renderer/MdToHtml/setupLinkify.js
packages/renderer/MdToHtml/setupLinkify.js.map
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,9 @@ packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useLineSorting.js.
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollHandler.js.map
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.d.ts
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
Expand Down Expand Up @@ -661,6 +664,9 @@ packages/app-desktop/gui/style/StyledTextInput.js.map
packages/app-desktop/gui/utils/NoteListUtils.d.ts
packages/app-desktop/gui/utils/NoteListUtils.js
packages/app-desktop/gui/utils/NoteListUtils.js.map
packages/app-desktop/gui/utils/SyncScrollMap.d.ts
packages/app-desktop/gui/utils/SyncScrollMap.js
packages/app-desktop/gui/utils/SyncScrollMap.js.map
packages/app-desktop/gui/utils/convertToScreenCoordinates.d.ts
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
Expand Down Expand Up @@ -1831,6 +1837,9 @@ packages/renderer/MdToHtml/rules/mermaid.js.map
packages/renderer/MdToHtml/rules/sanitize_html.d.ts
packages/renderer/MdToHtml/rules/sanitize_html.js
packages/renderer/MdToHtml/rules/sanitize_html.js.map
packages/renderer/MdToHtml/rules/source_map.d.ts
packages/renderer/MdToHtml/rules/source_map.js
packages/renderer/MdToHtml/rules/source_map.js.map
packages/renderer/MdToHtml/setupLinkify.d.ts
packages/renderer/MdToHtml/setupLinkify.js
packages/renderer/MdToHtml/setupLinkify.js.map
Expand Down
14 changes: 14 additions & 0 deletions packages/app-cli/tests/MdToHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,18 @@ describe('MdToHtml', function() {
}
}));

it('should return attributes of line numbers', (async () => {
const mdToHtml = newTestMdToHtml();

// Mapping information between source lines and html elements is
// annotated.
{
const input = '# Head\nFruits\n- Apple\n';
const result = await mdToHtml.render(input, null, { bodyOnly: true, mapsToLine: true });
expect(result.html.trim()).toBe('<h1 id="head" class="maps-to-line" source-line="0">Head</h1>\n' +
'<p class="maps-to-line" source-line="1">Fruits</p>\n' +
'<ul>\n<li class="maps-to-line" source-line="2">Apple</li>\n</ul>'
);
}
}));
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
import { CommandValue } from '../../utils/types';
import { useScrollHandler, usePrevious, cursorPositionToTextOffset } from './utils';
import { usePrevious, cursorPositionToTextOffset } from './utils';
import useScrollHandler, { translateScrollPercentToEditor, translateScrollPercentToViewer } from './utils/useScrollHandler';
import useElementSize from '@joplin/lib/hooks/useElementSize';
import Toolbar from './Toolbar';
import styles_ from './styles';
Expand Down Expand Up @@ -114,9 +115,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
if (!webviewRef.current) return;
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
} else if (options.type === ScrollOptionTypes.Percent) {
const p = options.value as number;
setEditorPercentScroll(p);
setViewerPercentScroll(p);
const editorPercent = options.value as number;
setEditorPercentScroll(editorPercent);
const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent);
setViewerPercentScroll(viewerPercent);
} else {
throw new Error(`Unsupported scroll options: ${options.type}`);
}
Expand Down Expand Up @@ -579,7 +581,17 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
editorRef.current.updateBody(newBody);
}
} else if (msg === 'percentScroll') {
setEditorPercentScroll(arg0);
const viewerPercent = arg0;
const editorPercent = translateScrollPercentToEditor(editorRef, webviewRef, viewerPercent);
setEditorPercentScroll(editorPercent);
} else if (msg === 'syncViewerScrollWithEditor') {
const force = !!arg0;
webviewRef.current?.wrappedInstance?.refreshSyncScrollMap(force);
const editorPercent = Math.max(0, Math.min(1, editorRef.current?.getScrollPercent()));
if (!isNaN(editorPercent)) {
const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent);
setViewerPercentScroll(viewerPercent);
}
} else {
props.onMessage(event);
}
Expand All @@ -604,6 +616,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
resourceInfos: props.resourceInfos,
contentMaxWidth: props.contentMaxWidth,
mapsToLine: true,
}));

if (cancelled) return;
Expand Down Expand Up @@ -639,6 +652,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
// Since we can't do much about it we just print an error.
if (webviewRef.current && webviewRef.current.wrappedInstance) {
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
webviewRef.current.wrappedInstance.refreshSyncScrollMap(true);
} else {
console.error('Trying to set HTML on an undefined webview ref');
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useCallback, useRef } from 'react';
import shim from '@joplin/lib/shim';
import { useEffect, useRef } from 'react';

export function cursorPositionToTextOffset(cursorPos: any, body: string) {
if (!body) return 0;
Expand Down Expand Up @@ -28,64 +27,3 @@ export function usePrevious(value: any): any {
});
return ref.current;
}

export function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) {
const ignoreNextEditorScrollEvent_ = useRef(false);
const scrollTimeoutId_ = useRef<any>(null);

const scheduleOnScroll = useCallback((event: any) => {
if (scrollTimeoutId_.current) {
shim.clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}

scrollTimeoutId_.current = shim.setTimeout(() => {
scrollTimeoutId_.current = null;
onScroll(event);
}, 10);
}, [onScroll]);

const setEditorPercentScroll = useCallback((p: number) => {
ignoreNextEditorScrollEvent_.current = true;

if (editorRef.current) {
editorRef.current.setScrollPercent(p);

scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);

const setViewerPercentScroll = useCallback((p: number) => {
if (webviewRef.current) {
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);

const editor_scroll = useCallback(() => {
if (ignoreNextEditorScrollEvent_.current) {
ignoreNextEditorScrollEvent_.current = false;
return;
}

if (editorRef.current) {
const percent = editorRef.current.getScrollPercent();
if (!isNaN(percent)) {
// when switching to another note, the percent can sometimes be NaN
// this is coming from `gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts`
// when CodeMirror returns scroll info with heigth == clientHeigth
// https://github.com/laurent22/joplin/issues/4797
setViewerPercentScroll(percent);
}
}
}, [setViewerPercentScroll]);

const resetScroll = useCallback(() => {
if (editorRef.current) {
editorRef.current.setScrollPercent(0);
}
}, []);

return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useCallback, useRef } from 'react';
import shim from '@joplin/lib/shim';
import { SyncScrollMap } from '../../../../utils/SyncScrollMap';

export default function useScrollHandler(editorRef: any, webviewRef: any, onScroll: Function) {
const ignoreNextEditorScrollEvent_ = useRef(false);
const scrollTimeoutId_ = useRef<any>(null);

const scheduleOnScroll = useCallback((event: any) => {
if (scrollTimeoutId_.current) {
shim.clearTimeout(scrollTimeoutId_.current);
scrollTimeoutId_.current = null;
}

scrollTimeoutId_.current = shim.setTimeout(() => {
scrollTimeoutId_.current = null;
onScroll(event);
}, 10);
}, [onScroll]);

const setEditorPercentScroll = useCallback((p: number) => {
ignoreNextEditorScrollEvent_.current = true;

if (editorRef.current) {
editorRef.current.setScrollPercent(p);

scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);

const setViewerPercentScroll = useCallback((p: number) => {
if (webviewRef.current) {
webviewRef.current.wrappedInstance.send('setPercentScroll', p);
scheduleOnScroll({ percent: p });
}
}, [scheduleOnScroll]);

const editor_scroll = useCallback(() => {
if (ignoreNextEditorScrollEvent_.current) {
ignoreNextEditorScrollEvent_.current = false;
return;
}

if (editorRef.current) {
const editorPercent = Math.max(0, Math.min(1, editorRef.current.getScrollPercent()));
if (!isNaN(editorPercent)) {
// when switching to another note, the percent can sometimes be NaN
// this is coming from `gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.ts`
// when CodeMirror returns scroll info with heigth == clientHeigth
// https://github.com/laurent22/joplin/issues/4797
const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent);
setViewerPercentScroll(viewerPercent);
}
}
}, [setViewerPercentScroll]);

const resetScroll = useCallback(() => {
if (editorRef.current) {
editorRef.current.setScrollPercent(0);
}
}, []);

return { resetScroll, setEditorPercentScroll, setViewerPercentScroll, editor_scroll };
}

const translateScrollPercent_ = (editorRef: any, webviewRef: any, percent: number, editorToViewer: boolean) => {
// If the input is out of (0,1) or not number, it is not translated.
if (!(0 < percent && percent < 1)) return percent;
const map: SyncScrollMap = webviewRef.current?.wrappedInstance.getSyncScrollMap();
const cm = editorRef.current;
if (!map || map.line.length <= 2 || !cm) return percent; // No translation
const lineCount = cm.lineCount();
if (map.line[map.line.length - 2] >= lineCount) {
// Discarded a obsolete map and use no translation.
webviewRef.current.wrappedInstance.refreshSyncScrollMap(false);
return percent;
}
const info = cm.getScrollInfo();
const height = Math.max(1, info.height - info.clientHeight);
let values = map.percent, target = percent;
if (editorToViewer) {
const top = percent * height;
const line = cm.lineAtHeight(top, 'local');
values = map.line;
target = line;
}
// Binary search (rightmost): finds where map[r-1][field] <= target < map[r][field]
let l = 1, r = values.length - 1;
while (l < r) {
const m = Math.floor(l + (r - l) / 2);
if (target < values[m]) r = m; else l = m + 1;
}
const lineU = map.line[r - 1];
const lineL = Math.min(lineCount, map.line[r]);
const ePercentU = r == 1 ? 0 : Math.min(1, cm.heightAtLine(lineU, 'local') / height);
const ePercentL = Math.min(1, cm.heightAtLine(lineL, 'local') / height);
const vPercentU = map.percent[r - 1];
const vPercentL = ePercentL == 1 ? 1 : map.percent[r];
let result;
if (editorToViewer) {
const linInterp = (percent - ePercentU) / (ePercentL - ePercentU);
result = vPercentU + (vPercentL - vPercentU) * linInterp;
} else {
const linInterp = (percent - vPercentU) / (vPercentL - vPercentU);
result = ePercentU + (ePercentL - ePercentU) * linInterp;
}
return Math.max(0, Math.min(1, result));
};

// translateScrollPercentToEditor() and translateScrollPercentToViewer() are
// the translation functions between Editor's scroll percent and Viewer's scroll
// percent. They are used for synchronous scrolling between Editor and Viewer.
// They use a SyncScrollMap provided by Viewer for its translation.
// To see the detail of synchronous scrolling, refer the following design document.
// https://github.com/laurent22/joplin/pull/5512#issuecomment-931277022

export const translateScrollPercentToEditor = (editorRef: any, webviewRef: any, viewerPercent: number) => {
const editorPercent = translateScrollPercent_(editorRef, webviewRef, viewerPercent, false);
return editorPercent;
};

export const translateScrollPercentToViewer = (editorRef: any, webviewRef: any, editorPercent: number) => {
const viewerPercent = translateScrollPercent_(editorRef, webviewRef, editorPercent, true);
return viewerPercent;
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface MarkupToHtmlOptions {
contentMaxWidth?: number;
plugins?: Record<string, any>;
bodyOnly?: boolean;
mapsToLine?: boolean;
}

export default function useMarkupToHtml(deps: HookDependencies) {
Expand Down
12 changes: 12 additions & 0 deletions packages/app-desktop/gui/NoteTextViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import PostMessageService, { MessageResponse, ResponderComponentType } from '@jo
import * as React from 'react';
const { connect } = require('react-redux');
import { reg } from '@joplin/lib/registry';
import { SyncScrollMap, SyncScrollMapper } from './utils/SyncScrollMap';

interface Props {
onDomReady: Function;
Expand Down Expand Up @@ -167,6 +168,17 @@ class NoteTextViewerComponent extends React.Component<Props, any> {
}
}

private syncScrollMapper_ = new SyncScrollMapper;

refreshSyncScrollMap(forced: boolean) {
return this.syncScrollMapper_.refresh(forced);
}

getSyncScrollMap(): SyncScrollMap {
const doc = this.webviewRef_.current?.contentWindow?.document;
return this.syncScrollMapper_.get(doc);
}

// ----------------------------------------------------------------
// Wrap WebView functions (END)
// ----------------------------------------------------------------
Expand Down
Loading

0 comments on commit 630a400

Please sign in to comment.