diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index bf21ec96c..46ec6b568 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -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 diff --git a/app/assets/javascripts/components/MultipleSelectedNotes.tsx b/app/assets/javascripts/components/MultipleSelectedNotes.tsx index d28da4a1d..a35e6c1c4 100644 --- a/app/assets/javascripts/components/MultipleSelectedNotes.tsx +++ b/app/assets/javascripts/components/MultipleSelectedNotes.tsx @@ -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(); - const panelRef = useRef(); - 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 (

{count} selected notes

- { - const rect = buttonRef.current.getBoundingClientRect(); - setOptionsPanelPosition({ - top: rect.bottom, - right: document.body.clientWidth - rect.right, - }); - setOpen((prevOpen) => !prevOpen); - }} - > - { - 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' - } - > - Actions - - - { - 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" - > - { - appState.notes.setLockSelectedNotes(!locked); - }} - > - - - Prevent editing - - - { - appState.notes.setHideSelectedNotePreviews(!hidePreviews); - }} - > - - - Show Preview - - -
- - - -
-
+

{count} selected notes

-

+

Actions will be performed on all selected notes.

diff --git a/app/assets/javascripts/components/NotesContextMenu.tsx b/app/assets/javascripts/components/NotesContextMenu.tsx new file mode 100644 index 000000000..5de71067b --- /dev/null +++ b/app/assets/javascripts/components/NotesContextMenu.tsx @@ -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(); + 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 ? ( +
+ +
+ ) : 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..0940ac537 --- /dev/null +++ b/app/assets/javascripts/components/NotesOptions.tsx @@ -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(); + + 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 ( + <> + { + appState.notes.setLockSelectedNotes(!locked); + }} + > + + + Prevent editing + + + { + appState.notes.setHideSelectedNotePreviews(!hidePreviews); + }} + > + + + Show Preview + + +
+ + + + + ); + } +); diff --git a/app/assets/javascripts/components/NotesOptionsPanel.tsx b/app/assets/javascripts/components/NotesOptionsPanel.tsx new file mode 100644 index 000000000..ef7d63096 --- /dev/null +++ b/app/assets/javascripts/components/NotesOptionsPanel.tsx @@ -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(); + const panelRef = useRef(); + const [closeOnBlur, setLockCloseOnBlur] = useCloseOnBlur(panelRef, setOpen); + + return ( + { + const rect = buttonRef.current.getBoundingClientRect(); + setPosition({ + top: rect.bottom, + right: document.body.clientWidth - rect.right, + }); + setOpen(!open); + }} + > + { + 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' + } + > + Actions + + + { + 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" + > + + + + ); +}); + +export const NotesOptionsPanelDirective = toDirective(NotesOptionsPanel); diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts index 84d640b72..465d1bca3 100644 --- a/app/assets/javascripts/components/utils.ts +++ b/app/assets/javascripts/components/utils.ts @@ -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] { +): [ + (event: { + relatedTarget: EventTarget | null; + target: EventTarget | null; + }) => void, + StateUpdater +] { 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); } diff --git a/app/assets/javascripts/ui_models/app_state/notes_state.ts b/app/assets/javascripts/ui_models/app_state/notes_state.ts index ad9b2e0b6..22014aa44 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts @@ -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 = {}; + 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( Object.keys(this.selectedNotes), @@ -120,7 +139,7 @@ export class NotesState { ); } - async setTrashSelectedNotes(trashed: boolean): Promise { + async setTrashSelectedNotes(trashed: boolean, trashButtonRef: RefObject): Promise { 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; } diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index 2a6132cf0..a1c08a28d 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -33,3 +33,6 @@ challenge="challenge" on-dismiss="self.removeChallenge(challenge)" ) + notes-context-menu( + app-state='self.appState' + ) diff --git a/app/assets/javascripts/views/editor/editor-view.pug b/app/assets/javascripts/views/editor/editor-view.pug index a19b4c0fe..b5dc4386e 100644 --- a/app/assets/javascripts/views/editor/editor-view.pug +++ b/app/assets/javascripts/views/editor/editor-view.pug @@ -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 diff --git a/app/assets/javascripts/views/editor/editor_view.ts b/app/assets/javascripts/views/editor/editor_view.ts index 9f484223e..c01f4a101 100644 --- a/app/assets/javascripts/views/editor/editor_view.ts +++ b/app/assets/javascripts/views/editor/editor_view.ts @@ -396,6 +396,7 @@ class EditorViewCtrl extends PureViewCtrl { toggleMenu(menu: keyof EditorState) { this.setMenuState(menu, !this.state[menu]); + this.application.getAppState().notes.setContextMenuOpen(false); } closeAllMenus(exclude?: string) { diff --git a/app/assets/javascripts/views/editor_group/editor-group-view.pug b/app/assets/javascripts/views/editor_group/editor-group-view.pug index eccb5d59f..f2f51a590 100644 --- a/app/assets/javascripts/views/editor_group/editor-group-view.pug +++ b/app/assets/javascripts/views/editor_group/editor-group-view.pug @@ -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( diff --git a/app/assets/javascripts/views/notes/notes-view.pug b/app/assets/javascripts/views/notes/notes-view.pug index 1240ec740..7f97da3b8 100644 --- a/app/assets/javascripts/views/notes/notes-view.pug +++ b/app/assets/javascripts/views/notes/notes-view.pug @@ -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)' diff --git a/app/assets/javascripts/views/notes/notes_view.ts b/app/assets/javascripts/views/notes/notes_view.ts index 9674ffe56..fc7be7755 100644 --- a/app/assets/javascripts/views/notes/notes_view.ts +++ b/app/assets/javascripts/views/notes/notes_view.ts @@ -126,6 +126,7 @@ class NotesViewCtrl extends PureViewCtrl { 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 { )); } - selectNote(note: SNNote): Promise { - 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 { + 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() { diff --git a/app/assets/stylesheets/_editor.scss b/app/assets/stylesheets/_editor.scss index 610c5324b..45ad2a88c 100644 --- a/app/assets/stylesheets/_editor.scss +++ b/app/assets/stylesheets/_editor.scss @@ -41,14 +41,10 @@ $heading-height: 75px; height: auto; overflow: visible; - $title-width: 70%; - $save-status-width: 30%; - - > .title { + .title { font-size: var(--sn-stylekit-font-size-h1); font-weight: bold; padding-top: 0px; - width: $title-width; padding-right: 20px; /* make room for save status */ > .input { @@ -71,15 +67,10 @@ $heading-height: 75px; } #save-status { - width: $save-status-width; - float: right; - position: absolute; - - right: 20px; + margin-right: 20px; font-size: calc(var(--sn-stylekit-base-font-size) - 2px); text-transform: none; font-weight: normal; - margin-top: 4px; text-align: right; white-space: nowrap; diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index d42ff0147..9fb3b8e78 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -86,6 +86,10 @@ width: 32px; } +.max-w-80 { + max-width: 20rem; +} + .h-32px { height: 32px; } @@ -120,6 +124,7 @@ @extend .absolute; @extend .bg-default; @extend .min-w-80; + @extend .transition-transform; @extend .duration-150; @extend .slide-down-animation; @extend .rounded;