feat: new revision history UI (#861)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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} />
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
112
app/assets/javascripts/components/RevisionHistoryModal/utils.ts
Normal file
112
app/assets/javascripts/components/RevisionHistoryModal/utils.ts
Normal 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();
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
26
app/assets/svg/il-history-locked.svg
Normal file
26
app/assets/svg/il-history-locked.svg
Normal 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 |
@@ -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",
|
||||
|
||||
66
yarn.lock
66
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user