feat: new revision history UI (#861)

This commit is contained in:
Aman Harwara
2022-02-16 19:39:04 +05:30
committed by GitHub
parent cc2bc1e21c
commit 71c7ee1bec
22 changed files with 1534 additions and 391 deletions

View File

@@ -23,6 +23,7 @@ import { NotesContextMenu } from '@/components/NotesContextMenu';
import { PurchaseFlowWrapper } from '@/purchaseFlow/PurchaseFlowWrapper';
import { render } from 'preact';
import { PermissionsModal } from './PermissionsModal';
import { RevisionHistoryModalWrapper } from './RevisionHistoryModal/RevisionHistoryModalWrapper';
import { PremiumModalProvider } from './Premium';
import { ConfirmSignoutContainer } from './ConfirmSignoutModal';
@@ -239,6 +240,11 @@ export class ApplicationView extends PureComponent<Props, State> {
appState={this.appState}
application={this.application}
/>
<RevisionHistoryModalWrapper
application={this.application}
appState={this.appState}
/>
</>
)}

View File

@@ -2,7 +2,8 @@ import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import TargetedMouseEvent = JSXInternal.TargetedMouseEvent;
import { FunctionComponent } from 'preact';
import { ComponentChildren, FunctionComponent, Ref } from 'preact';
import { forwardRef } from 'preact/compat';
const baseClass = `rounded px-4 py-1.75 font-bold text-sm fit-content`;
@@ -14,30 +15,46 @@ const buttonClasses: { [type in ButtonType]: string } = {
danger: `${baseClass} bg-default color-danger border-solid border-main border-1 focus:bg-contrast hover:bg-contrast`,
};
export const Button: FunctionComponent<{
type ButtonProps = {
children?: ComponentChildren;
className?: string;
type: ButtonType;
label: string;
label?: string;
onClick: (
event:
| TargetedEvent<HTMLFormElement>
| TargetedMouseEvent<HTMLButtonElement>
) => void;
disabled?: boolean;
}> = ({ type, label, className = '', onClick, disabled = false }) => {
const buttonClass = buttonClasses[type];
const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer';
return (
<button
className={`${buttonClass} ${cursorClass} ${className}`}
onClick={(e) => {
onClick(e);
e.preventDefault();
}}
disabled={disabled}
>
{label}
</button>
);
};
export const Button: FunctionComponent<ButtonProps> = forwardRef(
(
{
type,
label,
className = '',
onClick,
disabled = false,
children,
}: ButtonProps,
ref: Ref<HTMLButtonElement>
) => {
const buttonClass = buttonClasses[type];
const cursorClass = disabled ? 'cursor-default' : 'cursor-pointer';
return (
<button
className={`${buttonClass} ${cursorClass} ${className}`}
onClick={(e) => {
onClick(e);
e.preventDefault();
}}
disabled={disabled}
ref={ref}
>
{label ?? children}
</button>
);
}
);

View File

@@ -1,309 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import { NoteHistoryEntry, PayloadContent, SNNote } from '@standardnotes/snjs';
import { RevisionListEntry } from '@standardnotes/snjs';
import { alertDialog, confirmDialog } from '@/services/alertService';
import { PureComponent } from './Abstract/PureComponent';
import { MenuRow } from './MenuRow';
import { render } from 'preact';
import { RevisionPreviewModal } from './RevisionPreviewModal';
type HistoryState = {
sessionHistory?: NoteHistoryEntry[];
remoteHistory?: RevisionListEntry[];
fetchingRemoteHistory: boolean;
autoOptimize: boolean;
diskEnabled: boolean;
showRemoteOptions?: boolean;
showSessionOptions?: boolean;
};
type Props = {
application: WebApplication;
item: SNNote;
};
export class HistoryMenu extends PureComponent<Props, HistoryState> {
constructor(props: Props) {
super(props, props.application);
this.state = {
fetchingRemoteHistory: false,
autoOptimize: this.props.application.historyManager.autoOptimize,
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
sessionHistory:
this.props.application.historyManager.sessionHistoryForItem(
this.props.item
) as NoteHistoryEntry[],
};
}
reloadState() {
this.setState({
fetchingRemoteHistory: this.state.fetchingRemoteHistory,
autoOptimize: this.props.application.historyManager.autoOptimize,
diskEnabled: this.props.application.historyManager.isDiskEnabled(),
sessionHistory:
this.props.application.historyManager.sessionHistoryForItem(
this.props.item
) as NoteHistoryEntry[],
});
}
componentDidMount(): void {
super.componentDidMount();
this.fetchRemoteHistory();
}
fetchRemoteHistory = async () => {
this.setState({ fetchingRemoteHistory: true });
try {
const remoteHistory =
await this.props.application.historyManager.remoteHistoryForItem(
this.props.item
);
this.setState({ remoteHistory });
} finally {
this.setState({ fetchingRemoteHistory: false });
}
};
private presentRevisionPreviewModal = (
uuid: string,
content: PayloadContent,
title: string
) => {
render(
<RevisionPreviewModal
application={this.application}
uuid={uuid}
content={content}
title={title}
/>,
document.body.appendChild(document.createElement('div'))
);
};
openSessionRevision = (revision: NoteHistoryEntry) => {
this.presentRevisionPreviewModal(
revision.payload.uuid,
revision.payload.content,
revision.previewTitle()
);
};
openRemoteRevision = async (revision: RevisionListEntry) => {
this.setState({ fetchingRemoteHistory: true });
const remoteRevision =
await this.props.application.historyManager.fetchRemoteRevision(
this.props.item.uuid,
revision
);
this.setState({ fetchingRemoteHistory: false });
if (!remoteRevision) {
alertDialog({
text: 'The remote revision could not be loaded. Please try again later.',
});
return;
}
this.presentRevisionPreviewModal(
remoteRevision.payload.uuid,
remoteRevision.payload.content,
this.previewRemoteHistoryTitle(revision)
);
};
classForSessionRevision = (revision: NoteHistoryEntry) => {
const vector = revision.operationVector();
if (vector === 0) {
return 'default';
} else if (vector === 1) {
return 'success';
} else if (vector === -1) {
return 'danger';
}
};
clearItemSessionHistory = async () => {
if (
await confirmDialog({
text: 'Are you sure you want to delete the local session history for this note?',
confirmButtonStyle: 'danger',
})
) {
this.props.application.historyManager.clearHistoryForItem(
this.props.item
);
this.reloadState();
}
};
clearAllSessionHistory = async () => {
if (
await confirmDialog({
text: 'Are you sure you want to delete the local session history for all notes?',
confirmButtonStyle: 'danger',
})
) {
await this.props.application.historyManager.clearAllHistory();
this.reloadState();
}
};
toggleSessionHistoryDiskSaving = async () => {
if (!this.state.diskEnabled) {
if (
await confirmDialog({
text:
'Are you sure you want to save history to disk? This will decrease general ' +
'performance, especially as you type. You are advised to disable this feature ' +
'if you experience any lagging.',
confirmButtonStyle: 'danger',
})
) {
this.props.application.historyManager.toggleDiskSaving();
}
} else {
this.props.application.historyManager.toggleDiskSaving();
}
this.reloadState();
};
toggleSessionHistoryAutoOptimize = () => {
this.props.application.historyManager.toggleAutoOptimize();
this.reloadState();
};
previewRemoteHistoryTitle(revision: RevisionListEntry) {
return new Date(revision.created_at).toLocaleString();
}
toggleShowRemoteOptions = ($event: Event) => {
$event.stopPropagation();
this.setState({
showRemoteOptions: !this.state.showRemoteOptions,
});
};
toggleShowSessionOptions = ($event: Event) => {
$event.stopPropagation();
this.setState({
showSessionOptions: !this.state.showSessionOptions,
});
};
render() {
return (
<div id="history-menu" className="sn-component">
<div className="sk-menu-panel dropdown-menu">
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-header-title">
Session
<div className="sk-menu-panel-header-subtitle">
{this.state.sessionHistory?.length || 'No'} revisions
</div>
</div>
<a
className="sk-a info sk-h5"
onClick={this.toggleShowSessionOptions}
>
Options
</a>
</div>
{this.state.showSessionOptions && (
<div>
<MenuRow
action={this.clearItemSessionHistory}
label="Clear note local history"
/>
<MenuRow
action={this.clearAllSessionHistory}
label="Clear all local history"
/>
<MenuRow
action={this.toggleSessionHistoryAutoOptimize}
label={
(this.state.autoOptimize ? 'Disable' : 'Enable') +
' auto cleanup'
}
>
<div className="sk-sublabel">
Automatically cleans up small revisions to conserve space.
</div>
</MenuRow>
<MenuRow
action={this.toggleSessionHistoryDiskSaving}
label={
(this.state.diskEnabled ? 'Disable' : 'Enable') +
' saving history to disk'
}
>
<div className="sk-sublabel">
Saving to disk is not recommended. Decreases performance and
increases app loading time and memory footprint.
</div>
</MenuRow>
</div>
)}
{this.state.sessionHistory?.map((revision, index) => {
return (
<MenuRow
key={index}
action={() => this.openSessionRevision(revision)}
label={revision.previewTitle()}
>
<div
className={
this.classForSessionRevision(revision) +
' sk-sublabel opaque'
}
>
{revision.previewSubTitle()}
</div>
</MenuRow>
);
})}
<div className="sk-menu-panel-header">
<div className="sk-menu-panel-header-title">
Remote
<div className="sk-menu-panel-header-subtitle">
{this.state.remoteHistory?.length || 'No'} revisions
</div>
</div>
<a
onClick={this.toggleShowRemoteOptions}
className="sk-a info sk-h5"
>
Options
</a>
</div>
{this.state.showRemoteOptions && (
<MenuRow
action={this.fetchRemoteHistory}
label="Refresh"
disabled={this.state.fetchingRemoteHistory}
spinnerClass={
this.state.fetchingRemoteHistory ? 'info' : undefined
}
>
<div className="sk-sublabel">Fetch history from server.</div>
</MenuRow>
)}
{this.state.remoteHistory?.map((revision, index) => {
return (
<MenuRow
key={index}
action={() => this.openRemoteRevision(revision)}
label={this.previewRemoteHistoryTitle(revision)}
/>
);
})}
</div>
</div>
);
}
}

View File

@@ -26,6 +26,7 @@ import {
EyeOffIcon,
HashtagIcon,
HelpIcon,
HistoryIcon,
InfoIcon,
KeyboardIcon,
LinkOffIcon,
@@ -110,6 +111,7 @@ const ICONS = {
eye: EyeIcon,
hashtag: HashtagIcon,
help: HelpIcon,
history: HistoryIcon,
info: InfoIcon,
keyboard: KeyboardIcon,
listed: ListedIcon,

View File

@@ -34,7 +34,6 @@ import { PinNoteButton } from '../PinNoteButton';
import { NotesOptionsPanel } from '../NotesOptionsPanel';
import { NoteTagsContainer } from '../NoteTagsContainer';
import { ActionsMenu } from '../ActionsMenu';
import { HistoryMenu } from '../HistoryMenu';
import { ComponentView } from '../ComponentView';
import { PanelSide, PanelResizer, PanelResizeType } from '../PanelResizer';
import { ElementIds } from '@/element_ids';
@@ -108,7 +107,6 @@ type State = {
noteStatus?: NoteStatus;
saveError?: any;
showActionsMenu: boolean;
showHistoryMenu: boolean;
showLockedIcon: boolean;
showProtectedWarning: boolean;
spellcheck: boolean;
@@ -175,7 +173,6 @@ export class NoteView extends PureComponent<Props, State> {
noteStatus: undefined,
noteLocked: this.controller.note.locked,
showActionsMenu: false,
showHistoryMenu: false,
showLockedIcon: true,
showProtectedWarning: false,
spellcheck: true,
@@ -520,10 +517,10 @@ export class NoteView extends PureComponent<Props, State> {
};
closeAllMenus = (exclude?: string) => {
if (!(this.state.showActionsMenu || this.state.showHistoryMenu)) {
if (!this.state.showActionsMenu) {
return;
}
const allMenus = ['showActionsMenu', 'showHistoryMenu'];
const allMenus = ['showActionsMenu'];
const menuState: any = {};
for (const candidate of allMenus) {
if (candidate !== exclude) {
@@ -1115,21 +1112,6 @@ export class NoteView extends PureComponent<Props, State> {
/>
)}
</div>
<div
className={
(this.state.showHistoryMenu ? 'selected' : '') +
' sk-app-bar-item'
}
onClick={() => this.toggleMenu('showHistoryMenu')}
>
<div className="sk-label">History</div>
{this.state.showHistoryMenu && (
<HistoryMenu
item={this.note}
application={this.application}
/>
)}
</div>
</div>
</div>
</div>

View File

@@ -351,8 +351,25 @@ export const NotesOptions = observer(
);
}
const openRevisionHistoryModal = () => {
appState.notes.setShowRevisionHistoryModal(true);
};
return (
<>
{notes.length === 1 && (
<>
<button
onBlur={closeOnBlur}
className="sn-dropdown-item"
onClick={openRevisionHistoryModal}
>
<Icon type="history" className={iconClass} />
Note history
</button>
<div className="min-h-1px my-2 bg-border"></div>
</>
)}
<button
className="sn-dropdown-item justify-between"
onClick={() => {

View File

@@ -0,0 +1,230 @@
import { WebApplication } from '@/ui_models/application';
import {
ActionVerb,
HistoryEntry,
NoteHistoryEntry,
RevisionListEntry,
SNNote,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { StateUpdater, useCallback, useMemo, useState } from 'preact/hooks';
import { useEffect } from 'react';
import { LegacyHistoryList } from './LegacyHistoryList';
import { RemoteHistoryList } from './RemoteHistoryList';
import { SessionHistoryList } from './SessionHistoryList';
import {
LegacyHistoryEntry,
ListGroup,
RemoteRevisionListGroup,
sortRevisionListIntoGroups,
} from './utils';
export enum RevisionListTabType {
Session = 'Session',
Remote = 'Remote',
Legacy = 'Legacy',
}
type Props = {
application: WebApplication;
isFetchingRemoteHistory: boolean;
note: SNNote;
remoteHistory: RemoteRevisionListGroup[] | undefined;
setIsFetchingSelectedRevision: StateUpdater<boolean>;
setSelectedRemoteEntry: StateUpdater<RevisionListEntry | undefined>;
setSelectedRevision: StateUpdater<
HistoryEntry | LegacyHistoryEntry | undefined
>;
setShowContentLockedScreen: StateUpdater<boolean>;
};
export const HistoryListContainer: FunctionComponent<Props> = observer(
({
application,
isFetchingRemoteHistory,
note,
remoteHistory,
setIsFetchingSelectedRevision,
setSelectedRemoteEntry,
setSelectedRevision,
setShowContentLockedScreen,
}) => {
const sessionHistory = sortRevisionListIntoGroups<NoteHistoryEntry>(
application.historyManager.sessionHistoryForItem(
note
) as NoteHistoryEntry[]
);
const [isFetchingLegacyHistory, setIsFetchingLegacyHistory] =
useState(false);
const [legacyHistory, setLegacyHistory] =
useState<ListGroup<LegacyHistoryEntry>[]>();
const legacyHistoryLength = useMemo(
() => legacyHistory?.map((group) => group.entries).flat().length ?? 0,
[legacyHistory]
);
const [selectedTab, setSelectedTab] = useState<RevisionListTabType>(
RevisionListTabType.Remote
);
useEffect(() => {
const fetchLegacyHistory = async () => {
const actionExtensions = application.actionsManager.getExtensions();
actionExtensions.forEach(async (ext) => {
const actionExtension =
await application.actionsManager.loadExtensionInContextOfItem(
ext,
note
);
if (!actionExtension) {
return;
}
const isLegacyNoteHistoryExt = actionExtension?.actions.some(
(action) => action.verb === ActionVerb.Nested
);
if (!isLegacyNoteHistoryExt) {
return;
}
const legacyHistory = [] as LegacyHistoryEntry[];
setIsFetchingLegacyHistory(true);
await Promise.all(
actionExtension?.actions.map(async (action) => {
if (!action.subactions?.[0]) {
return;
}
const response = await application.actionsManager.runAction(
action.subactions[0],
note
);
if (!response) {
return;
}
legacyHistory.push(response.item as LegacyHistoryEntry);
})
);
setIsFetchingLegacyHistory(false);
setLegacyHistory(
sortRevisionListIntoGroups<LegacyHistoryEntry>(legacyHistory)
);
});
};
fetchLegacyHistory();
}, [application.actionsManager, note]);
const TabButton: FunctionComponent<{
type: RevisionListTabType;
}> = ({ type }) => {
const isSelected = selectedTab === type;
return (
<button
className={`bg-default border-0 cursor-pointer px-3 py-2.5 relative focus:shadow-inner ${
isSelected ? 'color-info font-medium shadow-bottom' : 'color-text'
}`}
onClick={() => {
setSelectedTab(type);
setSelectedRemoteEntry(undefined);
}}
>
{type}
</button>
);
};
const fetchAndSetRemoteRevision = useCallback(
async (revisionListEntry: RevisionListEntry) => {
setShowContentLockedScreen(false);
if (application.hasMinimumRole(revisionListEntry.required_role)) {
setIsFetchingSelectedRevision(true);
setSelectedRevision(undefined);
setSelectedRemoteEntry(undefined);
try {
const remoteRevision =
await application.historyManager.fetchRemoteRevision(
note.uuid,
revisionListEntry
);
setSelectedRevision(remoteRevision);
setSelectedRemoteEntry(revisionListEntry);
} catch (err) {
console.error(err);
} finally {
setIsFetchingSelectedRevision(false);
}
} else {
setShowContentLockedScreen(true);
setSelectedRevision(undefined);
}
},
[
application,
note.uuid,
setIsFetchingSelectedRevision,
setSelectedRemoteEntry,
setSelectedRevision,
setShowContentLockedScreen,
]
);
return (
<div
className={`flex flex-col min-w-60 border-0 border-r-1px border-solid border-main overflow-auto h-full`}
>
<div className="flex border-0 border-b-1 border-solid border-main">
<TabButton type={RevisionListTabType.Remote} />
<TabButton type={RevisionListTabType.Session} />
{isFetchingLegacyHistory && (
<div className="flex items-center justify-center px-3 py-2.5">
<div className="sk-spinner w-3 h-3 spinner-info" />
</div>
)}
{legacyHistory && legacyHistoryLength > 0 && (
<TabButton type={RevisionListTabType.Legacy} />
)}
</div>
<div className={`min-h-0 overflow-auto py-1.5 h-full`}>
{selectedTab === RevisionListTabType.Session && (
<SessionHistoryList
selectedTab={selectedTab}
sessionHistory={sessionHistory}
setSelectedRevision={setSelectedRevision}
setSelectedRemoteEntry={setSelectedRemoteEntry}
/>
)}
{selectedTab === RevisionListTabType.Remote && (
<RemoteHistoryList
application={application}
remoteHistory={remoteHistory}
isFetchingRemoteHistory={isFetchingRemoteHistory}
fetchAndSetRemoteRevision={fetchAndSetRemoteRevision}
selectedTab={selectedTab}
/>
)}
{selectedTab === RevisionListTabType.Legacy && (
<LegacyHistoryList
selectedTab={selectedTab}
legacyHistory={legacyHistory}
setSelectedRevision={setSelectedRevision}
setSelectedRemoteEntry={setSelectedRemoteEntry}
/>
)}
</div>
</div>
);
}
);

View File

@@ -0,0 +1,31 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/views/constants';
import { FunctionComponent } from 'preact';
type HistoryListItemProps = {
isSelected: boolean;
onClick: () => void;
};
export const HistoryListItem: FunctionComponent<HistoryListItemProps> = ({
children,
isSelected,
onClick,
}) => {
return (
<button
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={`sn-dropdown-item py-2.5 focus:bg-contrast focus:shadow-none ${
isSelected ? 'bg-info-backdrop' : ''
}`}
onClick={onClick}
data-selected={isSelected}
>
<div
className={`pseudo-radio-btn ${
isSelected ? 'pseudo-radio-btn--checked' : ''
} mr-2`}
></div>
{children}
</button>
);
};

View File

@@ -0,0 +1,111 @@
import { HistoryEntry, RevisionListEntry } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import {
StateUpdater,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useListKeyboardNavigation } from '../utils';
import { RevisionListTabType } from './HistoryListContainer';
import { HistoryListItem } from './HistoryListItem';
import {
LegacyHistoryEntry,
ListGroup,
previewHistoryEntryTitle,
} from './utils';
type Props = {
selectedTab: RevisionListTabType;
legacyHistory: ListGroup<LegacyHistoryEntry>[] | undefined;
setSelectedRevision: StateUpdater<
HistoryEntry | LegacyHistoryEntry | undefined
>;
setSelectedRemoteEntry: StateUpdater<RevisionListEntry | undefined>;
};
export const LegacyHistoryList: FunctionComponent<Props> = ({
legacyHistory,
selectedTab,
setSelectedRevision,
setSelectedRemoteEntry,
}) => {
const legacyHistoryListRef = useRef<HTMLDivElement>(null);
useListKeyboardNavigation(legacyHistoryListRef);
const legacyHistoryLength = useMemo(
() => legacyHistory?.map((group) => group.entries).flat().length,
[legacyHistory]
);
const [selectedItemCreatedAt, setSelectedItemCreatedAt] = useState<Date>();
const firstEntry = useMemo(() => {
return legacyHistory?.find((group) => group.entries?.length)?.entries?.[0];
}, [legacyHistory]);
const selectFirstEntry = useCallback(() => {
if (firstEntry) {
setSelectedItemCreatedAt(firstEntry.payload?.created_at);
setSelectedRevision(firstEntry);
}
}, [firstEntry, setSelectedRevision]);
useEffect(() => {
if (firstEntry && !selectedItemCreatedAt) {
selectFirstEntry();
} else if (!firstEntry) {
setSelectedRevision(undefined);
}
}, [
firstEntry,
selectFirstEntry,
selectedItemCreatedAt,
setSelectedRevision,
]);
useEffect(() => {
if (selectedTab === RevisionListTabType.Session) {
selectFirstEntry();
legacyHistoryListRef.current?.focus();
}
}, [selectFirstEntry, selectedTab]);
return (
<div
className={`flex flex-col w-full h-full focus:shadow-none ${
!legacyHistoryLength ? 'items-center justify-center' : ''
}`}
ref={legacyHistoryListRef}
>
{legacyHistory?.map((group) =>
group.entries && group.entries.length ? (
<Fragment key={group.title}>
<div className="px-3 mt-2.5 mb-1 font-semibold color-text uppercase color-grey-0 select-none">
{group.title}
</div>
{group.entries.map((entry, index) => (
<HistoryListItem
key={index}
isSelected={selectedItemCreatedAt === entry.payload.created_at}
onClick={() => {
setSelectedItemCreatedAt(entry.payload.created_at);
setSelectedRevision(entry);
setSelectedRemoteEntry(undefined);
}}
>
{previewHistoryEntryTitle(entry)}
</HistoryListItem>
))}
</Fragment>
) : null
)}
{!legacyHistoryLength && (
<div className="color-grey-0 select-none">No legacy history found</div>
)}
</div>
);
};

View File

@@ -0,0 +1,125 @@
import { WebApplication } from '@/ui_models/application';
import { RevisionListEntry } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { Fragment, FunctionComponent } from 'preact';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { Icon } from '../Icon';
import { useListKeyboardNavigation } from '../utils';
import { RevisionListTabType } from './HistoryListContainer';
import { HistoryListItem } from './HistoryListItem';
import { previewHistoryEntryTitle, RemoteRevisionListGroup } from './utils';
type RemoteHistoryListProps = {
application: WebApplication;
remoteHistory: RemoteRevisionListGroup[] | undefined;
isFetchingRemoteHistory: boolean;
fetchAndSetRemoteRevision: (
revisionListEntry: RevisionListEntry
) => Promise<void>;
selectedTab: RevisionListTabType;
};
export const RemoteHistoryList: FunctionComponent<RemoteHistoryListProps> =
observer(
({
application,
remoteHistory,
isFetchingRemoteHistory,
fetchAndSetRemoteRevision,
selectedTab,
}) => {
const remoteHistoryListRef = useRef<HTMLDivElement>(null);
useListKeyboardNavigation(remoteHistoryListRef);
const remoteHistoryLength = useMemo(
() => remoteHistory?.map((group) => group.entries).flat().length,
[remoteHistory]
);
const [selectedEntryUuid, setSelectedEntryUuid] = useState('');
const firstEntry = useMemo(() => {
return remoteHistory?.find((group) => group.entries?.length)
?.entries?.[0];
}, [remoteHistory]);
const selectFirstEntry = useCallback(() => {
if (firstEntry) {
setSelectedEntryUuid(firstEntry.uuid);
fetchAndSetRemoteRevision(firstEntry);
}
}, [fetchAndSetRemoteRevision, firstEntry]);
useEffect(() => {
if (firstEntry && !selectedEntryUuid.length) {
selectFirstEntry();
}
}, [
fetchAndSetRemoteRevision,
firstEntry,
remoteHistory,
selectFirstEntry,
selectedEntryUuid.length,
]);
useEffect(() => {
if (selectedTab === RevisionListTabType.Remote) {
selectFirstEntry();
remoteHistoryListRef.current?.focus();
}
}, [selectFirstEntry, selectedTab]);
return (
<div
className={`flex flex-col w-full h-full focus:shadow-none ${
isFetchingRemoteHistory || !remoteHistoryLength
? 'items-center justify-center'
: ''
}`}
ref={remoteHistoryListRef}
>
{isFetchingRemoteHistory && (
<div className="sk-spinner w-5 h-5 spinner-info"></div>
)}
{remoteHistory?.map((group) =>
group.entries && group.entries.length ? (
<Fragment key={group.title}>
<div className="px-3 mt-2.5 mb-1 font-semibold color-text uppercase color-grey-0 select-none">
{group.title}
</div>
{group.entries.map((entry) => (
<HistoryListItem
key={entry.uuid}
isSelected={selectedEntryUuid === entry.uuid}
onClick={() => {
setSelectedEntryUuid(entry.uuid);
fetchAndSetRemoteRevision(entry);
}}
>
<div className="flex flex-grow items-center justify-between">
<div>{previewHistoryEntryTitle(entry)}</div>
{!application.hasMinimumRole(entry.required_role) && (
<Icon type="premium-feature" />
)}
</div>
</HistoryListItem>
))}
</Fragment>
) : null
)}
{!remoteHistoryLength && !isFetchingRemoteHistory && (
<div className="color-grey-0 select-none">
No remote history found
</div>
)}
</div>
);
}
);

View File

@@ -0,0 +1,58 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import HistoryLockedIllustration from '../../../svg/il-history-locked.svg';
import { Button } from '../Button';
const getPlanHistoryDuration = (planName: string | undefined) => {
switch (planName) {
case 'Core':
return '30 days';
case 'Plus':
return '365 days';
default:
return 'the current session';
}
};
const getPremiumContentCopy = (planName: string | undefined) => {
return `Version history is limited to ${getPlanHistoryDuration(
planName
)} in the ${planName} plan`;
};
export const RevisionContentLocked: FunctionComponent<{
appState: AppState;
}> = observer(({ appState }) => {
const {
userSubscriptionName,
isUserSubscriptionExpired,
isUserSubscriptionCanceled,
} = appState.subscription;
return (
<div className="flex w-full h-full items-center justify-center">
<div className="flex flex-col items-center text-center max-w-40%">
<HistoryLockedIllustration />
<div class="text-lg font-bold mt-2 mb-1">Can't access this version</div>
<div className="mb-4 color-grey-0 leading-140%">
{getPremiumContentCopy(
!isUserSubscriptionCanceled && !isUserSubscriptionExpired
? userSubscriptionName
: 'free'
)}
. Learn more about our other plans to upgrade your history capacity.
</div>
<Button
type="primary"
label="Discover plans"
onClick={() => {
if (window._plans_url) {
window.location.assign(window._plans_url);
}
}}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,348 @@
import { confirmDialog } from '@/services/alertService';
import { STRING_RESTORE_LOCKED_ATTEMPT } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { getPlatformString } from '@/utils';
import {
AlertDialogContent,
AlertDialogDescription,
AlertDialogLabel,
AlertDialogOverlay,
} from '@reach/alert-dialog';
import VisuallyHidden from '@reach/visually-hidden';
import {
ButtonType,
ContentType,
HistoryEntry,
PayloadContent,
PayloadSource,
RevisionListEntry,
SNNote,
} from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { Button } from '../Button';
import { HistoryListContainer } from './HistoryListContainer';
import { RevisionContentLocked } from './RevisionContentLocked';
import { SelectedRevisionContent } from './SelectedRevisionContent';
import {
LegacyHistoryEntry,
RemoteRevisionListGroup,
sortRevisionListIntoGroups,
} from './utils';
type RevisionHistoryModalProps = {
application: WebApplication;
appState: AppState;
};
const ABSOLUTE_CENTER_CLASSNAME =
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2';
type RevisionContentPlaceholderProps = {
isFetchingSelectedRevision: boolean;
selectedRevision: HistoryEntry | LegacyHistoryEntry | undefined;
showContentLockedScreen: boolean;
};
const RevisionContentPlaceholder: FunctionComponent<
RevisionContentPlaceholderProps
> = ({
isFetchingSelectedRevision,
selectedRevision,
showContentLockedScreen,
}) => (
<div
className={`absolute w-full h-full top-0 left-0 ${
(isFetchingSelectedRevision || !selectedRevision) &&
!showContentLockedScreen
? 'z-index-1 bg-default'
: '-z-index-1'
}`}
>
{isFetchingSelectedRevision && (
<div
className={`sk-spinner w-5 h-5 spinner-info ${ABSOLUTE_CENTER_CLASSNAME}`}
/>
)}
{!isFetchingSelectedRevision && !selectedRevision ? (
<div className={`color-grey-0 select-none ${ABSOLUTE_CENTER_CLASSNAME}`}>
No revision selected
</div>
) : null}
</div>
);
export const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> =
observer(({ application, appState }) => {
const closeButtonRef = useRef<HTMLButtonElement>(null);
const dismissModal = () => {
appState.notes.setShowRevisionHistoryModal(false);
};
const note = Object.values(appState.notes.selectedNotes)[0];
const editorForCurrentNote = useMemo(() => {
return application.componentManager.editorForNote(note);
}, [application.componentManager, note]);
const [isFetchingSelectedRevision, setIsFetchingSelectedRevision] =
useState(false);
const [selectedRevision, setSelectedRevision] = useState<
HistoryEntry | LegacyHistoryEntry
>();
const [selectedRemoteEntry, setSelectedRemoteEntry] =
useState<RevisionListEntry>();
const [isDeletingRevision, setIsDeletingRevision] = useState(false);
const [templateNoteForRevision, setTemplateNoteForRevision] =
useState<SNNote>();
const [showContentLockedScreen, setShowContentLockedScreen] =
useState(false);
const [remoteHistory, setRemoteHistory] =
useState<RemoteRevisionListGroup[]>();
const [isFetchingRemoteHistory, setIsFetchingRemoteHistory] =
useState(false);
const fetchRemoteHistory = useCallback(async () => {
if (note) {
setRemoteHistory(undefined);
setIsFetchingRemoteHistory(true);
try {
const initialRemoteHistory =
await application.historyManager.remoteHistoryForItem(note);
const remoteHistoryAsGroups =
sortRevisionListIntoGroups<RevisionListEntry>(initialRemoteHistory);
setRemoteHistory(remoteHistoryAsGroups);
} catch (err) {
console.error(err);
} finally {
setIsFetchingRemoteHistory(false);
}
}
}, [application.historyManager, note]);
useEffect(() => {
if (!remoteHistory?.length) {
fetchRemoteHistory();
}
}, [fetchRemoteHistory, remoteHistory?.length]);
const restore = () => {
if (selectedRevision) {
const originalNote = application.findItem(
selectedRevision.payload.uuid
) as SNNote;
if (originalNote.locked) {
application.alertService.alert(STRING_RESTORE_LOCKED_ATTEMPT);
return;
}
confirmDialog({
text: "Are you sure you want to replace the current note's contents with what you see in this preview?",
confirmButtonStyle: 'danger',
}).then((confirmed) => {
if (confirmed) {
application.changeAndSaveItem(
selectedRevision.payload.uuid,
(mutator) => {
mutator.unsafe_setCustomContent(
selectedRevision.payload.content
);
},
true,
PayloadSource.RemoteActionRetrieved
);
dismissModal();
}
});
}
};
const restoreAsCopy = async () => {
if (selectedRevision) {
const originalNote = application.findItem(
selectedRevision.payload.uuid
) as SNNote;
const duplicatedItem = await application.duplicateItem(originalNote, {
...(selectedRevision.payload.content as PayloadContent),
title: selectedRevision.payload.content.title
? selectedRevision.payload.content.title + ' (copy)'
: undefined,
});
appState.notes.selectNote(duplicatedItem.uuid);
dismissModal();
}
};
useEffect(() => {
const fetchTemplateNote = async () => {
if (selectedRevision) {
const newTemplateNote = (await application.createTemplateItem(
ContentType.Note,
selectedRevision.payload.content
)) as SNNote;
setTemplateNoteForRevision(newTemplateNote);
}
};
fetchTemplateNote();
}, [application, selectedRevision]);
const deleteSelectedRevision = () => {
if (!selectedRemoteEntry) {
return;
}
application.alertService
.confirm(
'Are you sure you want to delete this revision?',
'Delete revision?',
'Delete revision',
ButtonType.Danger,
'Cancel'
)
.then((shouldDelete) => {
if (shouldDelete) {
setIsDeletingRevision(true);
application.historyManager
.deleteRemoteRevision(note.uuid, selectedRemoteEntry)
.then((res) => {
if (res.error?.message) {
throw new Error(res.error.message);
}
fetchRemoteHistory();
setIsDeletingRevision(false);
})
.catch(console.error);
}
})
.catch(console.error);
};
return (
<AlertDialogOverlay
className={`sn-component ${getPlatformString()}`}
onDismiss={dismissModal}
leastDestructiveRef={closeButtonRef}
>
<AlertDialogContent
className="rounded shadow-overlay"
style={{
width: '90%',
maxWidth: '90%',
minHeight: '90%',
background: '#fff',
}}
>
<AlertDialogLabel>
<VisuallyHidden>Note revision history</VisuallyHidden>
</AlertDialogLabel>
<AlertDialogDescription
className={`bg-default flex flex-col h-full overflow-hidden ${
isDeletingRevision ? 'pointer-events-none cursor-not-allowed' : ''
}`}
>
<div className="flex flex-grow min-h-0">
<HistoryListContainer
application={application}
note={note}
remoteHistory={remoteHistory}
isFetchingRemoteHistory={isFetchingRemoteHistory}
setSelectedRevision={setSelectedRevision}
setSelectedRemoteEntry={setSelectedRemoteEntry}
setShowContentLockedScreen={setShowContentLockedScreen}
setIsFetchingSelectedRevision={setIsFetchingSelectedRevision}
/>
<div className={`flex flex-col flex-grow relative`}>
<RevisionContentPlaceholder
selectedRevision={selectedRevision}
isFetchingSelectedRevision={isFetchingSelectedRevision}
showContentLockedScreen={showContentLockedScreen}
/>
{showContentLockedScreen && !selectedRevision && (
<RevisionContentLocked appState={appState} />
)}
{selectedRevision && templateNoteForRevision && (
<SelectedRevisionContent
application={application}
appState={appState}
selectedRevision={selectedRevision}
editorForCurrentNote={editorForCurrentNote}
templateNoteForRevision={templateNoteForRevision}
/>
)}
</div>
</div>
<div className="flex flex-shrink-0 justify-between items-center min-h-6 px-2.5 py-2 border-0 border-t-1px border-solid border-main">
<div>
<Button
className="py-1.35"
label="Close"
onClick={dismissModal}
ref={closeButtonRef}
type="normal"
/>
</div>
{selectedRevision && (
<div class="flex items-center">
{selectedRemoteEntry && (
<Button
className="py-1.35 mr-2.5"
onClick={deleteSelectedRevision}
type="normal"
>
{isDeletingRevision ? (
<div className="sk-spinner my-1 w-3 h-3 spinner-info" />
) : (
'Delete this revision'
)}
</Button>
)}
<Button
className="py-1.35 mr-2.5"
label="Restore as a copy"
onClick={restoreAsCopy}
type="normal"
/>
<Button
className="py-1.35"
label="Restore version"
onClick={restore}
type="primary"
/>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogContent>
</AlertDialogOverlay>
);
});
export const RevisionHistoryModalWrapper: FunctionComponent<RevisionHistoryModalProps> =
observer(({ application, appState }) => {
if (!appState.notes.showRevisionHistoryModal) {
return null;
}
return (
<RevisionHistoryModal application={application} appState={appState} />
);
});

View File

@@ -0,0 +1,92 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { HistoryEntry, SNComponent, SNNote } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useEffect, useMemo } from 'preact/hooks';
import { ComponentView } from '../ComponentView';
import { LegacyHistoryEntry } from './utils';
const ABSOLUTE_CENTER_CLASSNAME =
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2';
type SelectedRevisionContentProps = {
application: WebApplication;
appState: AppState;
selectedRevision: HistoryEntry | LegacyHistoryEntry;
editorForCurrentNote: SNComponent | undefined;
templateNoteForRevision: SNNote;
};
export const SelectedRevisionContent: FunctionComponent<SelectedRevisionContentProps> =
observer(
({
application,
appState,
selectedRevision,
editorForCurrentNote,
templateNoteForRevision,
}) => {
const componentViewer = useMemo(() => {
if (!editorForCurrentNote) {
return undefined;
}
const componentViewer =
application.componentManager.createComponentViewer(
editorForCurrentNote
);
componentViewer.setReadonly(true);
componentViewer.lockReadonly = true;
componentViewer.overrideContextItem = templateNoteForRevision;
return componentViewer;
}, [
application.componentManager,
editorForCurrentNote,
templateNoteForRevision,
]);
useEffect(() => {
return () => {
if (componentViewer) {
application.componentManager.destroyComponentViewer(
componentViewer
);
}
};
}, [application.componentManager, componentViewer]);
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-4 text-base font-bold w-full">
<div className="title">
{selectedRevision.payload.content.title}
</div>
</div>
{!componentViewer && (
<div className="relative flex-grow min-h-0 overflow-x-hidden overflow-y-auto">
{selectedRevision.payload.content.text.length ? (
<p className="p-4 pt-0">
{selectedRevision.payload.content.text}
</p>
) : (
<div className={`color-grey-0 ${ABSOLUTE_CENTER_CLASSNAME}`}>
Empty note.
</div>
)}
</div>
)}
{componentViewer && (
<div className="component-view">
<ComponentView
key={componentViewer.identifier}
componentViewer={componentViewer}
application={application}
appState={appState}
/>
</div>
)}
</div>
);
}
);

View File

@@ -0,0 +1,111 @@
import {
HistoryEntry,
NoteHistoryEntry,
RevisionListEntry,
} from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import {
StateUpdater,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'preact/hooks';
import { useListKeyboardNavigation } from '../utils';
import { RevisionListTabType } from './HistoryListContainer';
import { HistoryListItem } from './HistoryListItem';
import { LegacyHistoryEntry, ListGroup } from './utils';
type Props = {
selectedTab: RevisionListTabType;
sessionHistory: ListGroup<NoteHistoryEntry>[];
setSelectedRevision: StateUpdater<
HistoryEntry | LegacyHistoryEntry | undefined
>;
setSelectedRemoteEntry: StateUpdater<RevisionListEntry | undefined>;
};
export const SessionHistoryList: FunctionComponent<Props> = ({
sessionHistory,
selectedTab,
setSelectedRevision,
setSelectedRemoteEntry,
}) => {
const sessionHistoryListRef = useRef<HTMLDivElement>(null);
useListKeyboardNavigation(sessionHistoryListRef);
const sessionHistoryLength = useMemo(
() => sessionHistory.map((group) => group.entries).flat().length,
[sessionHistory]
);
const [selectedItemCreatedAt, setSelectedItemCreatedAt] = useState<Date>();
const firstEntry = useMemo(() => {
return sessionHistory?.find((group) => group.entries?.length)?.entries?.[0];
}, [sessionHistory]);
const selectFirstEntry = useCallback(() => {
if (firstEntry) {
setSelectedItemCreatedAt(firstEntry.payload.created_at);
setSelectedRevision(firstEntry);
}
}, [firstEntry, setSelectedRevision]);
useEffect(() => {
if (firstEntry && !selectedItemCreatedAt) {
selectFirstEntry();
} else if (!firstEntry) {
setSelectedRevision(undefined);
}
}, [
firstEntry,
selectFirstEntry,
selectedItemCreatedAt,
setSelectedRevision,
]);
useEffect(() => {
if (selectedTab === RevisionListTabType.Session) {
selectFirstEntry();
sessionHistoryListRef.current?.focus();
}
}, [selectFirstEntry, selectedTab]);
return (
<div
className={`flex flex-col w-full h-full focus:shadow-none ${
!sessionHistoryLength ? 'items-center justify-center' : ''
}`}
ref={sessionHistoryListRef}
>
{sessionHistory?.map((group) =>
group.entries && group.entries.length ? (
<Fragment key={group.title}>
<div className="px-3 mt-2.5 mb-1 font-semibold color-text uppercase color-grey-0 select-none">
{group.title}
</div>
{group.entries.map((entry, index) => (
<HistoryListItem
key={index}
isSelected={selectedItemCreatedAt === entry.payload.created_at}
onClick={() => {
setSelectedItemCreatedAt(entry.payload.created_at);
setSelectedRevision(entry);
setSelectedRemoteEntry(undefined);
}}
>
{entry.previewTitle()}
</HistoryListItem>
))}
</Fragment>
) : null
)}
{!sessionHistoryLength && (
<div className="color-grey-0 select-none">No session history found</div>
)}
</div>
);
};

View File

@@ -0,0 +1,112 @@
import { DAYS_IN_A_WEEK, DAYS_IN_A_YEAR } from '@/views/constants';
import {
HistoryEntry,
NoteHistoryEntry,
RevisionListEntry,
} from '@standardnotes/snjs/dist/@types';
import { calculateDifferenceBetweenDatesInDays } from '../utils';
export type LegacyHistoryEntry = {
payload: HistoryEntry['payload'];
created_at: string;
};
type RevisionEntry = RevisionListEntry | NoteHistoryEntry | LegacyHistoryEntry;
export type ListGroup<EntryType extends RevisionEntry> = {
title: string;
entries: EntryType[] | undefined;
};
export type RemoteRevisionListGroup = ListGroup<RevisionListEntry>;
export type SessionRevisionListGroup = ListGroup<NoteHistoryEntry>;
export const formatDateAsMonthYearString = (date: Date) =>
date.toLocaleDateString(undefined, {
month: 'long',
year: 'numeric',
});
export const getGroupIndexForEntry = (
entry: RevisionEntry,
groups: ListGroup<RevisionEntry>[]
) => {
const todayAsDate = new Date();
const entryDate = new Date(
(entry as RevisionListEntry).created_at ??
(entry as NoteHistoryEntry).payload.created_at
);
const differenceBetweenDatesInDays = calculateDifferenceBetweenDatesInDays(
todayAsDate,
entryDate
);
if (differenceBetweenDatesInDays === 0) {
return groups.findIndex((group) => group.title === GROUP_TITLE_TODAY);
}
if (
differenceBetweenDatesInDays > 0 &&
differenceBetweenDatesInDays < DAYS_IN_A_WEEK
) {
return groups.findIndex((group) => group.title === GROUP_TITLE_WEEK);
}
if (differenceBetweenDatesInDays > DAYS_IN_A_YEAR) {
return groups.findIndex((group) => group.title === GROUP_TITLE_YEAR);
}
const formattedEntryMonthYear = formatDateAsMonthYearString(entryDate);
return groups.findIndex((group) => group.title === formattedEntryMonthYear);
};
const GROUP_TITLE_TODAY = 'Today';
const GROUP_TITLE_WEEK = 'This Week';
const GROUP_TITLE_YEAR = 'More Than A Year Ago';
export const sortRevisionListIntoGroups = <EntryType extends RevisionEntry>(
revisionList: EntryType[] | undefined
) => {
const sortedGroups: ListGroup<EntryType>[] = [
{
title: GROUP_TITLE_TODAY,
entries: [],
},
{
title: GROUP_TITLE_WEEK,
entries: [],
},
{
title: GROUP_TITLE_YEAR,
entries: [],
},
];
revisionList?.forEach((entry) => {
const groupIndex = getGroupIndexForEntry(entry, sortedGroups);
if (groupIndex > -1) {
sortedGroups[groupIndex]?.entries?.push(entry);
} else {
sortedGroups.push({
title: formatDateAsMonthYearString(
new Date(
(entry as RevisionListEntry).created_at ??
(entry as NoteHistoryEntry).payload.created_at
)
),
entries: [entry],
});
}
});
return sortedGroups;
};
export const previewHistoryEntryTitle = (
revision: RevisionListEntry | LegacyHistoryEntry
) => {
return new Date(revision.created_at).toLocaleString();
};

View File

@@ -1,6 +1,15 @@
import { FunctionComponent, h, render } from 'preact';
import { unmountComponentAtNode } from 'preact/compat';
import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
import { KeyboardKey } from '@/services/ioService';
import {
FOCUSABLE_BUT_NOT_TABBABLE,
MILLISECONDS_IN_A_DAY,
} from '@/views/constants';
import {
StateUpdater,
useCallback,
useState,
useEffect,
Ref,
} from 'preact/hooks';
/**
* @returns a callback that will close a dropdown if none of its children has
@@ -57,3 +66,105 @@ export function useCloseOnClickOutside(
};
}, [closeOnClickOutside]);
}
export const calculateDifferenceBetweenDatesInDays = (
firstDate: Date,
secondDate: Date
) => {
const firstDateAsUTCMilliseconds = Date.UTC(
firstDate.getFullYear(),
firstDate.getMonth(),
firstDate.getDate()
);
const secondDateAsUTCMilliseconds = Date.UTC(
secondDate.getFullYear(),
secondDate.getMonth(),
secondDate.getDate()
);
return Math.round(
(firstDateAsUTCMilliseconds - secondDateAsUTCMilliseconds) /
MILLISECONDS_IN_A_DAY
);
};
export const useListKeyboardNavigation = (
container: Ref<HTMLElement | null>
) => {
const [listItems, setListItems] = useState<NodeListOf<HTMLButtonElement>>();
const [focusedItemIndex, setFocusedItemIndex] = useState<number>(0);
const focusItemWithIndex = useCallback(
(index: number) => {
setFocusedItemIndex(index);
listItems?.[index]?.focus();
},
[listItems]
);
useEffect(() => {
if (container.current) {
container.current.tabIndex = FOCUSABLE_BUT_NOT_TABBABLE;
setListItems(container.current.querySelectorAll('button'));
}
}, [container]);
const keyDownHandler = useCallback(
(e: KeyboardEvent) => {
if (e.key === KeyboardKey.Up || e.key === KeyboardKey.Down) {
e.preventDefault();
} else {
return;
}
if (!listItems?.length) {
setListItems(container.current?.querySelectorAll('button'));
}
if (listItems) {
if (e.key === KeyboardKey.Up) {
let previousIndex = focusedItemIndex - 1;
if (previousIndex < 0) {
previousIndex = listItems.length - 1;
}
focusItemWithIndex(previousIndex);
}
if (e.key === KeyboardKey.Down) {
let nextIndex = focusedItemIndex + 1;
if (nextIndex > listItems.length - 1) {
nextIndex = 0;
}
focusItemWithIndex(nextIndex);
}
}
},
[container, focusItemWithIndex, focusedItemIndex, listItems]
);
const FIRST_ITEM_FOCUS_TIMEOUT = 20;
const containerFocusHandler = useCallback(() => {
if (listItems) {
const selectedItemIndex = Array.from(listItems).findIndex(
(item) => item.dataset.selected
);
const indexToFocus = selectedItemIndex > -1 ? selectedItemIndex : 0;
setTimeout(() => {
focusItemWithIndex(indexToFocus);
}, FIRST_ITEM_FOCUS_TIMEOUT);
}
}, [focusItemWithIndex, listItems]);
useEffect(() => {
const containerElement = container.current;
containerElement?.addEventListener('focus', containerFocusHandler);
containerElement?.addEventListener('keydown', keyDownHandler);
return () => {
containerElement?.removeEventListener('focus', containerFocusHandler);
containerElement?.removeEventListener('keydown', keyDownHandler);
};
}, [container, containerFocusHandler, keyDownHandler]);
};

View File

@@ -32,6 +32,7 @@ export class NotesState {
contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 };
contextMenuMaxHeight: number | 'auto' = 'auto';
showProtectedWarning = false;
showRevisionHistoryModal = false;
constructor(
private application: WebApplication,
@@ -44,6 +45,7 @@ export class NotesState {
contextMenuOpen: observable,
contextMenuPosition: observable,
showProtectedWarning: observable,
showRevisionHistoryModal: observable,
selectedNotesCount: computed,
trashedNotesCount: computed,
@@ -53,6 +55,7 @@ export class NotesState {
setContextMenuPosition: action,
setContextMenuMaxHeight: action,
setShowProtectedWarning: action,
setShowRevisionHistoryModal: action,
unselectNotes: action,
});
@@ -457,4 +460,8 @@ export class NotesState {
private get io() {
return this.application.io;
}
setShowRevisionHistoryModal(show: boolean): void {
this.showRevisionHistoryModal = show;
}
}

View File

@@ -10,4 +10,7 @@ export const MAX_MENU_SIZE_MULTIPLIER = 30;
export const FOCUSABLE_BUT_NOT_TABBABLE = -1;
export const NOTES_LIST_SCROLL_THRESHOLD = 200;
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24;
export const DAYS_IN_A_WEEK = 7;
export const DAYS_IN_A_YEAR = 365;
export const BYTES_IN_ONE_MEGABYTE = 1000000;

View File

@@ -1,8 +1,5 @@
/* Components and utilities that are good candidates for extraction to StyleKit. */
:root {
}
.bg-grey-super-light {
background-color: var(--sn-stylekit-grey-super-light);
}
@@ -11,6 +8,10 @@
height: 90vh;
}
.h-3 {
height: 0.75rem;
}
.h-26 {
width: 6.5rem;
}
@@ -260,6 +261,10 @@
margin-left: 1rem;
}
.mr-2\.5 {
margin-right: 0.625rem;
}
.mr-3 {
margin-right: 0.75rem;
}
@@ -291,10 +296,18 @@
margin-top: 0;
}
.mt-2\.5 {
margin-top: 0.625rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.max-w-40\% {
max-width: 40%;
}
.max-w-1\/2 {
max-width: 50%;
}
@@ -326,6 +339,10 @@
margin-bottom: 2rem;
}
.w-3 {
width: 0.75rem;
}
.w-26 {
width: 6.5rem;
}
@@ -382,6 +399,10 @@
min-width: 7.5rem;
}
.min-w-60 {
min-width: 15rem;
}
.min-w-68 {
min-width: 17rem;
}
@@ -426,6 +447,14 @@
border-color: var(--sn-stylekit-neutral-contrast-color);
}
.sn-component .border-r-1px {
border-right-width: 1px;
}
.sn-component .border-t-1px {
border-top-width: 1px;
}
.border-2 {
border-width: 0.5rem;
}
@@ -474,6 +503,10 @@
padding-top: 1.5rem;
}
.pb-0 {
padding-bottom: 0;
}
.sn-component .pb-1 {
padding-bottom: 0.25rem;
}
@@ -522,6 +555,11 @@
padding-bottom: 0.125rem;
}
.sn-component .py-1\.35 {
padding-top: 0.3375rem;
padding-bottom: 0.3375rem;
}
.sn-component .py-2\.5 {
padding-top: 0.625rem;
padding-bottom: 0.625rem;
@@ -653,6 +691,10 @@
bottom: -1.25rem;
}
.z-index-1 {
z-index: 1;
}
.-z-index-1 {
z-index: -1;
}
@@ -755,6 +797,10 @@
color: var(--sn-stylekit-foreground-color) !important;
}
.sn-component .bg-info-backdrop {
background-color: var(--sn-stylekit-info-backdrop-color);
}
.focus\:bg-info-backdrop:focus {
background-color: var(--sn-stylekit-info-backdrop-color);
}
@@ -867,14 +913,31 @@
pointer-events: none;
}
.hide-if-last-child:last-child {
.flex-shrink-0 {
flex-shrink: 0;
}
.last\:hidden:last-child {
display: none;
}
.shadow-bottom {
box-shadow: currentcolor 0px -1px 0px 0px inset, currentcolor 0px 1px 0px 0px;
}
.focus\:shadow-inner:focus {
box-shadow: var(--sn-stylekit-info-color) 1px 1px 0px 0px inset,
var(--sn-stylekit-info-color) -1px -1px 0px 0px inset;
}
.bg-note-size-warning {
background-color: rgba(235, 173, 0, 0.08);
}
.overflow-x-hidden {
overflow-x: hidden;
}
.sn-component .border-y-1px {
border-top-width: 1px;
border-bottom-width: 1px;

View File

@@ -0,0 +1,26 @@
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="60" cy="60" r="60" fill="#F4F5F7" />
<g filter="url(#filter0_d_39_37)">
<path
d="M78.75 45.3333C80.4076 45.3333 81.9973 45.9755 83.1694 47.1186C84.3415 48.2617 85 49.812 85 51.4286V81.9048C85 83.5213 84.3415 85.0717 83.1694 86.2147C81.9973 87.3578 80.4076 88 78.75 88H41.25C39.5924 88 38.0027 87.3578 36.8306 86.2147C35.6585 85.0717 35 83.5213 35 81.9048V51.4286C35 48.0457 37.8125 45.3333 41.25 45.3333H44.375V39.2381C44.375 35.1967 46.0212 31.3208 48.9515 28.4631C51.8817 25.6054 55.856 24 60 24C62.0519 24 64.0837 24.3941 65.9794 25.1599C67.8751 25.9257 69.5976 27.0481 71.0485 28.4631C72.4995 29.8781 73.6504 31.558 74.4356 33.4067C75.2208 35.2555 75.625 37.237 75.625 39.2381V45.3333H78.75ZM60 30.0952C57.5136 30.0952 55.129 31.0585 53.3709 32.7731C51.6127 34.4877 50.625 36.8133 50.625 39.2381V45.3333H69.375V39.2381C69.375 36.8133 68.3873 34.4877 66.6291 32.7731C64.871 31.0585 62.4864 30.0952 60 30.0952Z"
fill="white" />
</g>
<path d="M61.0714 61.3889H59.1428V67.7778L64.6457 71.0234L65.5714 69.4772L61.0714 66.8195V61.3889Z" fill="#BBBEC4"
stroke="#BBBEC4" stroke-width="2" />
<path
d="M60.8571 52C57.1062 52 53.5089 53.5277 50.8566 56.247C48.2043 58.9662 46.7143 62.6544 46.7143 66.5H42L48.2229 72.9928L54.5714 66.5H49.8571C49.8571 63.5089 51.0161 60.6404 53.079 58.5254C55.1419 56.4104 57.9398 55.2222 60.8571 55.2222C63.7745 55.2222 66.5724 56.4104 68.6353 58.5254C70.6982 60.6404 71.8571 63.5089 71.8571 66.5C71.8571 69.4911 70.6982 72.3596 68.6353 74.4746C66.5724 76.5896 63.7745 77.7778 60.8571 77.7778C57.8243 77.7778 55.0743 76.505 53.0943 74.4589L50.8629 76.7467C53.4243 79.3889 56.9286 81 60.8571 81C64.6081 81 68.2054 79.4723 70.8577 76.753C73.51 74.0338 75 70.3456 75 66.5C75 62.6544 73.51 58.9662 70.8577 56.247C68.2054 53.5277 64.6081 52 60.8571 52Z"
fill="#BBBEC4" stroke="#BBBEC4" />
<defs>
<filter id="filter0_d_39_37" x="23" y="16" width="74" height="88" filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha" />
<feOffset dy="4" />
<feGaussianBlur stdDeviation="6" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.16 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_39_37" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_39_37" result="shape" />
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -63,7 +63,7 @@
"pug-loader": "^2.4.0",
"sass-loader": "^12.2.0",
"serve-static": "^1.14.1",
"@standardnotes/stylekit": "5.5.0",
"@standardnotes/stylekit": "5.6.0",
"svg-jest": "^1.0.1",
"ts-jest": "^27.0.7",
"ts-loader": "^9.2.6",
@@ -83,8 +83,8 @@
"@reach/listbox": "^0.16.2",
"@reach/tooltip": "^0.16.2",
"@standardnotes/components": "1.7.2",
"@standardnotes/features": "1.32.2",
"@standardnotes/snjs": "2.59.6",
"@standardnotes/features": "1.32.3",
"@standardnotes/snjs": "2.59.7",
"@standardnotes/settings": "^1.11.3",
"@standardnotes/sncrypto-web": "1.6.2",
"mobx": "^6.3.5",

View File

@@ -2822,39 +2822,39 @@
dependencies:
"@sinonjs/commons" "^1.7.0"
"@standardnotes/auth@^3.16.0":
version "3.16.0"
resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.16.0.tgz#6a69c1dc4fdfb4e98920c777e6eff892087d2397"
integrity sha512-0JDM6u3SDb2EkDNf+HN04HvXvkVe9gCKmWIeak+2vyfyS6nsbNBQf/uxAu7Jz3Jugi3Q7j57dhHeOSU21bFR3A==
"@standardnotes/auth@^3.16.1":
version "3.16.1"
resolved "https://registry.yarnpkg.com/@standardnotes/auth/-/auth-3.16.1.tgz#c73b3c70dfde1905998a6a27d91241ff5c7926dc"
integrity sha512-MSYfb80AVeERrZPiN15XG9e/ECv6UrVJ0R5h9jVIEJzxPKfmpVJVr2wH2ts3B6etjD3VYtUS7UNBXC/fYI4kuw==
dependencies:
"@standardnotes/common" "^1.10.0"
"@standardnotes/common" "^1.11.0"
jsonwebtoken "^8.5.1"
"@standardnotes/common@^1.10.0":
version "1.10.0"
resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.10.0.tgz#526b40b473cfbd8bb7291eb895ae174879e21047"
integrity sha512-7k8UZbA5d6BODjQYqV/i6xbkO6qEJULFLuLYVAS+Ery5CPekmUoEoh+UuA+iEITj2RNW6BV6sa/13jmHG5ADtA==
"@standardnotes/common@^1.11.0":
version "1.11.0"
resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.11.0.tgz#5ed4be817a1f448e6eeb700d141dbfd40193aabe"
integrity sha512-8TKx7bCwIazhGD3wkWTV4rmwbERsyisPbVDn6UIm1lNktWjKDF5OL1D8omalpR5wdM5qmXX5njI1zll2cxlW7A==
"@standardnotes/components@1.7.2":
version "1.7.2"
resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.7.2.tgz#33c2748955e1ebf2cd7af3b2bc9f60a0bc53d0c6"
integrity sha512-ckCC5Ez/Ni6PZZenCBrNC1sELxir5F3Q0UVm8nDWSQmf9G8+JGQ44BO0lEoZW8X3OPZpdYSde5Z5ITyOOBTXrQ==
"@standardnotes/domain-events@^2.23.3":
version "2.23.3"
resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.23.3.tgz#5fe89f95eaf68bcbaf636a2ace027dacdc03f82a"
integrity sha512-ZDgRUeu3wxbAiLWEq9W9LV+wt2OpKxvpqz7Lr1t9L1gMaBOatHKVNcdLjNs+PD9WgfANVRKswW3veKyY2j+QFQ==
"@standardnotes/domain-events@^2.23.4":
version "2.23.4"
resolved "https://registry.yarnpkg.com/@standardnotes/domain-events/-/domain-events-2.23.4.tgz#ab78ac57b35b723cd2184900466f4933003f057f"
integrity sha512-SvJzF6KugYruwPKGqEnB8hiBco+NqwGpqOZWiz03I8YozDeXoDhhRaKhBzCzXCRetLXf2YENppjPft+PkxkWVQ==
dependencies:
"@standardnotes/auth" "^3.16.0"
"@standardnotes/features" "^1.32.2"
"@standardnotes/auth" "^3.16.1"
"@standardnotes/features" "^1.32.3"
"@standardnotes/features@1.32.2", "@standardnotes/features@^1.32.2":
version "1.32.2"
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.32.2.tgz#e8f63a72e1cc0d5ee38015947be8af8c9eebffc4"
integrity sha512-dFtfQy18z73O0wt9/ih6g16HxWRS4C2VPNEZ1e+rypP/FKmyNjZo0NIAqfOt3ImEqr1eaJZYxT2iLkOs19I31A==
"@standardnotes/features@1.32.3", "@standardnotes/features@^1.32.3":
version "1.32.3"
resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.32.3.tgz#497d4c87f76293f0fabcaf20844f4bce048d5b47"
integrity sha512-QlHCtfKEtWTd2X6n28aGPg8xw/hHKTOzdo4nBECclO3GdE31WdZ6RpVstH8k515z0s2Q3RORXRvl7Ncn24Zd8g==
dependencies:
"@standardnotes/auth" "^3.16.0"
"@standardnotes/common" "^1.10.0"
"@standardnotes/auth" "^3.16.1"
"@standardnotes/common" "^1.11.0"
"@standardnotes/settings@^1.11.3":
version "1.11.3"
@@ -2875,22 +2875,22 @@
buffer "^6.0.3"
libsodium-wrappers "^0.7.9"
"@standardnotes/snjs@2.59.6":
version "2.59.6"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.59.6.tgz#21786e3a26445205de25f4988c53c29513a7b41b"
integrity sha512-CBen9+gzvDCCjq8kYuDDPdwJ16RhPBdce+LqVsTNcehhsRsioH2UYOgDR9KFIYmz2SxTbuziW8Ouu9oBtsX/Tg==
"@standardnotes/snjs@2.59.7":
version "2.59.7"
resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.59.7.tgz#bf0949ee1b29909aeb6db198d27d556456cc3e58"
integrity sha512-OBOaOzgnyilpBWuehbwF/6aiuxiuvBYaqaZPj1Xm2GdpneTCRE7elPtWB4UGAQV0xyK5d1b1ze7wnhC2xOVu4A==
dependencies:
"@standardnotes/auth" "^3.16.0"
"@standardnotes/common" "^1.10.0"
"@standardnotes/domain-events" "^2.23.3"
"@standardnotes/features" "^1.32.2"
"@standardnotes/auth" "^3.16.1"
"@standardnotes/common" "^1.11.0"
"@standardnotes/domain-events" "^2.23.4"
"@standardnotes/features" "^1.32.3"
"@standardnotes/settings" "^1.11.3"
"@standardnotes/sncrypto-common" "^1.6.0"
"@standardnotes/stylekit@5.5.0":
version "5.5.0"
resolved "https://registry.yarnpkg.com/@standardnotes/stylekit/-/stylekit-5.5.0.tgz#24d2aef1529bbb09406f95acdd55ba138cf14958"
integrity sha512-feAdjOu0tBfpRJh5pH+urji40Xu72tJ2VRWxzkxXX1SYHMbgXnjBNs5R4IWko2kMIQwNGuBOvEg87S5w2/fdhw==
"@standardnotes/stylekit@5.6.0":
version "5.6.0"
resolved "https://registry.yarnpkg.com/@standardnotes/stylekit/-/stylekit-5.6.0.tgz#52ff63e9bfc823817832786d54e21e8522f061ac"
integrity sha512-F6PxkVSKJBZMTYkGT96hVPDvcmpvevAC36KwMnaMtnjMQGfOjMLYJRgef5bJunvOeqo2bvoPX5xcd4EwacTBBw==
dependencies:
"@reach/listbox" "^0.15.0"
"@reach/menu-button" "^0.15.1"