Mobile: Resolves #520: Viewer, Rich Text Editor: Save/restore the cursor and scroll position when switching notes (#13962)

This commit is contained in:
Henry Heino
2025-12-23 03:51:15 -08:00
committed by GitHub
parent 496d007f74
commit 5f61bee712
25 changed files with 147 additions and 35 deletions
+1
View File
@@ -1147,6 +1147,7 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/clampPointToDocument.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
+1
View File
@@ -1119,6 +1119,7 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/clampPointToDocument.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
@@ -27,7 +27,7 @@ interface WrapperProps {
noteBody: string;
highlightedKeywords?: string[];
noteResources?: Record<string, ResourceInfo>;
onScroll?: (percent: number)=> void;
onScroll?: ()=> void;
onMarkForDownload?: OnMarkForDownloadCallback;
}
@@ -52,7 +52,7 @@ const WrappedNoteViewer: React.FC<WrapperProps> = (
highlightedKeywords={highlightedKeywords}
noteResources={noteResources}
paddingBottom={0}
initialScroll={0}
initialScrollPercent={0}
noteHash={''}
onMarkForDownload={onMarkForDownload}
onScroll={onScroll}
@@ -15,6 +15,7 @@ import CommandService from '@joplin/lib/services/CommandService';
import { AppState } from '../../utils/types';
import { connect } from 'react-redux';
import useWebViewSetup from '../../contentScripts/rendererBundle/useWebViewSetup';
import { OnScrollCallback } from '../../contentScripts/rendererBundle/types';
interface Props {
themeId: number;
@@ -25,12 +26,12 @@ interface Props {
highlightedKeywords: string[];
noteResources: Record<string, ResourceInfo>;
paddingBottom: number;
initialScroll: number|null;
initialScrollPercent: number|null;
noteHash: string;
onCheckboxChange?: HandleMessageCallback;
onRequestEditResource?: HandleMessageCallback;
onMarkForDownload?: OnMarkForDownloadCallback;
onScroll: (scrollTop: number)=> void;
onScroll: OnScrollCallback;
onLoadEnd?: ()=> void;
pluginStates: PluginStates;
}
@@ -46,9 +47,7 @@ const onJoplinLinkClick = async (message: string) => {
function NoteBodyViewer(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const onScroll = useCallback(async (scrollTop: number) => {
props.onScroll(scrollTop);
}, [props.onScroll]);
const onScroll = props.onScroll;
const onResourceLongPress = useOnResourceLongPress(
{
@@ -82,7 +81,7 @@ function NoteBodyViewer(props: Props) {
highlightedKeywords: props.highlightedKeywords,
noteResources: props.noteResources,
noteHash: props.noteHash,
initialScroll: props.initialScroll,
initialScrollPercent: props.initialScrollPercent,
paddingBottom: props.paddingBottom,
});
@@ -24,7 +24,7 @@ interface Props {
highlightedKeywords: string[];
noteResources: Record<string, ResourceInfo>;
noteHash: string;
initialScroll: number|undefined;
initialScrollPercent: number|undefined;
paddingBottom: number;
}
@@ -136,7 +136,7 @@ const useRerenderHandler = (props: Props) => {
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash
// instead.
initialScroll: (previousHash && hashChanged) ? undefined : props.initialScroll,
initialScrollPercent: (previousHash && hashChanged) ? undefined : props.initialScrollPercent,
noteHash: props.noteHash,
};
@@ -1,4 +1,4 @@
import { OnMessageEvent } from '../ExtendedWebView/types';
export type OnScrollCallback = (scrollTop: number)=> void;
export { OnScrollCallback } from '../../contentScripts/rendererBundle/types';
export type OnWebViewMessageHandler = (event: OnMessageEvent)=> void;
@@ -28,12 +28,14 @@ const defaultEditorProps = {
globalSearch: '',
noteId: '',
noteHash: '',
initialScroll: 0,
style: {},
toolbarEnabled: true,
readOnly: false,
onChange: ()=>{},
onSelectionChange: ()=>{},
onUndoRedoDepthChange: ()=>{},
onScroll: ()=>{},
onAttach: async ()=>{},
noteResources: {},
plugins: {},
@@ -13,7 +13,7 @@ import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
import { EditorControl, EditorSettings, EditorType } from './types';
import { _ } from '@joplin/lib/locale';
import { ChangeEvent, EditorEvent, EditorEventType, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { ChangeEvent, EditorEvent, EditorEventType, EditorScrolledEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events';
import { EditorCommandType, EditorKeymap, EditorLanguageType, SearchState } from '@joplin/editor/types';
import SelectionFormatting, { defaultSelectionFormatting } from '@joplin/editor/SelectionFormatting';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
@@ -38,6 +38,7 @@ import Logger from '@joplin/utils/Logger';
const logger = Logger.create('NoteEditor');
type ChangeEventHandler = (event: ChangeEvent)=> void;
type ScrollEventHandler = (event: EditorScrolledEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
type SelectionChangeEventHandler = (event: SelectionRangeChangeEvent)=> void;
type OnAttachCallback = (filePath?: string)=> Promise<void>;
@@ -46,6 +47,7 @@ interface Props {
ref: Ref<EditorControl>;
themeId: number;
initialText: string;
initialScroll: number;
mode: EditorType;
markupLanguage: MarkupLanguage;
noteId: string;
@@ -58,6 +60,7 @@ interface Props {
plugins: PluginStates;
noteResources: ResourceInfos;
onScroll: ScrollEventHandler;
onChange: ChangeEventHandler;
onSelectionChange: SelectionChangeEventHandler;
onUndoRedoDepthChange: UndoRedoDepthChangeHandler;
@@ -334,9 +337,11 @@ function NoteEditor(props: Props) {
break;
}
case EditorEventType.Remove:
case EditorEventType.Scroll:
// Not handled
break;
case EditorEventType.Scroll:
props.onScroll(event);
break;
default:
exhaustivenessCheck = event;
return exhaustivenessCheck;
@@ -442,6 +447,7 @@ function NoteEditor(props: Props) {
noteHash={props.noteHash}
initialText={props.initialText}
initialSelection={props.initialSelection}
initialScroll={props.initialScroll}
editorSettings={editorSettings}
globalSearch={props.globalSearch}
onEditorEvent={onEditorEvent}
@@ -96,6 +96,8 @@ const RichTextEditor: React.FC<EditorProps> = props => {
themeId: props.themeId,
pluginStates: props.plugins,
noteResources: props.noteResources,
initialSelection: props.initialSelection,
initialScroll: props.initialScroll,
onPostMessage: onPostMessage,
onAttachFile: props.onAttach,
});
@@ -12,6 +12,7 @@ const defaultWrapperProps: EditorProps = {
noteHash: '',
noteId: '',
initialText: '',
initialScroll: 0,
editorSettings: defaultEditorSettings,
initialSelection: { start: 0, end: 0 },
globalSearch: '',
@@ -62,6 +62,7 @@ export interface EditorProps {
noteHash: string;
initialText: string;
initialSelection: SelectionRange;
initialScroll: number;
editorSettings: EditorSettings;
globalSearch: string;
plugins: PluginStates;
@@ -76,6 +76,7 @@ import { EditorType } from '../../NoteEditor/types';
import { IconButton } from 'react-native-paper';
import { writeTextToCacheFile } from '../../../utils/ShareUtils';
import shareFile from '../../../utils/shareFile';
import NotePositionService from '@joplin/lib/services/NotePositionService';
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const emptyArray: any[] = [];
@@ -154,6 +155,8 @@ interface State {
multiline: boolean;
}
type ScrollEventSlice = { fraction: number };
class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> implements BaseNoteScreenComponent<State> {
// This isn't in this.state because we don't want changing scroll to trigger
// a re-render.
@@ -226,6 +229,13 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
multiline: false,
};
const initialCursorLocation = NotePositionService.instance().getCursorPosition(props.noteId, defaultWindowId).markdown;
if (initialCursorLocation) {
this.selection = { start: initialCursorLocation, end: initialCursorLocation };
}
const initialScroll = NotePositionService.instance().getScrollPercent(props.noteId, defaultWindowId);
this.lastBodyScroll = initialScroll;
this.titleTextFieldRef = React.createRef();
this.saveActionQueues_ = {};
@@ -770,8 +780,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
this.selection = event.nativeEvent.selection;
};
private onMarkdownEditorSelectionChange = (event: SelectionRangeChangeEvent) => {
private onEditorSelectionChange = (event: SelectionRangeChangeEvent) => {
this.selection = { start: event.from, end: event.to };
NotePositionService.instance().updateCursorPosition(
this.props.noteId, defaultWindowId, { markdown: event.from },
);
};
public makeSaveAction(state: State) {
@@ -1487,10 +1501,16 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
return this.folderPickerOptions_;
}
private onBodyViewerScroll = (scrollTop: number) => {
this.lastBodyScroll = scrollTop;
private onBodyViewerScroll = (event: ScrollEventSlice) => {
this.lastBodyScroll = event.fraction;
NotePositionService.instance().updateScrollPosition(
this.props.noteId, defaultWindowId, event.fraction,
);
};
private onMarkdownEditorScroll = () => {};
public onBodyViewerCheckboxChange(newBody: string) {
void this.saveOneProperty('body', newBody);
}
@@ -1604,7 +1624,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
onMarkForDownload={this.onMarkForDownload}
onRequestEditResource={this.onEditResource}
onScroll={this.onBodyViewerScroll}
initialScroll={this.lastBodyScroll}
initialScrollPercent={this.lastBodyScroll}
/>
);
} else {
@@ -1658,7 +1678,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
markupLanguage={this.state.note.markup_language}
globalSearch={this.props.searchQuery}
onChange={this.onMarkdownEditorTextChange}
onSelectionChange={this.onMarkdownEditorSelectionChange}
onSelectionChange={this.onEditorSelectionChange}
onUndoRedoDepthChange={this.onUndoRedoDepthChange}
onAttach={this.onAttach}
noteResources={this.state.noteResources}
@@ -1671,6 +1691,14 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
paddingLeft: 0,
paddingRight: 0,
}}
// For now, only save/restore the scroll location for the Rich Text editor since that editor's
// scroll should roughly match the viewer. In the future, it may make sense to refactor this to
// use mapsToLine (similar to what's done on desktop) to sync the Markdown editor scroll, but this
// will require refactoring.
initialScroll={this.props.editorType === EditorType.RichText ? this.lastBodyScroll : undefined}
onScroll={this.props.editorType === EditorType.RichText ? this.onBodyViewerScroll : this.onMarkdownEditorScroll}
mode={this.props.editorType}
/>;
}
@@ -22,6 +22,7 @@ import { themeStyle } from '../global-style';
import getHelpMessage from '@joplin/lib/components/shared/NoteRevisionViewer/getHelpMessage';
import { DialogContext } from '../DialogManager';
import useDeleteHistoryClick from '@joplin/lib/components/shared/NoteRevisionViewer/useDeleteHistoryClick';
import { OnScrollCallback } from '../NoteBodyViewer/types';
interface Props {
themeId: number;
@@ -153,6 +154,10 @@ const NoteRevisionViewer: React.FC<Props> = props => {
return result;
}, [revisions]);
const onScroll: OnScrollCallback = useCallback((event) => {
setInitialScroll(event.fraction);
}, []);
const onOptionSelected = useCallback((value: string) => {
setCurrentRevisionId(value);
}, []);
@@ -280,8 +285,8 @@ const NoteRevisionViewer: React.FC<Props> = props => {
noteResources={resources}
highlightedKeywords={emptyStringList}
paddingBottom={0}
initialScroll={initialScroll}
onScroll={setInitialScroll}
initialScrollPercent={initialScroll}
onScroll={onScroll}
noteHash={''}
/>
</View>;
@@ -10,7 +10,7 @@ const defaultRendererSettings: RenderSettings = {
resources: {},
codeTheme: 'atom-one-light.css',
noteHash: '',
initialScroll: 0,
initialScrollPercent: 0,
readAssetBlob: async (_path: string) => new Blob(),
removeUnusedPluginAssets: true,
@@ -20,7 +20,7 @@ export interface RenderSettings {
resources: ResourceInfos;
codeTheme: string;
noteHash: string;
initialScroll: number;
initialScrollPercent: number;
// If [null], plugin assets are not added to the document
pluginAssetContainerSelector: string|null;
removeUnusedPluginAssets: boolean;
@@ -64,7 +64,10 @@ export const initialize = (options: RendererWebViewOptions) => {
const onMainContentScroll = () => {
const newScrollTop = document.scrollingElement.scrollTop;
if (lastScrollTop !== newScrollTop) {
messenger.remoteApi.onScroll(newScrollTop);
const scrollHeight = document.scrollingElement.scrollHeight;
messenger.remoteApi.onScroll({
fraction: newScrollTop / (scrollHeight || 1),
});
}
};
@@ -19,13 +19,13 @@ const afterFullPageRender = (
}
const hash = renderSettings.noteHash;
const initialScroll = renderSettings.initialScroll;
const initialScrollPercent = renderSettings.initialScrollPercent;
// Don't scroll to a hash if we're given initial scroll (initial scroll
// overrides scrolling to a hash).
if ((initialScroll ?? null) !== null) {
if ((initialScrollPercent ?? null) !== null) {
const scrollingElement = document.scrollingElement ?? document.documentElement;
scrollingElement.scrollTop = initialScroll;
scrollingElement.scrollTop = initialScrollPercent * scrollingElement.scrollHeight;
} else if (hash) {
// Gives it a bit of time before scrolling to the anchor
// so that images are loaded.
@@ -30,20 +30,24 @@ export interface ExtraContentScriptSource {
pluginId: string;
}
export interface ScrollEvent {
fraction: number; // e.g. 0.5 when scrolled 50% of the way through the document
}
export type OnScrollCallback = (scrollTop: ScrollEvent)=> void;
export interface RendererProcessApi {
renderer: Renderer;
jumpToHash: (hash: string)=> void;
}
export interface MainProcessApi {
onScroll(scrollTop: number): void;
onScroll: OnScrollCallback;
onPostMessage(message: string): void;
onPostPluginMessage(contentScriptId: string, message: unknown): Promise<unknown>;
fsDriver: RendererFsDriver;
}
export type OnScrollCallback = (scrollTop: number)=> void;
export interface MarkupRecord {
language: MarkupLanguage;
markup: string;
@@ -62,7 +66,7 @@ export interface RenderOptions {
removeUnusedPluginAssets: boolean;
noteHash: string;
initialScroll: number;
initialScrollPercent: number;
// Forwarded renderer settings
splitted?: boolean;
@@ -91,8 +91,8 @@ const useMessenger = (props: UseMessengerProps) => {
const messenger = useMemo(() => {
const fsDriver = shim.fsDriver();
const localApi = {
onScroll: (fraction: number) => onScrollRef.current?.(fraction),
const localApi: MainProcessApi = {
onScroll: (event) => onScrollRef.current?.(event),
onPostMessage: (message: string) => onPostMessageRef.current?.(message),
onPostPluginMessage,
fsDriver: {
@@ -29,6 +29,8 @@ export const initialize = async (
settings,
initialText,
initialNoteId,
initialSelection,
initialScroll,
parentElementClassName,
initialSearch,
}: EditorProps,
@@ -41,6 +43,14 @@ export const initialize = async (
throw new Error('Parent node is not an element.');
}
document.addEventListener('scrollend', () => {
const fraction = document.scrollingElement.scrollTop / (document.scrollingElement.scrollHeight || 1);
void messenger.remoteApi.onEditorEvent({
kind: EditorEventType.Scroll,
fraction,
});
});
const assetContainer = document.createElement('div');
assetContainer.id = 'joplin-container-pluginAssetsContainer';
document.body.appendChild(assetContainer);
@@ -100,6 +110,13 @@ export const initialize = async (
});
editor.setSearchState(initialSearch, 'initialSearch');
if (initialSelection) {
editor.select(initialSelection.start, initialSelection.end);
}
if (initialScroll) {
editor.setScrollPercent(initialScroll);
}
messenger.setLocalInterface({
editor,
});
@@ -3,9 +3,13 @@ import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/
import { MarkupRecord, RendererControl } from '../rendererBundle/types';
import { RenderResult } from '@joplin/renderer/types';
type SelectionRange = { start: number; end: number };
export interface EditorProps {
initialText: string;
initialSearch: SearchState;
initialSelection: SelectionRange;
initialScroll: number;
initialNoteId: string;
parentElementClassName: string;
settings: EditorSettings;
@@ -14,6 +14,7 @@ import { RendererControl, RenderOptions } from '../rendererBundle/types';
import { ResourceInfos } from '@joplin/renderer/types';
import { _ } from '@joplin/lib/locale';
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
import { SelectionRange } from '../markdownEditorBundle/types';
const logger = Logger.create('useWebViewSetup');
@@ -21,6 +22,8 @@ interface Props {
initialText: string;
noteId: string;
settings: EditorSettings;
initialSelection: SelectionRange|null;
initialScroll: number|null;
parentElementClassName: string;
globalSearch: string;
themeId: number;
@@ -53,7 +56,7 @@ const useMessenger = (props: UseMessengerProps) => {
noteViewerFontSize: `${baseTheme.fontSize}${baseTheme.fontSizeUnits ?? 'px'}`,
},
noteHash: '',
initialScroll: 0,
initialScrollPercent: 0,
pluginAssetContainerSelector: null,
removeUnusedPluginAssets: true,
};
@@ -119,6 +122,8 @@ const useSource = (props: UseSourceProps) => {
...defaultSearchState,
searchText: propsRef.current.globalSearch,
},
initialScroll: propsRef.current.initialScroll,
initialSelection: propsRef.current.initialSelection,
settings: propsRef.current.settings,
};
+15 -2
View File
@@ -28,6 +28,7 @@ import { RenderResult } from '../../renderer/types';
import postprocessEditorOutput from './utils/postprocessEditorOutput';
import detailsPlugin from './plugins/detailsPlugin';
import tablePlugin from './plugins/tablePlugin';
import clampPointToDocument from './utils/clampPointToDocument';
interface ProseMirrorControl extends EditorControl {
getSettings(): EditorSettings;
@@ -134,6 +135,14 @@ const createEditor = async (
formatting: selectionFormatting,
});
}
props.onEvent({
kind: EditorEventType.SelectionRangeChange,
anchor: newState.selection.anchor,
head: newState.selection.head,
from: newState.selection.from,
to: newState.selection.to,
});
};
const view = new EditorView(parentElement, {
@@ -187,10 +196,14 @@ const createEditor = async (
redo: () => {
void editorControl.execCommand(EditorCommandType.Redo);
},
select: function(anchor: number, head: number): void {
select: (anchor: number, head: number) => {
const transaction = view.state.tr;
transaction.setSelection(
TextSelection.create(transaction.doc, anchor, head),
TextSelection.create(
transaction.doc,
clampPointToDocument(view.state, anchor),
clampPointToDocument(view.state, head),
),
);
view.dispatch(transaction);
},
@@ -0,0 +1,19 @@
import { EditorState } from 'prosemirror-state';
const documentMaximumIndex = (state: EditorState) => {
// nodeSize is documented to be the size of a node's content plus two (one for the
// start marker and one for the end marker). The main document doesn't have start or
// end markers, so subtract these to get the document maximum index:
return state.doc.nodeSize - 2;
};
const clampPointToDocument = (state: EditorState, point: number) => {
if (point < 0) return 0;
const maximumIndex = documentMaximumIndex(state);
if (point > maximumIndex) return maximumIndex;
return point;
};
export default clampPointToDocument;
+1
View File
@@ -225,3 +225,4 @@ mdpi
xhdpi
xxhdpi
xxxhdpi
scrollend