refactor: Move notes_view to React (#761)
This commit is contained in:
@@ -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;
|
||||
|
||||
107
app/assets/javascripts/components/NotesList.tsx
Normal file
107
app/assets/javascripts/components/NotesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
142
app/assets/javascripts/components/NotesListItem.tsx
Normal file
142
app/assets/javascripts/components/NotesListItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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: '&',
|
||||
}
|
||||
);
|
||||
|
||||
256
app/assets/javascripts/components/NotesView.tsx
Normal file
256
app/assets/javascripts/components/NotesView.tsx
Normal 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);
|
||||
60
app/assets/javascripts/components/PanelResizer.tsx
Normal file
60
app/assets/javascripts/components/PanelResizer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -17,7 +17,7 @@ type Props = {
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
const SearchOptions = observer(({ appState }: Props) => {
|
||||
export const SearchOptions = observer(({ appState }: Props) => {
|
||||
const { searchOptions } = appState;
|
||||
|
||||
const {
|
||||
|
||||
Reference in New Issue
Block a user