refactor: Move notes_view to React (#761)

This commit is contained in:
Aman Harwara
2021-12-21 20:31:11 +05:30
committed by GitHub
parent f120af3b43
commit 270fcbc3bc
20 changed files with 1495 additions and 1142 deletions

View File

@@ -5,7 +5,7 @@ import { observer } from 'mobx-react-lite';
type Props = { appState: AppState };
const NoAccountWarning = observer(({ appState }: Props) => {
export const NoAccountWarning = observer(({ appState }: Props) => {
const canShow = appState.noAccountWarning.show;
if (!canShow) {
return null;

View File

@@ -0,0 +1,107 @@
import { KeyboardKey } from '@/services/ioService';
import { AppState } from '@/ui_models/app_state';
import { DisplayOptions } from '@/ui_models/app_state/notes_view_state';
import { SNNote } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { NotesListItem } from './NotesListItem';
type Props = {
appState: AppState;
notes: SNNote[];
selectedNotes: Record<string, SNNote>;
displayOptions: DisplayOptions;
paginate: () => void;
};
const FOCUSABLE_BUT_NOT_TABBABLE = -1;
const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const NotesList: FunctionComponent<Props> = observer(
({ appState, notes, selectedNotes, displayOptions, paginate }) => {
const { selectPreviousNote, selectNextNote } = appState.notesView;
const { hideTags, hideDate, hideNotePreview, sortBy } = displayOptions;
const tagsStringForNote = (note: SNNote): string => {
if (hideTags) {
return '';
}
const selectedTag = appState.selectedTag;
if (!selectedTag) {
return '';
}
const tags = appState.getNoteTags(note);
if (!selectedTag.isSmartTag && tags.length === 1) {
return '';
}
return tags.map((tag) => `#${tag.title}`).join(' ');
};
const openNoteContextMenu = (posX: number, posY: number) => {
appState.notes.setContextMenuClickLocation({
x: posX,
y: posY,
});
appState.notes.reloadContextMenuLayout();
appState.notes.setContextMenuOpen(true);
};
const onContextMenu = async (note: SNNote, posX: number, posY: number) => {
await appState.notes.selectNote(note.uuid, true);
if (selectedNotes[note.uuid]) {
openNoteContextMenu(posX, posY);
}
};
const onScroll = (e: Event) => {
const offset = NOTES_LIST_SCROLL_THRESHOLD;
const element = e.target as HTMLElement;
if (
element.scrollTop + element.offsetHeight >=
element.scrollHeight - offset
) {
paginate();
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Up) {
e.preventDefault();
selectPreviousNote();
} else if (e.key === KeyboardKey.Down) {
e.preventDefault();
selectNextNote();
}
};
return (
<div
className="infinite-scroll"
id="notes-scrollable"
onScroll={onScroll}
onKeyDown={onKeyDown}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{notes.map((note) => (
<NotesListItem
key={note.uuid}
note={note}
tags={tagsStringForNote(note)}
selected={!!selectedNotes[note.uuid]}
hideDate={hideDate}
hidePreview={hideNotePreview}
hideTags={hideTags}
sortedBy={sortBy}
onClick={() => {
appState.notes.selectNote(note.uuid, true);
}}
onContextMenu={(e: MouseEvent) => {
e.preventDefault();
onContextMenu(note, e.clientX, e.clientY);
}}
/>
))}
</div>
);
}
);

View File

@@ -0,0 +1,142 @@
import { CollectionSort, SNNote } from '@standardnotes/snjs';
import { FunctionComponent } from 'preact';
type Props = {
note: SNNote;
tags: string;
hideDate: boolean;
hidePreview: boolean;
hideTags: boolean;
onClick: () => void;
onContextMenu: (e: MouseEvent) => void;
selected: boolean;
sortedBy?: CollectionSort;
};
type NoteFlag = {
text: string;
class: 'info' | 'neutral' | 'warning' | 'success' | 'danger';
};
const flagsForNote = (note: SNNote) => {
const flags = [] as NoteFlag[];
if (note.pinned) {
flags.push({
text: 'Pinned',
class: 'info',
});
}
if (note.archived) {
flags.push({
text: 'Archived',
class: 'warning',
});
}
if (note.locked) {
flags.push({
text: 'Editing Disabled',
class: 'neutral',
});
}
if (note.trashed) {
flags.push({
text: 'Deleted',
class: 'danger',
});
}
if (note.conflictOf) {
flags.push({
text: 'Conflicted Copy',
class: 'danger',
});
}
if (note.errorDecrypting) {
if (note.waitingForKey) {
flags.push({
text: 'Waiting For Keys',
class: 'info',
});
} else {
flags.push({
text: 'Missing Keys',
class: 'danger',
});
}
}
if (note.deleted) {
flags.push({
text: 'Deletion Pending Sync',
class: 'danger',
});
}
return flags;
};
export const NotesListItem: FunctionComponent<Props> = ({
hideDate,
hidePreview,
hideTags,
note,
onClick,
onContextMenu,
selected,
sortedBy,
tags,
}) => {
const flags = flagsForNote(note);
const showModifiedDate = sortedBy === CollectionSort.UpdatedAt;
return (
<div
className={`note ${selected ? 'selected' : ''}`}
id={`note-${note.uuid}`}
onClick={onClick}
onContextMenu={onContextMenu}
>
{flags && flags.length > 0 ? (
<div className="note-flags flex flex-wrap">
{flags.map((flag) => (
<div className={`flag ${flag.class}`}>
<div className="label">{flag.text}</div>
</div>
))}
</div>
) : null}
<div className="name">{note.title}</div>
{!hidePreview && !note.hidePreview && !note.protected ? (
<div className="note-preview">
{note.preview_html ? (
<div
className="html-preview"
dangerouslySetInnerHTML={{ __html: note.preview_html }}
></div>
) : null}
{!note.preview_html && note.preview_plain ? (
<div className="plain-preview">{note.preview_plain}</div>
) : null}
{!note.preview_html && !note.preview_plain ? (
<div className="default-preview">{note.text}</div>
) : null}
</div>
) : null}
{!hideDate || note.protected ? (
<div className="bottom-info faded">
{note.protected ? (
<span>Protected {hideDate ? '' : ' • '}</span>
) : null}
{!hideDate && showModifiedDate ? (
<span>Modified {note.updatedAtString || 'Now'}</span>
) : null}
{!hideDate && !showModifiedDate ? (
<span>{note.createdAtString || 'Now'}</span>
) : null}
</div>
) : null}
{!hideTags && (
<div className="tags-string">
<div className="faded">{tags}</div>
</div>
)}
</div>
);
};

View File

@@ -10,11 +10,11 @@ import { toDirective, useCloseOnClickOutside } from './utils';
type Props = {
application: WebApplication;
setShowMenuFalse: () => void;
closeDisplayOptionsMenu: () => void;
};
export const NotesListOptionsMenu: FunctionComponent<Props> = observer(
({ setShowMenuFalse, application }) => {
({ closeDisplayOptionsMenu, application }) => {
const menuClassName =
'sn-dropdown sn-dropdown--animated min-w-70 overflow-y-auto \
border-1 border-solid border-main text-sm z-index-dropdown-menu \
@@ -112,13 +112,13 @@ flex flex-col py-2 bottom-0 left-2 absolute';
useCloseOnClickOutside(menuRef as any, (open: boolean) => {
if (!open) {
setShowMenuFalse();
closeDisplayOptionsMenu();
}
});
return (
<div ref={menuRef} className={menuClassName}>
<Menu a11yLabel="Sort by" closeMenu={setShowMenuFalse}>
<Menu a11yLabel="Sort by" closeMenu={closeDisplayOptionsMenu}>
<div className="px-3 my-1 text-xs font-semibold color-text uppercase">
Sort by
</div>
@@ -246,7 +246,7 @@ flex flex-col py-2 bottom-0 left-2 absolute';
export const NotesListOptionsDirective = toDirective<Props>(
NotesListOptionsMenu,
{
setShowMenuFalse: '=',
closeDisplayOptionsMenu: '=',
state: '&',
}
);

View File

@@ -0,0 +1,256 @@
import {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { KeyboardKey, KeyboardModifier } from '@/services/ioService';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { PANEL_NAME_NOTES } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { NoAccountWarning } from './NoAccountWarning';
import { NotesList } from './NotesList';
import { NotesListOptionsMenu } from './NotesListOptionsMenu';
import { PanelResizer } from './PanelResizer';
import { SearchOptions } from './SearchOptions';
import { toDirective } from './utils';
type Props = {
application: WebApplication;
appState: AppState;
};
const NotesView: FunctionComponent<Props> = observer(
({ application, appState }) => {
const notesViewPanelRef = useRef<HTMLDivElement>(null);
const {
completedFullSync,
createNewNote,
displayOptions,
noteFilterText,
optionsSubtitle,
panelTitle,
renderedNotes,
selectedNotes,
setNoteFilterText,
showDisplayOptionsMenu,
toggleDisplayOptionsMenu,
searchBarElement,
selectNextNote,
selectPreviousNote,
onFilterEnter,
handleFilterTextChanged,
onSearchInputBlur,
clearFilterText,
paginate,
} = appState.notesView;
useEffect(() => {
handleFilterTextChanged();
}, [noteFilterText, handleFilterTextChanged]);
useEffect(() => {
/**
* In the browser we're not allowed to override cmd/ctrl + n, so we have to
* use Control modifier as well. These rules don't apply to desktop, but
* probably better to be consistent.
*/
const newNoteKeyObserver = application.io.addKeyObserver({
key: 'n',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Ctrl],
onKeyDown: (event) => {
event.preventDefault();
createNewNote();
},
});
const nextNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Down,
elements: [
document.body,
...(searchBarElement ? [searchBarElement] : []),
],
onKeyDown: () => {
if (searchBarElement === document.activeElement) {
searchBarElement?.blur();
}
selectNextNote();
},
});
const previousNoteKeyObserver = application.io.addKeyObserver({
key: KeyboardKey.Up,
element: document.body,
onKeyDown: () => {
selectPreviousNote();
},
});
const searchKeyObserver = application.io.addKeyObserver({
key: 'f',
modifiers: [KeyboardModifier.Meta, KeyboardModifier.Shift],
onKeyDown: () => {
if (searchBarElement) {
searchBarElement.focus();
}
},
});
return () => {
newNoteKeyObserver();
nextNoteKeyObserver();
previousNoteKeyObserver();
searchKeyObserver();
};
}, [
application.io,
createNewNote,
searchBarElement,
selectNextNote,
selectPreviousNote,
]);
const onNoteFilterTextChange = (e: Event) => {
setNoteFilterText((e.target as HTMLInputElement).value);
};
const onNoteFilterKeyUp = (e: KeyboardEvent) => {
if (e.key === KeyboardKey.Enter) {
onFilterEnter();
}
};
const panelResizeFinishCallback: ResizeFinishCallback = (
_w,
_l,
_mw,
isCollapsed
) => {
appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NOTES, isCollapsed);
};
const panelWidthEventCallback = () => {
appState.noteTags.reloadTagsContainerMaxWidth();
};
return (
<div
id="notes-column"
className="sn-component section notes"
aria-label="Notes"
ref={notesViewPanelRef}
>
<div className="content">
<div id="notes-title-bar" className="section-title-bar">
<div className="p-4">
<div className="section-title-bar-header">
<div className="sk-h2 font-semibold title">{panelTitle}</div>
<button
className="sk-button contrast wide"
title="Create a new note in the selected tag"
aria-label="Create new note"
onClick={() => createNewNote()}
>
<div className="sk-label">
<i className="ion-plus add-button" aria-hidden></i>
</div>
</button>
</div>
<div className="filter-section" role="search">
<input
type="text"
id="search-bar"
className="filter-bar"
placeholder="Search"
title="Searches notes in the currently selected tag"
value={noteFilterText}
onChange={onNoteFilterTextChange}
onKeyUp={onNoteFilterKeyUp}
onBlur={() => onSearchInputBlur()}
/>
{noteFilterText ? (
<button
onClick={clearFilterText}
aria-role="button"
id="search-clear-button"
>
</button>
) : null}
<div className="ml-2">
<SearchOptions
application={application}
appState={appState}
/>
</div>
</div>
<NoAccountWarning appState={appState} />
</div>
<div id="notes-menu-bar" className="sn-component">
<div className="sk-app-bar no-edges">
<div className="left">
<div
className={`sk-app-bar-item ${
showDisplayOptionsMenu ? 'selected' : ''
}`}
onClick={() =>
toggleDisplayOptionsMenu(!showDisplayOptionsMenu)
}
>
<div className="sk-app-bar-item-column">
<div className="sk-label">Options</div>
</div>
<div className="sk-app-bar-item-column">
<div className="sk-sublabel">{optionsSubtitle}</div>
</div>
</div>
</div>
</div>
{showDisplayOptionsMenu && (
<NotesListOptionsMenu
application={application}
closeDisplayOptionsMenu={() =>
toggleDisplayOptionsMenu(false)
}
/>
)}
</div>
</div>
{completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">No notes.</p>
) : null}
{!completedFullSync && !renderedNotes.length ? (
<p className="empty-notes-list faded">Loading notes...</p>
) : null}
{renderedNotes.length ? (
<NotesList
notes={renderedNotes}
selectedNotes={selectedNotes}
appState={appState}
displayOptions={displayOptions}
paginate={paginate}
/>
) : null}
</div>
{notesViewPanelRef.current && (
<PanelResizer
application={application}
collapsable={true}
defaultWidth={300}
panel={document.querySelector('notes-view') as HTMLDivElement}
prefKey={PrefKey.NotesPanelWidth}
side={PanelSide.Right}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
/>
)}
</div>
);
}
);
export const NotesViewDirective = toDirective<Props>(NotesView);

View File

@@ -0,0 +1,60 @@
import {
PanelResizerProps,
PanelResizerState,
} from '@/ui_models/panel_resizer';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks';
export const PanelResizer: FunctionComponent<PanelResizerProps> = observer(
({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
}) => {
const [panelResizerState] = useState(
() =>
new PanelResizerState({
alwaysVisible,
application,
defaultWidth,
hoverable,
collapsable,
minWidth,
panel,
prefKey,
resizeFinishCallback,
side,
widthEventCallback,
})
);
const panelResizerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (panelResizerRef.current) {
panelResizerState.setMinWidth(panelResizerRef.current.offsetWidth + 2);
}
}, [panelResizerState]);
return (
<div
className={`panel-resizer ${panelResizerState.side} ${
panelResizerState.hoverable ? 'hoverable' : ''
} ${panelResizerState.alwaysVisible ? 'alwaysVisible' : ''} ${
panelResizerState.pressed ? 'dragging' : ''
} ${panelResizerState.collapsed ? 'collapsed' : ''}`}
onMouseDown={panelResizerState.onMouseDown}
onDblClick={panelResizerState.onDblClick}
ref={panelResizerRef}
></div>
);
}
);

View File

@@ -17,7 +17,7 @@ type Props = {
application: WebApplication;
};
const SearchOptions = observer(({ appState }: Props) => {
export const SearchOptions = observer(({ appState }: Props) => {
const { searchOptions } = appState;
const {