mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-21 09:38:01 +02:00
This commit is contained in:
parent
725abbc167
commit
630a400181
@ -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.d.ts
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
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/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.d.ts
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
|
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
|
||||||
@ -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.d.ts
|
||||||
packages/app-desktop/gui/utils/NoteListUtils.js
|
packages/app-desktop/gui/utils/NoteListUtils.js
|
||||||
packages/app-desktop/gui/utils/NoteListUtils.js.map
|
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.d.ts
|
||||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||||
@ -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.d.ts
|
||||||
packages/renderer/MdToHtml/rules/sanitize_html.js
|
packages/renderer/MdToHtml/rules/sanitize_html.js
|
||||||
packages/renderer/MdToHtml/rules/sanitize_html.js.map
|
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.d.ts
|
||||||
packages/renderer/MdToHtml/setupLinkify.js
|
packages/renderer/MdToHtml/setupLinkify.js
|
||||||
packages/renderer/MdToHtml/setupLinkify.js.map
|
packages/renderer/MdToHtml/setupLinkify.js.map
|
||||||
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -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.d.ts
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useListIdent.js
|
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/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.d.ts
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js
|
||||||
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
|
packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/utils/useScrollUtils.js.map
|
||||||
@ -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.d.ts
|
||||||
packages/app-desktop/gui/utils/NoteListUtils.js
|
packages/app-desktop/gui/utils/NoteListUtils.js
|
||||||
packages/app-desktop/gui/utils/NoteListUtils.js.map
|
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.d.ts
|
||||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
packages/app-desktop/gui/utils/convertToScreenCoordinates.js
|
||||||
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
packages/app-desktop/gui/utils/convertToScreenCoordinates.js.map
|
||||||
@ -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.d.ts
|
||||||
packages/renderer/MdToHtml/rules/sanitize_html.js
|
packages/renderer/MdToHtml/rules/sanitize_html.js
|
||||||
packages/renderer/MdToHtml/rules/sanitize_html.js.map
|
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.d.ts
|
||||||
packages/renderer/MdToHtml/setupLinkify.js
|
packages/renderer/MdToHtml/setupLinkify.js
|
||||||
packages/renderer/MdToHtml/setupLinkify.js.map
|
packages/renderer/MdToHtml/setupLinkify.js.map
|
||||||
|
@ -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>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,8 @@ import { EditorCommand, NoteBodyEditorProps } from '../../utils/types';
|
|||||||
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
|
import { commandAttachFileToBody, handlePasteEvent } from '../../utils/resourceHandling';
|
||||||
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
import { ScrollOptions, ScrollOptionTypes } from '../../utils/types';
|
||||||
import { CommandValue } 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 useElementSize from '@joplin/lib/hooks/useElementSize';
|
||||||
import Toolbar from './Toolbar';
|
import Toolbar from './Toolbar';
|
||||||
import styles_ from './styles';
|
import styles_ from './styles';
|
||||||
@ -114,9 +115,10 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
|||||||
if (!webviewRef.current) return;
|
if (!webviewRef.current) return;
|
||||||
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
|
webviewRef.current.wrappedInstance.send('scrollToHash', options.value as string);
|
||||||
} else if (options.type === ScrollOptionTypes.Percent) {
|
} else if (options.type === ScrollOptionTypes.Percent) {
|
||||||
const p = options.value as number;
|
const editorPercent = options.value as number;
|
||||||
setEditorPercentScroll(p);
|
setEditorPercentScroll(editorPercent);
|
||||||
setViewerPercentScroll(p);
|
const viewerPercent = translateScrollPercentToViewer(editorRef, webviewRef, editorPercent);
|
||||||
|
setViewerPercentScroll(viewerPercent);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported scroll options: ${options.type}`);
|
throw new Error(`Unsupported scroll options: ${options.type}`);
|
||||||
}
|
}
|
||||||
@ -579,7 +581,17 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
|||||||
editorRef.current.updateBody(newBody);
|
editorRef.current.updateBody(newBody);
|
||||||
}
|
}
|
||||||
} else if (msg === 'percentScroll') {
|
} 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 {
|
} else {
|
||||||
props.onMessage(event);
|
props.onMessage(event);
|
||||||
}
|
}
|
||||||
@ -604,6 +616,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
|||||||
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
|
const result = await props.markupToHtml(props.contentMarkupLanguage, bodyToRender, markupRenderOptions({
|
||||||
resourceInfos: props.resourceInfos,
|
resourceInfos: props.resourceInfos,
|
||||||
contentMaxWidth: props.contentMaxWidth,
|
contentMaxWidth: props.contentMaxWidth,
|
||||||
|
mapsToLine: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
@ -639,6 +652,7 @@ function CodeMirror(props: NoteBodyEditorProps, ref: any) {
|
|||||||
// Since we can't do much about it we just print an error.
|
// Since we can't do much about it we just print an error.
|
||||||
if (webviewRef.current && webviewRef.current.wrappedInstance) {
|
if (webviewRef.current && webviewRef.current.wrappedInstance) {
|
||||||
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
webviewRef.current.wrappedInstance.send('setHtml', renderedBody.html, options);
|
||||||
|
webviewRef.current.wrappedInstance.refreshSyncScrollMap(true);
|
||||||
} else {
|
} else {
|
||||||
console.error('Trying to set HTML on an undefined webview ref');
|
console.error('Trying to set HTML on an undefined webview ref');
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useCallback, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import shim from '@joplin/lib/shim';
|
|
||||||
|
|
||||||
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
|
export function cursorPositionToTextOffset(cursorPos: any, body: string) {
|
||||||
if (!body) return 0;
|
if (!body) return 0;
|
||||||
@ -28,64 +27,3 @@ export function usePrevious(value: any): any {
|
|||||||
});
|
});
|
||||||
return ref.current;
|
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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
};
|
@ -19,6 +19,7 @@ export interface MarkupToHtmlOptions {
|
|||||||
contentMaxWidth?: number;
|
contentMaxWidth?: number;
|
||||||
plugins?: Record<string, any>;
|
plugins?: Record<string, any>;
|
||||||
bodyOnly?: boolean;
|
bodyOnly?: boolean;
|
||||||
|
mapsToLine?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useMarkupToHtml(deps: HookDependencies) {
|
export default function useMarkupToHtml(deps: HookDependencies) {
|
||||||
|
@ -2,6 +2,7 @@ import PostMessageService, { MessageResponse, ResponderComponentType } from '@jo
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
import { reg } from '@joplin/lib/registry';
|
import { reg } from '@joplin/lib/registry';
|
||||||
|
import { SyncScrollMap, SyncScrollMapper } from './utils/SyncScrollMap';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDomReady: Function;
|
onDomReady: Function;
|
||||||
@ -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)
|
// Wrap WebView functions (END)
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
@ -102,14 +102,7 @@
|
|||||||
// images are being displayed then restored while images are being reloaded, the new scrollTop might be changed
|
// images are being displayed then restored while images are being reloaded, the new scrollTop might be changed
|
||||||
// so that it is not greater than contentHeight. On the other hand, with percentScroll it is possible to restore
|
// so that it is not greater than contentHeight. On the other hand, with percentScroll it is possible to restore
|
||||||
// it at any time knowing that it's not going to be changed because the content height has changed.
|
// it at any time knowing that it's not going to be changed because the content height has changed.
|
||||||
// To restore percentScroll the "checkScrollIID" interval is used. It constantly resets the scroll position during
|
|
||||||
// one second after the content has been updated.
|
|
||||||
//
|
|
||||||
// ignoreNextScroll is used to differentiate between scroll event from the users and those that are the result
|
|
||||||
// of programmatically changing scrollTop. We only want to respond to events initiated by the user.
|
|
||||||
|
|
||||||
let percentScroll_ = 0;
|
let percentScroll_ = 0;
|
||||||
let checkScrollIID_ = null;
|
|
||||||
|
|
||||||
// This variable provides a way to skip scroll events for a certain duration.
|
// This variable provides a way to skip scroll events for a certain duration.
|
||||||
// In general, it should be set whenever the scroll value is set explicitely (programmatically)
|
// In general, it should be set whenever the scroll value is set explicitely (programmatically)
|
||||||
@ -195,7 +188,66 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let checkAllImageLoadedIID_ = null;
|
let alreadyAllImagesLoaded = false;
|
||||||
|
|
||||||
|
// During a note is being rendered, its height is varying. To keep scroll
|
||||||
|
// consistency, observing the height of the content element and updating its
|
||||||
|
// scroll position is required. For the purpose, 'ResizeObserver' is used.
|
||||||
|
// ResizeObserver is standard and an element's counterpart to 'window.resize'
|
||||||
|
// event. It's overhead is cheaper than observation using an interval timer.
|
||||||
|
//
|
||||||
|
// To observe the scroll height of the content element, adding, removing and
|
||||||
|
// resizing of its children should be observed. So, the combination of
|
||||||
|
// ResizeObserver (used for resizing) and MutationObserver (used for ading
|
||||||
|
// and removing) is used.
|
||||||
|
//
|
||||||
|
// References:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
|
||||||
|
//
|
||||||
|
// By using them, this observeRendering() function provides a efficient way
|
||||||
|
// to observe the changes of the scroll height of the content element
|
||||||
|
// using a callback approach.
|
||||||
|
function observeRendering(callback, compress = false) {
|
||||||
|
let previousHeight = 0;
|
||||||
|
const fn = (cause) => {
|
||||||
|
const height = contentElement.scrollHeight;
|
||||||
|
const heightChanged = height != previousHeight;
|
||||||
|
if (!compress || heightChanged) {
|
||||||
|
previousHeight = height;
|
||||||
|
callback(cause, height, heightChanged);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 'resized' means DOM Layout change or Window resize event
|
||||||
|
let resizeObserver = new ResizeObserver(() => fn('resized'));
|
||||||
|
// An HTML document to be rendered is added and removed as a child of
|
||||||
|
// the content element for each setHtml() invocation.
|
||||||
|
let mutationObserver = new MutationObserver(entries => {
|
||||||
|
const e = entries[0];
|
||||||
|
e.removedNodes.forEach(n => n instanceof Element && resizeObserver.unobserve(n));
|
||||||
|
e.addedNodes.forEach(n => n instanceof Element && resizeObserver.observe(n));
|
||||||
|
if (e.removedNodes.length + e.addedNodes.length) fn('dom-changed');
|
||||||
|
});
|
||||||
|
mutationObserver.observe(contentElement, { childList: true });
|
||||||
|
return { mutationObserver, resizeObserver };
|
||||||
|
};
|
||||||
|
|
||||||
|
// A callback anonymous function invoked when the scroll height changes.
|
||||||
|
const onRendering = observeRendering((cause, height, heightChanged) => {
|
||||||
|
if (!alreadyAllImagesLoaded) {
|
||||||
|
const loaded = allImagesLoaded();
|
||||||
|
if (loaded) {
|
||||||
|
alreadyAllImagesLoaded = true;
|
||||||
|
ipcProxySendToHost('syncViewerScrollWithEditor', true);
|
||||||
|
ipcProxySendToHost('noteRenderComplete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (heightChanged) {
|
||||||
|
// When the scroll height changes, sync is needed.
|
||||||
|
ipcProxySendToHost('syncViewerScrollWithEditor');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
ipc.focus = (event) => {
|
ipc.focus = (event) => {
|
||||||
const dummyID = 'joplin-content-focus-dummy';
|
const dummyID = 'joplin-content-focus-dummy';
|
||||||
@ -217,23 +269,10 @@
|
|||||||
|
|
||||||
contentElement.innerHTML = html;
|
contentElement.innerHTML = html;
|
||||||
|
|
||||||
let previousContentHeight = contentElement.scrollHeight;
|
restorePercentScroll(); // First, a quick treatment is applied.
|
||||||
let startTime = Date.now();
|
ipcProxySendToHost('syncViewerScrollWithEditor');
|
||||||
restorePercentScroll();
|
|
||||||
|
|
||||||
if (!checkScrollIID_) {
|
alreadyAllImagesLoaded = false;
|
||||||
checkScrollIID_ = setInterval(() => {
|
|
||||||
const h = contentElement.scrollHeight;
|
|
||||||
if (h !== previousContentHeight) {
|
|
||||||
previousContentHeight = h;
|
|
||||||
restorePercentScroll();
|
|
||||||
}
|
|
||||||
if (Date.now() - startTime >= 1000) {
|
|
||||||
clearInterval(checkScrollIID_);
|
|
||||||
checkScrollIID_ = null;
|
|
||||||
}
|
|
||||||
}, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
addPluginAssets(event.options.pluginAssets);
|
addPluginAssets(event.options.pluginAssets);
|
||||||
|
|
||||||
@ -242,25 +281,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
document.dispatchEvent(new Event('joplin-noteDidUpdate'));
|
||||||
|
|
||||||
if (checkAllImageLoadedIID_) clearInterval(checkAllImageLoadedIID_);
|
|
||||||
|
|
||||||
checkAllImageLoadedIID_ = setInterval(() => {
|
|
||||||
if (!allImagesLoaded()) return;
|
|
||||||
|
|
||||||
clearInterval(checkAllImageLoadedIID_);
|
|
||||||
ipcProxySendToHost('noteRenderComplete');
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ipc.setPercentScroll = (event) => {
|
ipc.setPercentScroll = (event) => {
|
||||||
const percent = event.percent;
|
const percent = event.percent;
|
||||||
|
|
||||||
if (checkScrollIID_) {
|
|
||||||
clearInterval(checkScrollIID_);
|
|
||||||
checkScrollIID_ = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastScrollEventTime = Date.now();
|
lastScrollEventTime = Date.now();
|
||||||
setPercentScroll(percent);
|
setPercentScroll(percent);
|
||||||
}
|
}
|
||||||
@ -365,7 +389,9 @@
|
|||||||
|
|
||||||
function currentPercentScroll() {
|
function currentPercentScroll() {
|
||||||
const m = maxScrollTop();
|
const m = maxScrollTop();
|
||||||
return m ? contentElement.scrollTop / m : 0;
|
// As of 2021, if zoomFactor != 1, underlying Chrome returns scrollTop with
|
||||||
|
// some numerical error. It can be more than maxScrollTop().
|
||||||
|
return m ? Math.min(1, contentElement.scrollTop / m) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
contentElement.addEventListener('wheel', webviewLib.logEnabledEventHandler(e => {
|
contentElement.addEventListener('wheel', webviewLib.logEnabledEventHandler(e => {
|
||||||
|
92
packages/app-desktop/gui/utils/SyncScrollMap.ts
Normal file
92
packages/app-desktop/gui/utils/SyncScrollMap.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import shim from '@joplin/lib/shim';
|
||||||
|
|
||||||
|
// SyncScrollMap is used for synchronous scrolling between Markdown Editor and Viewer.
|
||||||
|
// It has the mapping information between the line numbers of a Markdown text and
|
||||||
|
// the scroll positions (percents) of the elements in the HTML document transformed
|
||||||
|
// from the Markdown text.
|
||||||
|
// To see the detail of synchronous scrolling, refer the following design document.
|
||||||
|
// https://github.com/laurent22/joplin/pull/5512#issuecomment-931277022
|
||||||
|
|
||||||
|
export interface SyncScrollMap {
|
||||||
|
line: number[];
|
||||||
|
percent: number[];
|
||||||
|
viewHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map creation utility class
|
||||||
|
export class SyncScrollMapper {
|
||||||
|
private map_: SyncScrollMap = null;
|
||||||
|
private refreshTimeoutId_: any = null;
|
||||||
|
private refreshTime_ = 0;
|
||||||
|
|
||||||
|
// Invalidates an outdated SyncScrollMap.
|
||||||
|
// For a performance reason, too frequent refresh requests are
|
||||||
|
// skippend and delayed. If forced is true, refreshing is immediately performed.
|
||||||
|
public refresh(forced: boolean) {
|
||||||
|
const elapsed = this.refreshTime_ ? Date.now() - this.refreshTime_ : 10 * 1000;
|
||||||
|
if (!forced && (elapsed < 200 || this.refreshTimeoutId_)) {
|
||||||
|
// to avoid too frequent recreations of a sync-scroll map.
|
||||||
|
if (this.refreshTimeoutId_) {
|
||||||
|
shim.clearTimeout(this.refreshTimeoutId_);
|
||||||
|
this.refreshTimeoutId_ = null;
|
||||||
|
}
|
||||||
|
this.refreshTimeoutId_ = shim.setTimeout(() => {
|
||||||
|
this.refreshTimeoutId_ = null;
|
||||||
|
this.map_ = null;
|
||||||
|
this.refreshTime_ = Date.now();
|
||||||
|
}, 200);
|
||||||
|
} else {
|
||||||
|
this.map_ = null;
|
||||||
|
this.refreshTime_ = Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates a new SyncScrollMap or reuses an existing one.
|
||||||
|
public get(doc: Document): SyncScrollMap {
|
||||||
|
// Returns a cached translation map between editor's scroll percenet
|
||||||
|
// and viewer's scroll percent. Both attributes (line and percent) of
|
||||||
|
// the returned map are sorted respectively.
|
||||||
|
// Since creating this map is costly for each scroll event, it is cached.
|
||||||
|
// When some update events which outdate it such as switching a note or
|
||||||
|
// editing a note, it has to be invalidated (using refresh()),
|
||||||
|
// and a new map will be created at a next scroll event.
|
||||||
|
if (!doc) return null;
|
||||||
|
const contentElement = doc.getElementById('joplin-container-content');
|
||||||
|
if (!contentElement) return null;
|
||||||
|
const height = Math.max(1, contentElement.scrollHeight - contentElement.clientHeight);
|
||||||
|
if (this.map_) {
|
||||||
|
// check whether map_ is obsolete
|
||||||
|
if (this.map_.viewHeight === height) return this.map_;
|
||||||
|
this.map_ = null;
|
||||||
|
}
|
||||||
|
// Since getBoundingClientRect() returns a relative position,
|
||||||
|
// the offset of the origin is needed to get its aboslute position.
|
||||||
|
const offset = doc.getElementById('rendered-md').getBoundingClientRect().top;
|
||||||
|
if (!offset) return null;
|
||||||
|
// Mapping information between editor's lines and viewer's elements is
|
||||||
|
// embedded into elements by the renderer.
|
||||||
|
// See also renderer/MdToHtml/rules/source_map.ts.
|
||||||
|
const elems = doc.getElementsByClassName('maps-to-line');
|
||||||
|
const map: SyncScrollMap = { line: [0], percent: [0], viewHeight: height };
|
||||||
|
// Each map entry is total-ordered.
|
||||||
|
let last = 0;
|
||||||
|
for (let i = 0; i < elems.length; i++) {
|
||||||
|
const top = elems[i].getBoundingClientRect().top - offset;
|
||||||
|
const line = Number(elems[i].getAttribute('source-line'));
|
||||||
|
const percent = Math.max(0, Math.min(1, top / height));
|
||||||
|
if (map.line[last] < line && map.percent[last] < percent) {
|
||||||
|
map.line.push(line);
|
||||||
|
map.percent.push(percent);
|
||||||
|
last += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (map.percent[last] < 1) {
|
||||||
|
map.line.push(1e10);
|
||||||
|
map.percent.push(1);
|
||||||
|
} else {
|
||||||
|
map.line[last] = 1e10;
|
||||||
|
}
|
||||||
|
this.map_ = map;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
@ -25,6 +25,7 @@ export interface RenderOptions {
|
|||||||
pdfViewerEnabled?: boolean;
|
pdfViewerEnabled?: boolean;
|
||||||
codeHighlightCacheKey?: string;
|
codeHighlightCacheKey?: string;
|
||||||
plainResourceRendering?: boolean;
|
plainResourceRendering?: boolean;
|
||||||
|
mapsToLine?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RendererRule {
|
interface RendererRule {
|
||||||
@ -62,6 +63,7 @@ const rules: RendererRules = {
|
|||||||
code_inline: require('./MdToHtml/rules/code_inline').default,
|
code_inline: require('./MdToHtml/rules/code_inline').default,
|
||||||
fountain: require('./MdToHtml/rules/fountain').default,
|
fountain: require('./MdToHtml/rules/fountain').default,
|
||||||
mermaid: require('./MdToHtml/rules/mermaid').default,
|
mermaid: require('./MdToHtml/rules/mermaid').default,
|
||||||
|
source_map: require('./MdToHtml/rules/source_map').default,
|
||||||
};
|
};
|
||||||
|
|
||||||
const hljs = require('highlight.js');
|
const hljs = require('highlight.js');
|
||||||
|
36
packages/renderer/MdToHtml/rules/source_map.ts
Normal file
36
packages/renderer/MdToHtml/rules/source_map.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
export default {
|
||||||
|
plugin: (markdownIt: any, params: any) => {
|
||||||
|
|
||||||
|
if (!params.mapsToLine) return;
|
||||||
|
|
||||||
|
const allowedLevels = {
|
||||||
|
paragraph_open: 0,
|
||||||
|
heading_open: 0,
|
||||||
|
// fence: 0, // fence uses custom rendering that doesn't propogate attr so it can't be used for now
|
||||||
|
blockquote_open: 0,
|
||||||
|
table_open: 0,
|
||||||
|
code_block: 0,
|
||||||
|
hr: 0,
|
||||||
|
html_block: 0,
|
||||||
|
list_item_open: 99, // this will stop matching if a list goes more than 99 indents deep
|
||||||
|
math_block: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, allowedLevel] of Object.entries(allowedLevels)) {
|
||||||
|
const precedentRule = markdownIt.renderer.rules[key];
|
||||||
|
|
||||||
|
markdownIt.renderer.rules[key] = (tokens: any[], idx: number, options: any, env: any, self: any) => {
|
||||||
|
if (!!tokens[idx].map && tokens[idx].level <= allowedLevel) {
|
||||||
|
const line = tokens[idx].map[0];
|
||||||
|
tokens[idx].attrJoin('class', 'maps-to-line');
|
||||||
|
tokens[idx].attrSet('source-line', `${line}`);
|
||||||
|
}
|
||||||
|
if (precedentRule) {
|
||||||
|
return precedentRule(tokens, idx, options, env, self);
|
||||||
|
} else {
|
||||||
|
return self.renderToken(tokens, idx, options);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user