feat: add right-click context menu
This commit is contained in:
@@ -61,6 +61,8 @@ import { NoProtectionsdNoteWarningDirective } from './components/NoProtectionsNo
|
|||||||
import { SearchOptionsDirective } from './components/SearchOptions';
|
import { SearchOptionsDirective } from './components/SearchOptions';
|
||||||
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
|
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
|
||||||
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
|
||||||
|
import { NotesContextMenuDirective } from './components/NotesContextMenu';
|
||||||
|
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
|
||||||
|
|
||||||
function reloadHiddenFirefoxTab(): boolean {
|
function reloadHiddenFirefoxTab(): boolean {
|
||||||
/**
|
/**
|
||||||
@@ -151,6 +153,8 @@ const startApplication: StartApplication = async function startApplication(
|
|||||||
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
|
.directive('protectedNotePanel', NoProtectionsdNoteWarningDirective)
|
||||||
.directive('searchOptions', SearchOptionsDirective)
|
.directive('searchOptions', SearchOptionsDirective)
|
||||||
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
|
.directive('multipleSelectedNotesPanel', MultipleSelectedNotesDirective)
|
||||||
|
.directive('notesContextMenu', NotesContextMenuDirective)
|
||||||
|
.directive('notesOptionsPanel', NotesOptionsPanelDirective)
|
||||||
.directive('confirmSignout', ConfirmSignoutDirective);
|
.directive('confirmSignout', ConfirmSignoutDirective);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
|
|||||||
@@ -1,185 +1,28 @@
|
|||||||
import { AppState } from '@/ui_models/app_state';
|
import { AppState } from '@/ui_models/app_state';
|
||||||
import VisuallyHidden from '@reach/visually-hidden';
|
import { toDirective } from './utils';
|
||||||
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 NotesIcon from '../../icons/il-notes.svg';
|
||||||
import { useRef, useState } from 'preact/hooks';
|
|
||||||
import { Switch } from './Switch';
|
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { SNApplication } from '@standardnotes/snjs';
|
import { NotesOptionsPanel } from './NotesOptionsPanel';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: SNApplication;
|
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
};
|
};
|
||||||
|
|
||||||
const MultipleSelectedNotes = observer(({ appState }: Props) => {
|
const MultipleSelectedNotes = observer(({ appState }: Props) => {
|
||||||
const count = appState.notes.selectedNotesCount;
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full items-center">
|
<div className="flex flex-col h-full items-center">
|
||||||
<div className="flex items-center justify-between p-4 w-full">
|
<div className="flex items-center justify-between p-4 w-full">
|
||||||
<h1 className="text-3xl m-0">{count} selected notes</h1>
|
<h1 className="text-3xl m-0">{count} selected notes</h1>
|
||||||
<Disclosure
|
<NotesOptionsPanel appState={appState} />
|
||||||
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>
|
||||||
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
<div className="flex-grow flex flex-col justify-center items-center w-full max-w-md">
|
||||||
<NotesIcon className="block" />
|
<NotesIcon className="block" />
|
||||||
<h2 className="text-2xl m-0 text-center mt-4">
|
<h2 className="text-2xl m-0 text-center mt-4">
|
||||||
{count} selected notes
|
{count} selected notes
|
||||||
</h2>
|
</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.
|
Actions will be performed on all selected notes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
40
app/assets/javascripts/components/NotesContextMenu.tsx
Normal file
40
app/assets/javascripts/components/NotesContextMenu.tsx
Normal 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);
|
||||||
122
app/assets/javascripts/components/NotesOptions.tsx
Normal file
122
app/assets/javascripts/components/NotesOptions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
80
app/assets/javascripts/components/NotesOptionsPanel.tsx
Normal file
80
app/assets/javascripts/components/NotesOptionsPanel.tsx
Normal 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);
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FunctionComponent, h, render } from 'preact';
|
import { FunctionComponent, h, render } from 'preact';
|
||||||
import { StateUpdater, useCallback, useState } from 'preact/hooks';
|
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
|
* @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(
|
export function useCloseOnBlur(
|
||||||
container: { current: HTMLDivElement },
|
container: { current: HTMLDivElement },
|
||||||
setOpen: (open: boolean) => void
|
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);
|
const [locked, setLocked] = useState(false);
|
||||||
return [
|
return [
|
||||||
useCallback(
|
useCallback(
|
||||||
function onBlur(event: { relatedTarget: EventTarget | null }) {
|
function onBlur(event: {
|
||||||
|
relatedTarget: EventTarget | null;
|
||||||
|
target: EventTarget | null;
|
||||||
|
}) {
|
||||||
if (
|
if (
|
||||||
!locked &&
|
!locked &&
|
||||||
!container.current.contains(event.relatedTarget as Node)
|
!container.current?.contains(event.relatedTarget as Node) &&
|
||||||
|
!container.current?.contains(event.target as Node)
|
||||||
) {
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
runInAction,
|
runInAction,
|
||||||
} from 'mobx';
|
} from 'mobx';
|
||||||
|
import { RefObject } from 'preact';
|
||||||
import { WebApplication } from '../application';
|
import { WebApplication } from '../application';
|
||||||
import { Editor } from '../editor';
|
import { Editor } from '../editor';
|
||||||
|
|
||||||
export class NotesState {
|
export class NotesState {
|
||||||
selectedNotes: Record<UuidString, SNNote> = {};
|
selectedNotes: Record<UuidString, SNNote> = {};
|
||||||
|
contextMenuOpen = false;
|
||||||
|
contextMenuPosition: { top: number, left: number } = { top: 0, left: 0 };
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private application: WebApplication,
|
private application: WebApplication,
|
||||||
@@ -27,12 +30,20 @@ export class NotesState {
|
|||||||
) {
|
) {
|
||||||
makeObservable(this, {
|
makeObservable(this, {
|
||||||
selectedNotes: observable,
|
selectedNotes: observable,
|
||||||
|
contextMenuOpen: observable,
|
||||||
|
contextMenuPosition: observable,
|
||||||
|
|
||||||
selectedNotesCount: computed,
|
selectedNotesCount: computed,
|
||||||
|
|
||||||
selectNote: action,
|
selectNote: action,
|
||||||
|
setArchiveSelectedNotes: action,
|
||||||
|
setContextMenuOpen: action,
|
||||||
|
setContextMenuPosition: action,
|
||||||
setHideSelectedNotePreviews: action,
|
setHideSelectedNotePreviews: action,
|
||||||
setLockSelectedNotes: action,
|
setLockSelectedNotes: action,
|
||||||
|
setPinSelectedNotes: action,
|
||||||
|
setTrashSelectedNotes: action,
|
||||||
|
unselectNotes: action,
|
||||||
});
|
});
|
||||||
|
|
||||||
appEventListeners.push(
|
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 {
|
setHideSelectedNotePreviews(hide: boolean): void {
|
||||||
this.application.changeItems<NoteMutator>(
|
this.application.changeItems<NoteMutator>(
|
||||||
Object.keys(this.selectedNotes),
|
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 (
|
if (
|
||||||
await confirmDialog({
|
await confirmDialog({
|
||||||
title: Strings.trashNotesTitle,
|
title: Strings.trashNotesTitle,
|
||||||
@@ -137,7 +156,10 @@ export class NotesState {
|
|||||||
);
|
);
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.selectedNotes = {};
|
this.selectedNotes = {};
|
||||||
|
this.contextMenuOpen = false;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
trashButtonRef.current?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +185,10 @@ export class NotesState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unselectNotes(): void {
|
||||||
|
this.selectedNotes = {};
|
||||||
|
}
|
||||||
|
|
||||||
private get io() {
|
private get io() {
|
||||||
return this.application.io;
|
return this.application.io;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,3 +33,6 @@
|
|||||||
challenge="challenge"
|
challenge="challenge"
|
||||||
on-dismiss="self.removeChallenge(challenge)"
|
on-dismiss="self.removeChallenge(challenge)"
|
||||||
)
|
)
|
||||||
|
notes-context-menu(
|
||||||
|
app-state='self.appState'
|
||||||
|
)
|
||||||
|
|||||||
@@ -19,44 +19,50 @@
|
|||||||
.sk-label.warning
|
.sk-label.warning
|
||||||
i.icon.ion-locked
|
i.icon.ion-locked
|
||||||
| {{self.lockText}}
|
| {{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-class="{'locked' : self.noteLocked}",
|
||||||
ng-show='self.note && !self.note.errorDecrypting'
|
ng-show='self.note && !self.note.errorDecrypting'
|
||||||
)
|
)
|
||||||
.title
|
div
|
||||||
input#note-title-editor.input(
|
.title
|
||||||
ng-blur='self.onTitleBlur()',
|
input#note-title-editor.input(
|
||||||
ng-change='self.onTitleChange()',
|
ng-blur='self.onTitleBlur()',
|
||||||
ng-disabled='self.noteLocked',
|
ng-change='self.onTitleChange()',
|
||||||
ng-focus='self.onTitleFocus()',
|
ng-disabled='self.noteLocked',
|
||||||
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
ng-focus='self.onTitleFocus()',
|
||||||
ng-model='self.editorValues.title',
|
ng-keyup='$event.keyCode == 13 && self.onTitleEnter($event)',
|
||||||
select-on-focus='true',
|
ng-model='self.editorValues.title',
|
||||||
spellcheck='false'
|
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'
|
|
||||||
)
|
)
|
||||||
input.tags-input(
|
.editor-tags
|
||||||
ng-blur='self.onTagsInputBlur()',
|
#note-tags-component-container(ng-if='self.state.tagsComponent && !self.note.errorDecrypting')
|
||||||
ng-disabled='self.noteLocked',
|
component-view.component-view(
|
||||||
ng-if='!self.state.tagsComponent',
|
component-uuid='self.state.tagsComponent.uuid',
|
||||||
ng-keyup='$event.keyCode == 13 && $event.target.blur();',
|
ng-class="{'locked' : self.noteLocked}",
|
||||||
ng-model='self.editorValues.tagsInputValue',
|
ng-style="self.notesLocked && {'pointer-events' : 'none'}",
|
||||||
placeholder='#tags',
|
application='self.application'
|
||||||
spellcheck='false',
|
)
|
||||||
type='text'
|
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')
|
.sn-component(ng-if='self.note')
|
||||||
#editor-menu-bar.sk-app-bar.no-edges
|
#editor-menu-bar.sk-app-bar.no-edges
|
||||||
.left
|
.left
|
||||||
|
|||||||
@@ -396,6 +396,7 @@ class EditorViewCtrl extends PureViewCtrl<unknown, EditorState> {
|
|||||||
|
|
||||||
toggleMenu(menu: keyof EditorState) {
|
toggleMenu(menu: keyof EditorState) {
|
||||||
this.setMenuState(menu, !this.state[menu]);
|
this.setMenuState(menu, !this.state[menu]);
|
||||||
|
this.application.getAppState().notes.setContextMenuOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeAllMenus(exclude?: string) {
|
closeAllMenus(exclude?: string) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
.h-full
|
.h-full
|
||||||
multiple-selected-notes-panel.h-full(
|
multiple-selected-notes-panel.h-full(
|
||||||
app-state='self.appState'
|
app-state='self.appState'
|
||||||
application='self.application'
|
|
||||||
ng-if='self.state.showMultipleSelectedNotes'
|
ng-if='self.state.showMultipleSelectedNotes'
|
||||||
)
|
)
|
||||||
.flex-grow.h-full(
|
.flex-grow.h-full(
|
||||||
|
|||||||
@@ -127,6 +127,7 @@
|
|||||||
threshold='200'
|
threshold='200'
|
||||||
)
|
)
|
||||||
.note(
|
.note(
|
||||||
|
ng-attr-id='note-{{note.uuid}}'
|
||||||
ng-repeat='note in self.state.renderedNotes track by note.uuid'
|
ng-repeat='note in self.state.renderedNotes track by note.uuid'
|
||||||
ng-class="{'selected' : self.isNoteSelected(note.uuid) }"
|
ng-class="{'selected' : self.isNoteSelected(note.uuid) }"
|
||||||
ng-click='self.selectNote(note)'
|
ng-click='self.selectNote(note)'
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
|
|||||||
deinit() {
|
deinit() {
|
||||||
for (const remove of this.removeObservers) remove();
|
for (const remove of this.removeObservers) remove();
|
||||||
this.removeObservers.length = 0;
|
this.removeObservers.length = 0;
|
||||||
|
this.removeAllContextMenuListeners();
|
||||||
this.panelPuppet!.onReady = undefined;
|
this.panelPuppet!.onReady = undefined;
|
||||||
this.panelPuppet = undefined;
|
this.panelPuppet = undefined;
|
||||||
window.removeEventListener('resize', this.onWindowResize, true);
|
window.removeEventListener('resize', this.onWindowResize, true);
|
||||||
@@ -296,8 +297,45 @@ class NotesViewCtrl extends PureViewCtrl<unknown, NotesCtrlState> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
selectNote(note: SNNote): Promise<void> {
|
private openNotesContextMenu = (e: MouseEvent) => {
|
||||||
return this.appState.notes.selectNote(note.uuid);
|
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() {
|
async createNewNote() {
|
||||||
|
|||||||
@@ -41,14 +41,10 @@ $heading-height: 75px;
|
|||||||
height: auto;
|
height: auto;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
$title-width: 70%;
|
.title {
|
||||||
$save-status-width: 30%;
|
|
||||||
|
|
||||||
> .title {
|
|
||||||
font-size: var(--sn-stylekit-font-size-h1);
|
font-size: var(--sn-stylekit-font-size-h1);
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
width: $title-width;
|
|
||||||
padding-right: 20px; /* make room for save status */
|
padding-right: 20px; /* make room for save status */
|
||||||
|
|
||||||
> .input {
|
> .input {
|
||||||
@@ -71,15 +67,10 @@ $heading-height: 75px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
#save-status {
|
#save-status {
|
||||||
width: $save-status-width;
|
margin-right: 20px;
|
||||||
float: right;
|
|
||||||
position: absolute;
|
|
||||||
|
|
||||||
right: 20px;
|
|
||||||
font-size: calc(var(--sn-stylekit-base-font-size) - 2px);
|
font-size: calc(var(--sn-stylekit-base-font-size) - 2px);
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin-top: 4px;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,10 @@
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.max-w-80 {
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-32px {
|
.h-32px {
|
||||||
height: 32px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
@@ -120,6 +124,7 @@
|
|||||||
@extend .absolute;
|
@extend .absolute;
|
||||||
@extend .bg-default;
|
@extend .bg-default;
|
||||||
@extend .min-w-80;
|
@extend .min-w-80;
|
||||||
|
@extend .transition-transform;
|
||||||
@extend .duration-150;
|
@extend .duration-150;
|
||||||
@extend .slide-down-animation;
|
@extend .slide-down-animation;
|
||||||
@extend .rounded;
|
@extend .rounded;
|
||||||
|
|||||||
Reference in New Issue
Block a user