feat: add right-click context menu

This commit is contained in:
Antonella Sgarlatta
2021-04-29 17:07:14 -03:00
parent a1d56abd2e
commit b70cc0e7e4
15 changed files with 382 additions and 214 deletions

View File

@@ -61,6 +61,8 @@ import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNo
import { SearchOptionsDirective } from './components/SearchOptions';
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
import { NotesContextMenuDirective } from './components/NotesContextMenu';
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
function reloadHiddenFirefoxTab(): boolean {
/**
@@ -151,6 +153,8 @@ const startApplication: StartApplication = async function startApplication(
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
.directive('searchOptions', SearchOptionsDirective)
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
.directive('notesContextMenu', NotesContextMenuDirective)
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
.directive('confirmSignout', ConfirmSignoutDirective);
// Filters

View File

@@ -1,185 +1,28 @@
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 { toDirective } from './utils';
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';
import { NotesOptionsPanel } from './NotesOptionsPanel';
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>
<NotesOptionsPanel appState={appState} />
</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">
<p className="text-lg mt-2 text-center max-w-80">
Actions will be performed on all selected notes.
</p>
</div>

View File

@@ -0,0 +1,40 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective, useCloseOnBlur } from './utils';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { useEffect, useRef } from 'preact/hooks';
type Props = {
appState: AppState;
};
const NotesContextMenu = observer(({ appState }: Props) => {
const contextMenuRef = useRef<HTMLDivElement>();
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(
contextMenuRef,
(open: boolean) => appState.notes.setContextMenuOpen(open)
);
useEffect(() => {
document.addEventListener('click', closeOnBlur);
return () => {
document.removeEventListener('click', closeOnBlur);
};
});
return appState.notes.contextMenuOpen ? (
<div
ref={contextMenuRef}
className="sn-dropdown max-w-80 flex flex-col"
style={{ position: 'absolute', ...appState.notes.contextMenuPosition }}
>
<NotesOptions
appState={appState}
closeOnBlur={closeOnBlur}
setLockCloseOnBlur={setLockCloseOnBlur}
/>
</div>
) : null;
});
export const NotesContextMenuDirective = toDirective<Props>(NotesContextMenu);

View File

@@ -0,0 +1,122 @@
import { AppState } from '@/ui_models/app_state';
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 { Switch } from './Switch';
import { observer } from 'mobx-react-lite';
import { useRef } from 'preact/hooks';
type Props = {
appState: AppState;
closeOnBlur: (event: {
relatedTarget: EventTarget | null;
target: EventTarget | null;
}) => void;
setLockCloseOnBlur: (lock: boolean) => void;
};
export const NotesOptions = observer(
({ appState, closeOnBlur, setLockCloseOnBlur }: Props) => {
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 trashButtonRef = useRef<HTMLButtonElement>();
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 (
<>
<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
ref={trashButtonRef}
onBlur={closeOnBlur}
className={buttonClass}
onClick={async () => {
setLockCloseOnBlur(true);
await appState.notes.setTrashSelectedNotes(!trashed, trashButtonRef);
setLockCloseOnBlur(false);
}}
>
<TrashIcon className={iconClass} />
{trashed ? 'Restore' : 'Move to trash'}
</button>
</>
);
}
);

View File

@@ -0,0 +1,80 @@
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 { 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<HTMLButtonElement>();
const panelRef = useRef<HTMLDivElement>();
const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen);
return (
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
setOpen(!open);
}}
>
<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={{
...position,
}}
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2"
>
<NotesOptions
appState={appState}
closeOnBlur={closeOnBlur}
setLockCloseOnBlur={setLockCloseOnBlur}
/>
</DisclosurePanel>
</Disclosure>
);
});
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);

View File

@@ -1,6 +1,5 @@
import { FunctionComponent, h, render } from 'preact';
import { StateUpdater, useCallback, useState } from 'preact/hooks';
import { FocusEvent, EventHandler, FocusEventHandler } from 'react';
/**
* @returns a callback that will close a dropdown if none of its children has
@@ -10,14 +9,24 @@ import { FocusEvent, EventHandler, FocusEventHandler } from 'react';
export function useCloseOnBlur(
container: { current: HTMLDivElement },
setOpen: (open: boolean) => void
): [(event: { relatedTarget: EventTarget | null }) => void, StateUpdater<boolean>] {
): [
(event: {
relatedTarget: EventTarget | null;
target: EventTarget | null;
}) => void,
StateUpdater<boolean>
] {
const [locked, setLocked] = useState(false);
return [
useCallback(
function onBlur(event: { relatedTarget: EventTarget | null }) {
function onBlur(event: {
relatedTarget: EventTarget | null;
target: EventTarget | null;
}) {
if (
!locked &&
!container.current.contains(event.relatedTarget as Node)
!container.current?.contains(event.relatedTarget as Node) &&
!container.current?.contains(event.target as Node)
) {
setOpen(false);
}

View File

@@ -14,11 +14,14 @@ import {
computed,
runInAction,
} from 'mobx';
import { RefObject } from 'preact';
import { WebApplication } from '../application';
import { Editor } from '../editor';
export class NotesState {
selectedNotes: Record<UuidString, SNNote> = {};
contextMenuOpen = false;
contextMenuPosition: { top: number, left: number } = { top: 0, left: 0 };
constructor(
private application: WebApplication,
@@ -27,12 +30,20 @@ export class NotesState {
) {
makeObservable(this, {
selectedNotes: observable,
contextMenuOpen: observable,
contextMenuPosition: observable,
selectedNotesCount: computed,
selectNote: action,
setArchiveSelectedNotes: action,
setContextMenuOpen: action,
setContextMenuPosition: action,
setHideSelectedNotePreviews: action,
setLockSelectedNotes: action,
setPinSelectedNotes: action,
setTrashSelectedNotes: action,
unselectNotes: action,
});
appEventListeners.push(
@@ -100,6 +111,14 @@ export class NotesState {
}
}
setContextMenuOpen(open: boolean): void {
this.contextMenuOpen = open;
}
setContextMenuPosition(position: { top: number, left: number }): void {
this.contextMenuPosition = position;
}
setHideSelectedNotePreviews(hide: boolean): void {
this.application.changeItems<NoteMutator>(
Object.keys(this.selectedNotes),
@@ -120,7 +139,7 @@ export class NotesState {
);
}
async setTrashSelectedNotes(trashed: boolean): Promise<void> {
async setTrashSelectedNotes(trashed: boolean, trashButtonRef: RefObject<HTMLButtonElement>): Promise<void> {
if (
await confirmDialog({
title: Strings.trashNotesTitle,
@@ -137,7 +156,10 @@ export class NotesState {
);
runInAction(() => {
this.selectedNotes = {};
this.contextMenuOpen = false;
});
} else {
trashButtonRef.current?.focus();
}
}
@@ -163,6 +185,10 @@ export class NotesState {
});
}
unselectNotes(): void {
this.selectedNotes = {};
}
private get io() {
return this.application.io;
}

View File

@@ -33,3 +33,6 @@
challenge="challenge"
on-dismiss="self.removeChallenge(challenge)"
)
notes-context-menu(
app-state='self.appState'
)

View File

@@ -19,44 +19,50 @@
.sk-label.warning
i.icon.ion-locked
| {{self.lockText}}
#editor-title-bar.section-title-bar(
#editor-title-bar.section-title-bar.flex.items-center.justify-between.w-full(
ng-class="{'locked' : self.noteLocked}",
ng-show='self.note && !self.note.errorDecrypting'
)
.title
input#note-title-editor.input(
ng-blur='self.onTitleBlur()',
ng-change='self.onTitleChange()',
ng-disabled='self.noteLocked',
ng-focus='self.onTitleFocus()',
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
ng-model='self.editorValues.title',
select-on-focus='true',
spellcheck='false'
)
#save-status
.message(
ng-class="{'warning sk-bold': self.state.syncTakingTooLong, 'danger sk-bold': self.state.saveError}"
) {{self.state.noteStatus.message}}
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
.editor-tags
#note-tags-component-container(ng-if='self.state.tagsComponent && !self.note.errorDecrypting')
component-view.component-view(
component-uuid='self.state.tagsComponent.uuid',
ng-class="{'locked' : self.noteLocked}",
ng-style="self.noteLocked && {'pointer-events' : 'none'}",
application='self.application'
div
.title
input#note-title-editor.input(
ng-blur='self.onTitleBlur()',
ng-change='self.onTitleChange()',
ng-disabled='self.noteLocked',
ng-focus='self.onTitleFocus()',
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
ng-model='self.editorValues.title',
select-on-focus='true',
spellcheck='false'
)
input.tags-input(
ng-blur='self.onTagsInputBlur()',
ng-disabled='self.noteLocked',
ng-if='!self.state.tagsComponent',
ng-keyup='$event.keyCode == 13 && $event.target.blur();',
ng-model='self.editorValues.tagsInputValue',
placeholder='#tags',
spellcheck='false',
type='text'
)
.editor-tags
#note-tags-component-container(ng-if='self.state.tagsComponent && !self.note.errorDecrypting')
component-view.component-view(
component-uuid='self.state.tagsComponent.uuid',
ng-class="{'locked' : self.noteLocked}",
ng-style="self.notesLocked && {'pointer-events' : 'none'}",
application='self.application'
)
input.tags-input(
ng-blur='self.onTagsInputBlur()',
ng-disabled='self.noteLocked',
ng-if='!self.state.tagsComponent',
ng-keyup='$event.keyCode == 13 && $event.target.blur();',
ng-model='self.editorValues.tagsInputValue',
placeholder='#tags',
spellcheck='false',
type='text'
)
div.flex.items-center
#save-status
.message(
ng-class="{'warning sk-bold': self.state.syncTakingTooLong, 'danger sk-bold': self.state.saveError}"
) {{self.state.noteStatus.message}}
.desc(ng-show='self.state.noteStatus.desc') {{self.state.noteStatus.desc}}
notes-options-panel(
app-state='self.appState',
ng-if='self.appState.notes.selectedNotesCount > 0'
)
.sn-component(ng-if='self.note')
#editor-menu-bar.sk-app-bar.no-edges
.left

View File

@@ -396,6 +396,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
toggleMenu(menu: keyof EditorState) {
this.setMenuState(menu, !this.state[menu]);
this.application.getAppState().notes.setContextMenuOpen(false);
}
closeAllMenus(exclude?: string) {

View File

@@ -1,7 +1,6 @@
.h-full
multiple-selected-notes-panel.h-full(
app-state='self.appState'
application='self.application'
ng-if='self.state.showMultipleSelectedNotes'
)
.flex-grow.h-full(

View File

@@ -127,6 +127,7 @@
threshold='200'
)
.note(
ng-attr-id='note-{{note.uuid}}'
ng-repeat='note in self.state.renderedNotes track by note.uuid'
ng-class="{'selected' : self.isNoteSelected(note.uuid) }"
ng-click='self.selectNote(note)'

View File

@@ -126,6 +126,7 @@ class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
deinit() {
for (const remove of this.removeObservers) remove();
this.removeObservers.length = 0;
this.removeAllContextMenuListeners();
this.panelPuppet!.onReady = undefined;
this.panelPuppet = undefined;
window.removeEventListener('resize', this.onWindowResize, true);
@@ -296,8 +297,45 @@ class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
));
}
selectNote(note: SNNote): Promise<void> {
return this.appState.notes.selectNote(note.uuid);
private openNotesContextMenu = (e: MouseEvent) => {
e.preventDefault();
this.application.getAppState().notes.setContextMenuPosition({
top: e.clientY,
left: e.clientX,
});
this.application.getAppState().notes.setContextMenuOpen(true);
}
private removeAllContextMenuListeners = () => {
const { selectedNotes, selectedNotesCount } = this.application.getAppState().notes;
if (selectedNotesCount > 0) {
Object.values(selectedNotes).forEach(({ uuid }) => {
document
.getElementById(`note-${uuid}`)
?.removeEventListener('contextmenu', this.openNotesContextMenu);
});
}
}
async selectNote(note: SNNote): Promise<void> {
const noteElement = document.getElementById(`note-${note.uuid}`);
if (
this.application.io.activeModifiers.has(KeyboardModifier.Meta) ||
this.application.io.activeModifiers.has(KeyboardModifier.Ctrl)
) {
if (this.application.getAppState().notes.selectedNotes[note.uuid]) {
noteElement?.removeEventListener(
'contextmenu',
this.openNotesContextMenu
);
} else {
noteElement?.addEventListener('contextmenu', this.openNotesContextMenu);
}
} else {
this.removeAllContextMenuListeners();
noteElement?.addEventListener('contextmenu', this.openNotesContextMenu);
}
await this.appState.notes.selectNote(note.uuid);
}
async createNewNote() {