1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-24 08:12:24 +02:00

Desktop: Fixes #9433: Fixed various scrolling issues when creating new notes or to-dos

This commit is contained in:
Laurent Cozic 2024-01-28 17:57:41 +00:00
parent 07b4117aa1
commit 25b12a2366
13 changed files with 149 additions and 92 deletions

View File

@ -37,12 +37,11 @@ const StyledTitle = styled.span`
`;
// const buttonSizePx = 32;
export const buttonSizePx = (props: Props) => {
if (!props.size || props.size === ButtonSize.Normal) return 32;
if (props.size === ButtonSize.Small) return 26;
throw new Error(`Unknown size: ${props.size}`);
export const buttonSizePx = (props: Props | ButtonSize) => {
const buttonSize = typeof props === 'number' ? props : props.size;
if (!buttonSize || buttonSize === ButtonSize.Normal) return 32;
if (buttonSize === ButtonSize.Small) return 26;
throw new Error(`Unknown size: ${buttonSize}`);
};
const isSquare = (props: Props) => {

View File

@ -29,6 +29,12 @@ export const runtime = (): CommandRuntime => {
type: 'NOTE_SELECT',
id: newNote.id,
});
// Immediately sort the note list so that the new note is positioned correctly before
// scrolling to it.
utils.store.dispatch({
type: 'NOTE_SORT',
});
},
enabledCondition: 'oneFolderSelected && !inConflictFolder && !folderIsReadOnly',
};

View File

@ -76,16 +76,6 @@ const NoteList = (props: Props) => {
props.notes,
);
// const renderedNotes = useRenderedNotes(
// startNoteIndex,
// endNoteIndex,
// props.notes,
// props.selectedNoteIds,
// listRenderer,
// props.highlightedWords,
// props.watchedNoteFiles,
// );
const noteItemStyle = useMemo(() => {
return {
width: 'auto',

View File

@ -30,3 +30,17 @@ export interface Props {
parentFolderIsReadOnly: boolean;
listRenderer: ListRenderer;
}
export enum BaseBreakpoint {
Sm = 75,
Md = 80,
Lg = 120,
Xl = 474,
}
export interface Breakpoints {
Sm: number;
Md: number;
Lg: number;
Xl: number;
}

View File

@ -35,6 +35,9 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
if (setScrollTopLikeYouMeanItTimer.current) shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItStartTime.current = Date.now();
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
setScrollTopLikeYouMeanItTimer.current = shim.setInterval(() => {
if (!listRef.current) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
@ -45,7 +48,7 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
listRef.current.scrollTop = newScrollTop;
lastScrollSetTime.current = Date.now();
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 500) {
if (Date.now() - setScrollTopLikeYouMeanItStartTime.current > 1000) {
shim.clearInterval(setScrollTopLikeYouMeanItTimer.current);
setScrollTopLikeYouMeanItTimer.current = null;
}
@ -59,9 +62,9 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
const makeItemIndexVisible = useCallback((itemIndex: number) => {
const lineTopFloat = scrollTop / itemSize.height;
const topFloat = lineTopFloat * itemsPerLine; // scrollTop / itemSize.height;
const topFloat = lineTopFloat * itemsPerLine;
const lineBottomFloat = (scrollTop + listSize.height - itemSize.height) / itemSize.height;
const bottomFloat = lineBottomFloat * itemsPerLine; // (scrollTop + listSize.height - itemSize.height) / itemSize.height;
const bottomFloat = lineBottomFloat * itemsPerLine;
const top = Math.min(noteCount - 1, Math.floor(topFloat) + 1);
const bottom = Math.max(0, Math.floor(bottomFloat));
@ -85,7 +88,7 @@ const useScroll = (itemsPerLine: number, noteCount: number, itemSize: Size, list
const onScroll = useCallback((event: any) => {
// Ignore the scroll event if it has just been set programmatically.
if (Date.now() - lastScrollSetTime.current < 100) return;
if (Date.now() - lastScrollSetTime.current < 500) return;
setScrollTop(event.target.scrollTop);
}, []);

View File

@ -41,6 +41,8 @@ const useVisibleRange = (itemsPerLine: number, scrollTop: number, listSize: Size
}, [noteCount, itemsPerLine]);
// console.info('itemsPerLine', itemsPerLine);
// console.info('listSize.height', listSize.height);
// console.info('itemSize.height', itemSize.height);
// console.info('startLineIndexFloat', startLineIndexFloat);
// console.info('endLineIndexFloat', endLineIndexFloat);
// console.info('visibleLineCount', visibleLineCount);

View File

@ -1,6 +1,6 @@
import { AppState } from '../../app.reducer';
import * as React from 'react';
import { useEffect, useRef, useMemo, useState } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import SearchBar from '../SearchBar/SearchBar';
import Button, { ButtonLevel, ButtonSize, buttonSizePx } from '../Button/Button';
import CommandService from '@joplin/lib/services/CommandService';
@ -11,13 +11,7 @@ import { _ } from '@joplin/lib/locale';
const { connect } = require('react-redux');
import styled from 'styled-components';
import stateToWhenClauseContext from '../../services/commands/stateToWhenClauseContext';
enum BaseBreakpoint {
Sm = 75,
Md = 80,
Lg = 120,
Xl = 474,
}
import { Breakpoints } from '../NoteList/utils/types';
interface Props {
showNewNoteButtons: boolean;
@ -29,23 +23,23 @@ interface Props {
width: number;
newNoteButtonEnabled: boolean;
newTodoButtonEnabled: boolean;
}
interface Breakpoints {
Sm: number;
Md: number;
Lg: number;
Xl: number;
newNoteButtonRef: React.MutableRefObject<any>;
lineCount: number;
breakpoint: number;
dynamicBreakpoints: Breakpoints;
buttonSize: ButtonSize;
padding: number;
buttonVerticalGap: number;
}
const StyledRoot = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
padding: ${(props: any) => props.theme.mainPadding}px;
padding: ${(props: any) => props.padding}px;
background-color: ${(props: any) => props.theme.backgroundColor3};
gap: 5px;
`;
gap: ${(props: any) => props.buttonVerticalGap}px;
` as any;
const StyledButton = styled(Button)`
width: auto;
@ -93,47 +87,14 @@ const SortOrderButtonsContainer = styled.div`
`;
function NoteListControls(props: Props) {
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });
const searchBarRef = useRef(null);
const newNoteRef = useRef(null);
const newTodoRef = useRef(null);
const newTodoButtonRef = useRef(null);
const noteControlsRef = useRef(null);
const searchAndSortRef = useRef(null);
const getTextWidth = (text: string): number => {
const canvas = document.createElement('canvas');
if (!canvas) throw new Error('Failed to create canvas element');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get context');
const fontWeight = getComputedStyle(newNoteRef.current).getPropertyValue('font-weight');
const fontSize = getComputedStyle(newNoteRef.current).getPropertyValue('font-size');
const fontFamily = getComputedStyle(newNoteRef.current).getPropertyValue('font-family');
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
return ctx.measureText(text).width;
};
// Initialize language-specific breakpoints
useEffect(() => {
// Use the longest string to calculate the amount of extra width needed
const smAdditional = getTextWidth(_('note')) > getTextWidth(_('to-do')) ? getTextWidth(_('note')) : getTextWidth(_('to-do'));
const mdAdditional = getTextWidth(_('New note')) > getTextWidth(_('New to-do')) ? getTextWidth(_('New note')) : getTextWidth(_('New to-do'));
const Sm = BaseBreakpoint.Sm + smAdditional * 2;
const Md = BaseBreakpoint.Md + mdAdditional * 2;
const Lg = BaseBreakpoint.Lg + Md;
const Xl = BaseBreakpoint.Xl;
setDynamicBreakpoints({ Sm, Md, Lg, Xl });
}, []);
const breakpoint = useMemo(() => {
// Find largest breakpoint that width is less than
const index = Object.values(dynamicBreakpoints).findIndex(x => props.width < x);
return index === -1 ? dynamicBreakpoints.Xl : Object.values(dynamicBreakpoints)[index];
}, [props.width, dynamicBreakpoints]);
const breakpoint = props.breakpoint;
const dynamicBreakpoints = props.dynamicBreakpoints;
const lineCount = props.lineCount;
const noteButtonText = useMemo(() => {
if (breakpoint === dynamicBreakpoints.Sm) {
@ -180,13 +141,13 @@ function NoteListControls(props: Props) {
}, [breakpoint, dynamicBreakpoints.Sm]);
useEffect(() => {
if (breakpoint === dynamicBreakpoints.Xl) {
if (lineCount === 1) {
noteControlsRef.current.style.flexDirection = 'row';
searchAndSortRef.current.style.flex = '2 1 50%';
} else {
noteControlsRef.current.style.flexDirection = 'column';
}
}, [breakpoint, dynamicBreakpoints]);
}, [lineCount]);
useEffect(() => {
CommandService.instance().registerRuntime('focusSearch', focusSearchRuntime(searchBarRef));
@ -246,23 +207,23 @@ function NoteListControls(props: Props) {
return (
<TopRow className="new-note-todo-buttons">
<StyledButton ref={newNoteRef}
<StyledButton ref={props.newNoteButtonRef}
className="new-note-button"
tooltip={ showTooltip ? CommandService.instance().label('newNote') : '' }
iconName={noteIcon}
title={_('%s', noteButtonText)}
level={ButtonLevel.Primary}
size={ButtonSize.Small}
size={props.buttonSize}
onClick={onNewNoteButtonClick}
disabled={!props.newNoteButtonEnabled}
/>
<StyledButton ref={newTodoRef}
<StyledButton ref={newTodoButtonRef}
className="new-todo-button"
tooltip={ showTooltip ? CommandService.instance().label('newTodo') : '' }
iconName={todoIcon}
title={_('%s', todoButtonText)}
level={ButtonLevel.Secondary}
size={ButtonSize.Small}
size={props.buttonSize}
onClick={onNewTodoButtonClick}
disabled={!props.newTodoButtonEnabled}
/>
@ -271,7 +232,7 @@ function NoteListControls(props: Props) {
}
return (
<StyledRoot ref={noteControlsRef}>
<StyledRoot ref={noteControlsRef} padding={props.padding} buttonVerticalGap={props.buttonVerticalGap}>
{renderNewNoteButtons()}
<BottomRow ref={searchAndSortRef} className="search-and-sort">
<SearchBar inputRef={searchBarRef}/>
@ -282,7 +243,7 @@ function NoteListControls(props: Props) {
tooltip={sortOrderFieldTooltip()}
iconName={sortOrderFieldIcon()}
level={ButtonLevel.Secondary}
size={ButtonSize.Small}
size={props.buttonSize}
onClick={onSortOrderFieldButtonClick}
/>
<StyledPairButtonR
@ -290,7 +251,7 @@ function NoteListControls(props: Props) {
tooltip={CommandService.instance().label('toggleNotesSortOrderReverse')}
iconName={sortOrderReverseIcon()}
level={ButtonLevel.Secondary}
size={ButtonSize.Small}
size={props.buttonSize}
onClick={onSortOrderReverseButtonClick}
/>
</SortOrderButtonsContainer>
@ -304,7 +265,6 @@ const mapStateToProps = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state);
return {
// TODO: showNewNoteButtons and the logic associated is not needed anymore.
showNewNoteButtons: true,
newNoteButtonEnabled: CommandService.instance().isEnabled('newNote', whenClauseContext),
newTodoButtonEnabled: CommandService.instance().isEnabled('newTodo', whenClauseContext),

View File

@ -59,7 +59,7 @@ const NoteListItem = (props: NoteItemProps, ref: LegacyRef<HTMLDivElement>) => {
const rootElement = useRootElement(elementId);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords);
const renderedNote = useRenderedNote(props.note, props.isSelected, props.isWatched, props.listRenderer, props.highlightedWords, props.index);
const itemElement = useItemElement(
rootElement,

View File

@ -3,7 +3,7 @@ import { NoteEntity, TagEntity } from '@joplin/lib/services/database/types';
import { Size } from '@joplin/utils/types';
import Note from '@joplin/lib/models/Note';
const prepareViewProps = async (dependencies: ListRendererDependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean, noteTags: TagEntity[]) => {
const prepareViewProps = async (dependencies: ListRendererDependency[], note: NoteEntity, itemSize: Size, selected: boolean, noteTitleHtml: string, noteIsWatched: boolean, noteTags: TagEntity[], itemIndex: number) => {
const output: any = {};
for (const dep of dependencies) {
@ -45,6 +45,11 @@ const prepareViewProps = async (dependencies: ListRendererDependency[], note: No
if (!output.item) output.item = {};
output.item.selected = selected;
}
if (dep === 'item.index') {
if (!output.item) output.item = {};
output.item.index = itemIndex;
}
}
return output;

View File

@ -19,7 +19,7 @@ const hashContent = (content: any) => {
return createHash('sha1').update(JSON.stringify(content)).digest('hex');
};
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[]) => {
export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listRenderer: ListRenderer, highlightedWords: string[], itemIndex: number) => {
const [renderedNote, setRenderedNote] = useState<RenderedNote>(null);
useAsyncEffect(async (event) => {
@ -56,6 +56,7 @@ export default (note: NoteEntity, isSelected: boolean, isWatched: boolean, listR
titleHtml,
isWatched,
noteTags,
itemIndex,
);
if (event.cancelled) return null;

View File

@ -1,12 +1,15 @@
import { themeStyle } from '@joplin/lib/theme';
import * as React from 'react';
import { useMemo, useState } from 'react';
import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import NoteList2 from '../NoteList/NoteList2';
import NoteListControls from '../NoteListControls/NoteListControls';
import { Size } from '../ResizableLayout/utils/types';
import styled from 'styled-components';
import { getDefaultListRenderer, getListRendererById } from '@joplin/lib/services/noteList/renderers';
import Logger from '@joplin/utils/Logger';
import { _ } from '@joplin/lib/locale';
import { BaseBreakpoint, Breakpoints } from '../NoteList/utils/types';
import { ButtonSize, buttonSizePx } from '../Button/Button';
const logger = Logger.create('NoteListWrapper');
@ -26,6 +29,52 @@ const StyledRoot = styled.div`
width: 100%;
`;
// Even though these calculations mostly concern the NoteListControls component, we do them here
// because we need to know the height of that control to calculate the note list height.
const useNoteListControlsBreakpoints = (width: number, newNoteRef: React.MutableRefObject<any>) => {
const [dynamicBreakpoints, setDynamicBreakpoints] = useState<Breakpoints>({ Sm: BaseBreakpoint.Sm, Md: BaseBreakpoint.Md, Lg: BaseBreakpoint.Lg, Xl: BaseBreakpoint.Xl });
const getTextWidth = useCallback((text: string): number => {
const canvas = document.createElement('canvas');
if (!canvas) throw new Error('Failed to create canvas element');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Failed to get context');
const fontWeight = getComputedStyle(newNoteRef.current).getPropertyValue('font-weight');
const fontSize = getComputedStyle(newNoteRef.current).getPropertyValue('font-size');
const fontFamily = getComputedStyle(newNoteRef.current).getPropertyValue('font-family');
ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`;
return ctx.measureText(text).width;
}, [newNoteRef]);
// Initialize language-specific breakpoints
useEffect(() => {
if (!newNoteRef.current) return;
// Use the longest string to calculate the amount of extra width needed
const smAdditional = getTextWidth(_('note')) > getTextWidth(_('to-do')) ? getTextWidth(_('note')) : getTextWidth(_('to-do'));
const mdAdditional = getTextWidth(_('New note')) > getTextWidth(_('New to-do')) ? getTextWidth(_('New note')) : getTextWidth(_('New to-do'));
const Sm = BaseBreakpoint.Sm + smAdditional * 2;
const Md = BaseBreakpoint.Md + mdAdditional * 2;
const Lg = BaseBreakpoint.Lg + Md;
const Xl = BaseBreakpoint.Xl;
setDynamicBreakpoints({ Sm, Md, Lg, Xl });
}, [newNoteRef, getTextWidth]);
const breakpoint: number = useMemo(() => {
// Find largest breakpoint that width is less than
const index = Object.values(dynamicBreakpoints).findIndex(x => width < x);
return index === -1 ? dynamicBreakpoints.Xl : Object.values(dynamicBreakpoints)[index];
}, [width, dynamicBreakpoints]);
const lineCount = breakpoint !== dynamicBreakpoints.Xl ? 2 : 1;
return { breakpoint, dynamicBreakpoints, lineCount };
};
// If the renderer ID that was saved to settings is already registered, we
// return it. If not, we need to wait for all plugins to be loaded, because one
// of them will most likely register the renderer we need. If none of them do,
@ -44,13 +93,28 @@ export default function NoteListWrapper(props: Props) {
const theme = themeStyle(props.themeId);
const [controlHeight] = useState(theme.topRowHeight);
const listRenderer = useListRenderer(props.listRendererId, props.startupPluginsLoaded);
const newNoteButtonRef = useRef(null);
const { breakpoint, dynamicBreakpoints, lineCount } = useNoteListControlsBreakpoints(props.size.width, newNoteButtonRef);
const noteListControlsButtonSize = ButtonSize.Small;
const noteListControlsPadding = theme.mainPadding;
const noteListControlsButtonVerticalGap = 5;
const noteListControlsHeight = useMemo(() => {
if (lineCount === 1) {
return buttonSizePx(noteListControlsButtonSize) + noteListControlsPadding * 2;
} else {
return buttonSizePx(noteListControlsButtonSize) * 2 + noteListControlsPadding * 2 + noteListControlsButtonVerticalGap;
}
}, [lineCount, noteListControlsButtonSize, noteListControlsPadding, noteListControlsButtonVerticalGap]);
const noteListSize = useMemo(() => {
return {
width: props.size.width,
height: props.size.height,
height: props.size.height - noteListControlsHeight,
};
}, [props.size]);
}, [props.size, noteListControlsHeight]);
const renderNoteList = () => {
if (!listRenderer) return null;
@ -64,7 +128,17 @@ export default function NoteListWrapper(props: Props) {
return (
<StyledRoot>
<NoteListControls height={controlHeight} width={noteListSize.width}/>
<NoteListControls
height={controlHeight}
width={noteListSize.width}
newNoteButtonRef={newNoteButtonRef}
breakpoint={breakpoint}
dynamicBreakpoints={dynamicBreakpoints}
lineCount={lineCount}
buttonSize={noteListControlsButtonSize}
padding={noteListControlsPadding}
buttonVerticalGap={noteListControlsButtonVerticalGap}
/>
{renderNoteList()}
</StyledRoot>
);

View File

@ -9,6 +9,7 @@ interface Props {
todo_completed: number;
};
item: {
// index: number;
size: {
height: number;
};
@ -29,6 +30,7 @@ const defaultListRenderer: ListRenderer = {
},
dependencies: [
// 'item.index',
'item.selected',
'item.size.height',
'note.id',

View File

@ -44,6 +44,7 @@ export type OnChangeHandler = (event: OnChangeEvent)=> Promise<void>;
*/
export type ListRendererDependency =
ListRendererDatabaseDependency |
'item.index' |
'item.size.width' |
'item.size.height' |
'item.selected' |