diff --git a/app/assets/icons/ic-archive.svg b/app/assets/icons/ic-archive.svg
new file mode 100644
index 000000000..bc703480c
--- /dev/null
+++ b/app/assets/icons/ic-archive.svg
@@ -0,0 +1,4 @@
+
diff --git a/app/assets/icons/ic-chevron-right.svg b/app/assets/icons/ic-chevron-right.svg
new file mode 100644
index 000000000..6d39ca8a9
--- /dev/null
+++ b/app/assets/icons/ic-chevron-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/icons/ic_close.svg b/app/assets/icons/ic-close.svg
similarity index 85%
rename from app/assets/icons/ic_close.svg
rename to app/assets/icons/ic-close.svg
index 8d3b13d49..cd22c9a5c 100644
--- a/app/assets/icons/ic_close.svg
+++ b/app/assets/icons/ic-close.svg
@@ -1,3 +1,3 @@
-
-
diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx
new file mode 100644
index 000000000..3058c035f
--- /dev/null
+++ b/app/assets/javascripts/components/NotesContextMenu.tsx
@@ -0,0 +1,45 @@
+import { AppState } from '@/ui_models/app_state';
+import { toDirective, useCloseOnBlur } from './utils';
+import { observer } from 'mobx-react-lite';
+import { NotesOptions } from './NotesOptions';
+import { useCallback, useEffect, useRef } from 'preact/hooks';
+
+type Props = {
+ appState: AppState;
+};
+
+const NotesContextMenu = observer(({ appState }: Props) => {
+ const contextMenuRef = useRef();
+ const [closeOnBlur] = useCloseOnBlur(
+ contextMenuRef,
+ (open: boolean) => appState.notes.setContextMenuOpen(open)
+ );
+
+ const closeOnClickOutside = useCallback((event: MouseEvent) => {
+ if (!contextMenuRef.current?.contains(event.target as Node)) {
+ appState.notes.setContextMenuOpen(false);
+ }
+ }, [appState]);
+
+ useEffect(() => {
+ document.addEventListener('click', closeOnClickOutside);
+ return () => {
+ document.removeEventListener('click', closeOnClickOutside);
+ };
+ }, [closeOnClickOutside]);
+
+ return appState.notes.contextMenuOpen ? (
+
+
+
+ ) : null;
+});
+
+export const NotesContextMenuDirective = toDirective(NotesContextMenu);
diff --git a/app/assets/javascripts/components/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions.tsx
new file mode 100644
index 000000000..5bca358e1
--- /dev/null
+++ b/app/assets/javascripts/components/NotesOptions.tsx
@@ -0,0 +1,298 @@
+import { AppState } from '@/ui_models/app_state';
+import { Icon } from './Icon';
+import { Switch } from './Switch';
+import { observer } from 'mobx-react-lite';
+import { useRef, useState, useEffect } from 'preact/hooks';
+import {
+ Disclosure,
+ DisclosureButton,
+ DisclosurePanel,
+} from '@reach/disclosure';
+import { SNNote } from '@standardnotes/snjs/dist/@types';
+
+type Props = {
+ appState: AppState;
+ closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
+ onSubmenuChange?: (submenuOpen: boolean) => void;
+};
+
+export const NotesOptions = observer(
+ ({ appState, closeOnBlur, onSubmenuChange }: Props) => {
+ const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
+ const [tagsMenuPosition, setTagsMenuPosition] = useState({
+ top: 0,
+ right: 0,
+ });
+ const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState('auto');
+
+ const toggleOn = (condition: (note: SNNote) => boolean) => {
+ const notesMatchingAttribute = notes.filter(condition);
+ const notesNotMatchingAttribute = notes.filter(
+ (note) => !condition(note)
+ );
+ return notesMatchingAttribute.length > notesNotMatchingAttribute.length;
+ };
+
+ const notes = Object.values(appState.notes.selectedNotes);
+ const hidePreviews = toggleOn((note) => note.hidePreview);
+ const locked = toggleOn((note) => note.locked);
+ const protect = toggleOn((note) => note.protected);
+ const archived = notes.some((note) => note.archived);
+ const unarchived = notes.some((note) => !note.archived);
+ const trashed = notes.some((note) => note.trashed);
+ const notTrashed = notes.some((note) => !note.trashed);
+ const pinned = notes.some((note) => note.pinned);
+ const unpinned = notes.some((note) => !note.pinned);
+
+ const tagsButtonRef = useRef();
+
+ const iconClass = 'color-neutral mr-2';
+ const buttonClass =
+ 'flex items-center border-0 focus:inner-ring-info ' +
+ 'cursor-pointer hover:bg-contrast color-text bg-transparent px-3 ' +
+ 'text-left';
+
+ useEffect(() => {
+ if (onSubmenuChange) {
+ onSubmenuChange(tagsMenuOpen);
+ }
+ }, [tagsMenuOpen, onSubmenuChange]);
+
+ const openTagsMenu = () => {
+ const defaultFontSize = window.getComputedStyle(
+ document.documentElement
+ ).fontSize;
+ const maxTagsMenuSize = parseFloat(defaultFontSize) * 20;
+ const { clientWidth, clientHeight } = document.body;
+ const buttonRect = tagsButtonRef.current.getBoundingClientRect();
+ const { offsetTop, offsetWidth } = tagsButtonRef.current;
+ const footerHeight = 32;
+
+ if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) {
+ setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2);
+ }
+
+ setTagsMenuPosition({
+ top: offsetTop,
+ right:
+ buttonRect.right + maxTagsMenuSize >
+ clientWidth
+ ? offsetWidth
+ : -offsetWidth,
+ });
+
+ setTagsMenuOpen(!tagsMenuOpen);
+ };
+
+ return (
+ <>
+ {
+ appState.notes.setLockSelectedNotes(!locked);
+ }}
+ >
+
+
+ Prevent editing
+
+
+ {
+ appState.notes.setHideSelectedNotePreviews(!hidePreviews);
+ }}
+ >
+
+
+ Show preview
+
+
+ {
+ appState.notes.setProtectSelectedNotes(!protect);
+ }}
+ >
+
+
+ Protect
+
+
+
+ {appState.tags.tagsCount > 0 && (
+
+ {
+ if (event.key === 'Escape') {
+ setTagsMenuOpen(false);
+ }
+ }}
+ onBlur={closeOnBlur}
+ ref={tagsButtonRef}
+ className={`${buttonClass} py-1.5 justify-between`}
+ >
+
+
+ {'Add tag'}
+
+
+
+ {
+ if (event.key === 'Escape') {
+ setTagsMenuOpen(false);
+ tagsButtonRef.current.focus();
+ }
+ }}
+ style={{
+ ...tagsMenuPosition,
+ maxHeight: tagsMenuMaxHeight,
+ }}
+ className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 max-h-80 overflow-y-scroll"
+ >
+ {appState.tags.tags.map((tag) => (
+ {
+ appState.notes.isTagInSelectedNotes(tag)
+ ? appState.notes.removeTagFromSelectedNotes(tag)
+ : appState.notes.addTagToSelectedNotes(tag);
+ }}
+ >
+
+ {tag.title}
+
+
+ ))}
+
+
+ )}
+ {unpinned && (
+ {
+ appState.notes.setPinSelectedNotes(true);
+ }}
+ >
+
+ Pin to top
+
+ )}
+ {pinned && (
+ {
+ appState.notes.setPinSelectedNotes(false);
+ }}
+ >
+
+ Unpin
+
+ )}
+ {unarchived && (
+ {
+ appState.notes.setArchiveSelectedNotes(true);
+ }}
+ >
+
+ Archive
+
+ )}
+ {archived && (
+ {
+ appState.notes.setArchiveSelectedNotes(false);
+ }}
+ >
+
+ Unarchive
+
+ )}
+ {notTrashed && (
+ {
+ await appState.notes.setTrashSelectedNotes(true);
+ }}
+ >
+
+ Move to Trash
+
+ )}
+ {trashed && (
+ <>
+ {
+ await appState.notes.setTrashSelectedNotes(false);
+ }}
+ >
+
+ Restore
+
+ {
+ await appState.notes.deleteNotesPermanently();
+ }}
+ >
+
+ Delete permanently
+
+ {
+ await appState.notes.emptyTrash();
+ }}
+ >
+
+
+
+
Empty Trash
+
+ {appState.notes.trashedNotesCount} notes in Trash
+
+
+
+
+ >
+ )}
+ >
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/NotesOptionsPanel.tsx b/app/assets/javascripts/components/NotesOptionsPanel.tsx
new file mode 100644
index 000000000..886eda79f
--- /dev/null
+++ b/app/assets/javascripts/components/NotesOptionsPanel.tsx
@@ -0,0 +1,88 @@
+import { AppState } from '@/ui_models/app_state';
+import { Icon } from './Icon';
+import VisuallyHidden from '@reach/visually-hidden';
+import { toDirective, useCloseOnBlur } from './utils';
+import {
+ Disclosure,
+ DisclosureButton,
+ DisclosurePanel,
+} from '@reach/disclosure';
+import { Portal } from '@reach/portal';
+import { useRef, useState } from 'preact/hooks';
+import { observer } from 'mobx-react-lite';
+import { NotesOptions } from './NotesOptions';
+
+type Props = {
+ appState: AppState;
+};
+
+export const NotesOptionsPanel = observer(({ appState }: Props) => {
+ const [open, setOpen] = useState(false);
+ const [position, setPosition] = useState({
+ top: 0,
+ right: 0,
+ });
+ const buttonRef = useRef();
+ const panelRef = useRef();
+ const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
+ const [submenuOpen, setSubmenuOpen] = useState(false);
+
+ const onSubmenuChange = (open: boolean) => {
+ setSubmenuOpen(open);
+ };
+
+ return (
+ {
+ const rect = buttonRef.current.getBoundingClientRect();
+ setPosition({
+ top: rect.bottom,
+ right: document.body.clientWidth - rect.right,
+ });
+ setOpen(!open);
+ }}
+ >
+ {
+ if (event.key === 'Escape' && !submenuOpen) {
+ setOpen(false);
+ }
+ }}
+ onBlur={closeOnBlur}
+ ref={buttonRef}
+ className="sn-icon-button"
+ >
+ Actions
+
+
+
+
+ {
+ if (event.key === 'Escape' && !submenuOpen) {
+ setOpen(false);
+ buttonRef.current.focus();
+ }
+ }}
+ ref={panelRef}
+ style={{
+ ...position,
+ }}
+ className="sn-dropdown flex flex-col py-2"
+ >
+ {open && (
+
+ )}
+
+
+
+
+ );
+});
+
+export const NotesOptionsPanelDirective = toDirective(NotesOptionsPanel);
diff --git a/app/assets/javascripts/components/SearchOptions.tsx b/app/assets/javascripts/components/SearchOptions.tsx
index 2c24e6c44..7edad0eb2 100644
--- a/app/assets/javascripts/components/SearchOptions.tsx
+++ b/app/assets/javascripts/components/SearchOptions.tsx
@@ -1,5 +1,6 @@
import { AppState } from '@/ui_models/app_state';
-import { toDirective, useAutorunValue } from './utils';
+import { Icon } from './Icon';
+import { toDirective, useCloseOnBlur } from './utils';
import { useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import VisuallyHidden from '@reach/visually-hidden';
@@ -8,56 +9,35 @@ import {
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
-import { FocusEvent } from 'react';
import { Switch } from './Switch';
-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();
const panelRef = useRef();
+ const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
- function closeOnBlur(event: FocusEvent) {
- if (
- !togglingIncludeProtectedContents &&
- !panelRef.current.contains(event.relatedTarget as Node)
- ) {
- setOpen(false);
+ async function toggleIncludeProtectedContents() {
+ setLockCloseOnBlur(true);
+ try {
+ await searchOptions.toggleIncludeProtectedContents();
+ } finally {
+ setLockCloseOnBlur(false);
}
}
@@ -73,10 +53,10 @@ function SearchOptions({ appState }: Props) {
Search options
-
+
Include protected contents
Include archived notes
);
-}
+});
export const SearchOptionsDirective = toDirective(SearchOptions);
diff --git a/app/assets/javascripts/components/SessionsModal.tsx b/app/assets/javascripts/components/SessionsModal.tsx
index b8c82daf4..6079e3aca 100644
--- a/app/assets/javascripts/components/SessionsModal.tsx
+++ b/app/assets/javascripts/components/SessionsModal.tsx
@@ -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;
@@ -171,7 +172,7 @@ const SessionsModal: FunctionComponent<{
{formatter.format(session.updated_at)}
setRevokingSessionUuid(session.uuid)
@@ -212,14 +213,14 @@ const SessionsModal: FunctionComponent<{
{SessionStrings.RevokeCancelButton}
{
closeRevokeSessionAlert();
revokeSession(confirmRevokingSessionUuid);
@@ -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 ;
} else {
return null;
}
-};
+});
export const SessionsModalDirective = toDirective(Sessions);
diff --git a/app/assets/javascripts/components/Switch.tsx b/app/assets/javascripts/components/Switch.tsx
index 44dbf7dc5..89bb8574c 100644
--- a/app/assets/javascripts/components/Switch.tsx
+++ b/app/assets/javascripts/components/Switch.tsx
@@ -11,6 +11,7 @@ import '@reach/checkbox/styles.css';
export type SwitchProps = HTMLProps & {
checked?: boolean;
onChange: (checked: boolean) => void;
+ className?: string;
children: ComponentChildren;
};
@@ -19,8 +20,9 @@ export const Switch: FunctionalComponent = (
) => {
const [checkedState, setChecked] = useState(props.checked || false);
const checked = props.checked ?? checkedState;
+ const className = props.className ?? '';
return (
-