feat: multiple selected notes panel
This commit is contained in:
192
app/assets/javascripts/components/MultipleSelectedNotes.tsx
Normal file
192
app/assets/javascripts/components/MultipleSelectedNotes.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import MoreIcon from '../../icons/ic-more.svg';
|
||||
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
|
||||
import RichTextIcon from '../../icons/ic-text-rich.svg';
|
||||
import TrashIcon from '../../icons/ic-trash.svg';
|
||||
import PinIcon from '../../icons/ic-pin.svg';
|
||||
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
||||
import ArchiveIcon from '../../icons/ic-archive.svg';
|
||||
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
||||
import NotesIcon from '../../icons/il-notes.svg';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { Switch } from './Switch';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { SNApplication } from '@standardnotes/snjs';
|
||||
|
||||
type Props = {
|
||||
application: SNApplication;
|
||||
appState: AppState;
|
||||
};
|
||||
|
||||
const MultipleSelectedNotes = observer(({ appState }: Props) => {
|
||||
const count = appState.notes.selectedNotesCount;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [optionsPanelPosition, setOptionsPanelPosition] = useState({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
const buttonRef = useRef<HTMLButtonElement>();
|
||||
const panelRef = useRef<HTMLDivElement>();
|
||||
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
|
||||
|
||||
const notes = Object.values(appState.notes.selectedNotes);
|
||||
const hidePreviews = !notes.some((note) => !note.hidePreview);
|
||||
const locked = !notes.some((note) => !note.locked);
|
||||
const archived = !notes.some((note) => !note.archived);
|
||||
const trashed = !notes.some((note) => !note.trashed);
|
||||
const pinned = !notes.some((note) => !note.pinned);
|
||||
|
||||
const iconClass = 'fill-current color-neutral mr-2.5';
|
||||
const buttonClass =
|
||||
'flex items-center border-0 capitalize focus:inner-ring-info ' +
|
||||
'cursor-pointer hover:bg-contrast color-text bg-transparent h-10 px-3 ' +
|
||||
'text-left';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full items-center">
|
||||
<div className="flex items-center justify-between p-4 w-full">
|
||||
<h1 className="text-3xl m-0">{count} selected notes</h1>
|
||||
<Disclosure
|
||||
open={open}
|
||||
onChange={() => {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setOptionsPanelPosition({
|
||||
top: rect.bottom,
|
||||
right: document.body.clientWidth - rect.right,
|
||||
});
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
}}
|
||||
>
|
||||
<DisclosureButton
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={buttonRef}
|
||||
className={
|
||||
'bg-transparent border-solid border-1 border-gray-300 ' +
|
||||
'cursor-pointer w-32px h-32px rounded-full p-0 ' +
|
||||
'flex justify-center items-center'
|
||||
}
|
||||
>
|
||||
<VisuallyHidden>Actions</VisuallyHidden>
|
||||
<MoreIcon className="fill-current block" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
buttonRef.current.focus();
|
||||
}
|
||||
}}
|
||||
ref={panelRef}
|
||||
style={{
|
||||
...optionsPanelPosition,
|
||||
}}
|
||||
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 select-none"
|
||||
>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="h-10"
|
||||
checked={locked}
|
||||
onChange={() => {
|
||||
appState.notes.setLockSelectedNotes(!locked);
|
||||
}}
|
||||
>
|
||||
<span className="capitalize flex items-center">
|
||||
<PencilOffIcon className={iconClass} />
|
||||
Prevent editing
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="h-10"
|
||||
checked={!hidePreviews}
|
||||
onChange={() => {
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
|
||||
}}
|
||||
>
|
||||
<span className="capitalize flex items-center">
|
||||
<RichTextIcon className={iconClass} />
|
||||
Show Preview
|
||||
</span>
|
||||
</Switch>
|
||||
<div className="h-1px my-2.5 bg-secondary-contrast"></div>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(!pinned);
|
||||
}}
|
||||
>
|
||||
{pinned ? (
|
||||
<>
|
||||
<UnpinIcon className={iconClass} />
|
||||
Unpin notes
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PinIcon className={iconClass} />
|
||||
Pin notes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(!archived);
|
||||
}}
|
||||
>
|
||||
{archived ? (
|
||||
<>
|
||||
<UnarchiveIcon className={iconClass} />
|
||||
Unarchive
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className={iconClass} />
|
||||
Archive
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className={buttonClass}
|
||||
onClick={async () => {
|
||||
setLockCloseOnBlur(true);
|
||||
await appState.notes.setTrashSelectedNotes(!trashed);
|
||||
setLockCloseOnBlur(false);
|
||||
}}
|
||||
>
|
||||
<TrashIcon className={iconClass} />
|
||||
{trashed ? 'Restore' : 'Move to trash'}
|
||||
</button>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||
<NotesIcon className="block" />
|
||||
<h2 className="text-2xl m-0 text-center mt-4">
|
||||
{count} selected notes
|
||||
</h2>
|
||||
<p className="text-lg mt-2 text-center">
|
||||
Actions will be performed on all selected notes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const MultipleSelectedNotesDirective = toDirective<Props>(
|
||||
MultipleSelectedNotes
|
||||
);
|
||||
@@ -1,13 +1,12 @@
|
||||
import { toDirective, useAutorunValue } from './utils';
|
||||
import Close from '../../icons/ic_close.svg';
|
||||
import { toDirective } from './utils';
|
||||
import Close from '../../icons/ic-close.svg';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = { appState: AppState };
|
||||
|
||||
function NoAccountWarning({ appState }: Props) {
|
||||
const canShow = useAutorunValue(() => appState.noAccountWarning.show, [
|
||||
appState,
|
||||
]);
|
||||
const NoAccountWarning = observer(({ appState }: Props) => {
|
||||
const canShow = appState.noAccountWarning.show;
|
||||
if (!canShow) {
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +38,6 @@ function NoAccountWarning({ appState }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const NoAccountWarningDirective = toDirective<Props>(NoAccountWarning);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import { toDirective, useAutorunValue } from './utils';
|
||||
import { toDirective, useCloseOnBlur } from './utils';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import VisuallyHidden from '@reach/visually-hidden';
|
||||
@@ -10,54 +10,35 @@ import {
|
||||
} from '@reach/disclosure';
|
||||
import { FocusEvent } from 'react';
|
||||
import { Switch } from './Switch';
|
||||
import TuneIcon from '../../icons/ic_tune.svg';
|
||||
import TuneIcon from '../../icons/ic-tune.svg';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Props = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
};
|
||||
|
||||
function SearchOptions({ appState }: Props) {
|
||||
const SearchOptions = observer(({ appState }: Props) => {
|
||||
const { searchOptions } = appState;
|
||||
|
||||
const {
|
||||
includeProtectedContents,
|
||||
includeArchived,
|
||||
includeTrashed,
|
||||
} = useAutorunValue(
|
||||
() => ({
|
||||
includeProtectedContents: searchOptions.includeProtectedContents,
|
||||
includeArchived: searchOptions.includeArchived,
|
||||
includeTrashed: searchOptions.includeTrashed,
|
||||
}),
|
||||
[searchOptions]
|
||||
);
|
||||
|
||||
const [
|
||||
togglingIncludeProtectedContents,
|
||||
setTogglingIncludeProtectedContents,
|
||||
] = useState(false);
|
||||
|
||||
async function toggleIncludeProtectedContents() {
|
||||
setTogglingIncludeProtectedContents(true);
|
||||
try {
|
||||
await searchOptions.toggleIncludeProtectedContents();
|
||||
} finally {
|
||||
setTogglingIncludeProtectedContents(false);
|
||||
}
|
||||
}
|
||||
} = searchOptions;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [optionsPanelTop, setOptionsPanelTop] = useState(0);
|
||||
const buttonRef = useRef<HTMLButtonElement>();
|
||||
const panelRef = useRef<HTMLDivElement>();
|
||||
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
|
||||
|
||||
function closeOnBlur(event: FocusEvent<HTMLElement>) {
|
||||
if (
|
||||
!togglingIncludeProtectedContents &&
|
||||
!panelRef.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
async function toggleIncludeProtectedContents() {
|
||||
setLockCloseOnBlur(true);
|
||||
try {
|
||||
await searchOptions.toggleIncludeProtectedContents();
|
||||
} finally {
|
||||
setLockCloseOnBlur(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +67,7 @@ function SearchOptions({ appState }: Props) {
|
||||
className="sn-dropdown sn-dropdown-anchor-right grid gap-2 py-2"
|
||||
>
|
||||
<Switch
|
||||
className="h-10"
|
||||
checked={includeProtectedContents}
|
||||
onChange={toggleIncludeProtectedContents}
|
||||
onBlur={closeOnBlur}
|
||||
@@ -93,6 +75,7 @@ function SearchOptions({ appState }: Props) {
|
||||
<p className="capitalize">Include protected contents</p>
|
||||
</Switch>
|
||||
<Switch
|
||||
className="h-10"
|
||||
checked={includeArchived}
|
||||
onChange={searchOptions.toggleIncludeArchived}
|
||||
onBlur={closeOnBlur}
|
||||
@@ -100,6 +83,7 @@ function SearchOptions({ appState }: Props) {
|
||||
<p className="capitalize">Include archived notes</p>
|
||||
</Switch>
|
||||
<Switch
|
||||
className="h-10"
|
||||
checked={includeTrashed}
|
||||
onChange={searchOptions.toggleIncludeTrashed}
|
||||
onBlur={closeOnBlur}
|
||||
@@ -109,6 +93,6 @@ function SearchOptions({ appState }: Props) {
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const SearchOptionsDirective = toDirective<Props>(SearchOptions);
|
||||
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogLabel,
|
||||
} from '@reach/alert-dialog';
|
||||
import { toDirective, useAutorunValue } from './utils';
|
||||
import { toDirective } from './utils';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
type Session = RemoteSession & {
|
||||
revoking?: true;
|
||||
@@ -242,16 +243,12 @@ const SessionsModal: FunctionComponent<{
|
||||
const Sessions: FunctionComponent<{
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
}> = ({ appState, application }) => {
|
||||
const showModal = useAutorunValue(() => appState.isSessionsModalVisible, [
|
||||
appState,
|
||||
]);
|
||||
|
||||
if (showModal) {
|
||||
}> = observer(({ appState, application }) => {
|
||||
if (appState.isSessionsModalVisible) {
|
||||
return <SessionsModal application={application} appState={appState} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
export const SessionsModalDirective = toDirective(Sessions);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '@reach/checkbox/styles.css';
|
||||
export type SwitchProps = HTMLProps<HTMLInputElement> & {
|
||||
checked?: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
children: ComponentChildren;
|
||||
};
|
||||
|
||||
@@ -19,8 +20,9 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
) => {
|
||||
const [checkedState, setChecked] = useState(props.checked || false);
|
||||
const checked = props.checked ?? checkedState;
|
||||
const className = props.className ?? '';
|
||||
return (
|
||||
<label className="sn-component flex justify-between items-center cursor-pointer hover:bg-contrast py-2 px-3">
|
||||
<label className={`sn-component flex justify-between items-center cursor-pointer hover:bg-contrast px-3 ${className}`}>
|
||||
{props.children}
|
||||
<CustomCheckboxContainer
|
||||
checked={checked}
|
||||
@@ -33,6 +35,7 @@ export const Switch: FunctionalComponent<SwitchProps> = (
|
||||
<CustomCheckboxInput
|
||||
{...({
|
||||
...props,
|
||||
className: undefined,
|
||||
children: undefined,
|
||||
} as CustomCheckboxInputProps)}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { autorun } from 'mobx';
|
||||
import { FunctionComponent, h, render } from 'preact';
|
||||
import { Inputs, useEffect, useState } from 'preact/hooks';
|
||||
import { StateUpdater, useCallback, useState } from 'preact/hooks';
|
||||
import { FocusEvent, EventHandler, FocusEventHandler } from 'react';
|
||||
|
||||
export function useAutorunValue<T>(query: () => T, inputs: Inputs): T {
|
||||
const [value, setValue] = useState(query);
|
||||
useEffect(() => {
|
||||
return autorun(() => {
|
||||
setValue(query());
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, inputs);
|
||||
return value;
|
||||
/**
|
||||
* @returns a callback that will close a dropdown if none of its children has
|
||||
* focus. Must be set as the onBlur callback of children that need to be
|
||||
* monitored.
|
||||
*/
|
||||
export function useCloseOnBlur(
|
||||
container: { current: HTMLDivElement },
|
||||
setOpen: (open: boolean) => void
|
||||
): [(event: { relatedTarget: EventTarget | null }) => void, StateUpdater<boolean>] {
|
||||
const [locked, setLocked] = useState(false);
|
||||
return [
|
||||
useCallback(
|
||||
function onBlur(event: { relatedTarget: EventTarget | null }) {
|
||||
if (
|
||||
!locked &&
|
||||
!container.current.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
},
|
||||
[container, setOpen, locked]
|
||||
),
|
||||
setLocked,
|
||||
];
|
||||
}
|
||||
|
||||
export function toDirective<Props>(
|
||||
|
||||
Reference in New Issue
Block a user