feat: Add new "Change Editor" option to note context menu (#823)
* feat: add editor icon * refactor: remove 'any' type and format * refactor: move NotesOptions and add ChangeEditorOption * refactor: fix type for using regular RefObject<T> * feat: add hide-if-last-child util class * feat: add Change Editor option * feat: make radio btn gray if not checked * fix: accordion menu header and item sizing/spacing * feat: add Escape key to KeyboardKey enum * refactor: Remove Editor Menu * feat: add editor select functionality * refactor: move plain editor name to constant * feat: add premium editors with modal if no subscription refactor: simplify menu group creation * feat: show alert when switching to non-interchangeable editor * fix: change editor menu going out of bounds * feat: increase group header & editor item size * fix: change editor menu close on blur * refactor: Use KeyboardKey enum & remove else statement * feat: add keyboard navigation to change editor menu * fix: editor menu separators * feat: improve change editor menu sizing & spacing * feat: show alert only if editor is not interchangeable * feat: don't show alert when switching to/from plain editor * chore: bump snjs version * feat: temporarily remove change editor alert * feat: dynamically get footer height * refactor: move magic number to const * refactor: move constants to constants file * feat: use const instead of magic number
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/strings';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { AppState } from '@/ui_models/app_state';
|
||||
import {
|
||||
MENU_MARGIN_FROM_APP_BORDER,
|
||||
MAX_MENU_SIZE_MULTIPLIER,
|
||||
} from '@/views/constants';
|
||||
import {
|
||||
reloadFont,
|
||||
transactionForAssociateComponentWithCurrentNote,
|
||||
transactionForDisassociateComponentWithCurrentNote,
|
||||
} from '@/views/note_view/note_view';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import {
|
||||
ComponentArea,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
SNNote,
|
||||
TransactionalMutation,
|
||||
} from '@standardnotes/snjs';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Icon, IconType } from '../Icon';
|
||||
import { PremiumModalProvider } from '../Premium';
|
||||
import { createEditorMenuGroups } from './changeEditor/createEditorMenuGroups';
|
||||
import { EditorAccordionMenu } from './changeEditor/EditorAccordionMenu';
|
||||
|
||||
type ChangeEditorOptionProps = {
|
||||
appState: AppState;
|
||||
application: WebApplication;
|
||||
note: SNNote;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
};
|
||||
|
||||
type AccordionMenuGroup<T> = {
|
||||
icon?: IconType;
|
||||
iconClassName?: string;
|
||||
title: string;
|
||||
items: Array<T>;
|
||||
};
|
||||
|
||||
export type EditorMenuItem = {
|
||||
name: string;
|
||||
component?: SNComponent;
|
||||
isPremiumFeature?: boolean;
|
||||
};
|
||||
|
||||
export type EditorMenuGroup = AccordionMenuGroup<EditorMenuItem>;
|
||||
|
||||
export const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({
|
||||
application,
|
||||
appState,
|
||||
closeOnBlur,
|
||||
note,
|
||||
}) => {
|
||||
const [changeEditorMenuOpen, setChangeEditorMenuOpen] = useState(false);
|
||||
const [changeEditorMenuPosition, setChangeEditorMenuPosition] = useState<{
|
||||
top?: number | 'auto';
|
||||
right?: number | 'auto';
|
||||
bottom: number | 'auto';
|
||||
left?: number | 'auto';
|
||||
}>({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
});
|
||||
const changeEditorMenuRef = useRef<HTMLDivElement>(null);
|
||||
const changeEditorButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const [editors] = useState<SNComponent[]>(() =>
|
||||
application.componentManager
|
||||
.componentsForArea(ComponentArea.Editor)
|
||||
.sort((a, b) => {
|
||||
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
|
||||
})
|
||||
);
|
||||
const [editorMenuGroups, setEditorMenuGroups] = useState<EditorMenuGroup[]>(
|
||||
[]
|
||||
);
|
||||
const [selectedEditor, setSelectedEditor] = useState(() =>
|
||||
application.componentManager.editorForNote(note)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditorMenuGroups(createEditorMenuGroups(editors));
|
||||
}, [editors]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEditor(application.componentManager.editorForNote(note));
|
||||
}, [application, note]);
|
||||
|
||||
const toggleChangeEditorMenu = () => {
|
||||
const defaultFontSize = window.getComputedStyle(
|
||||
document.documentElement
|
||||
).fontSize;
|
||||
const maxChangeEditorMenuSize =
|
||||
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const buttonRect = changeEditorButtonRef.current?.getBoundingClientRect();
|
||||
const buttonParentRect =
|
||||
changeEditorButtonRef.current?.parentElement?.getBoundingClientRect();
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
|
||||
if (buttonRect && buttonParentRect && footerHeightInPx) {
|
||||
let positionBottom =
|
||||
clientHeight - buttonRect.bottom - buttonRect.height / 2;
|
||||
|
||||
if (positionBottom < footerHeightInPx) {
|
||||
positionBottom = footerHeightInPx + MENU_MARGIN_FROM_APP_BORDER;
|
||||
}
|
||||
|
||||
if (buttonRect.right + maxChangeEditorMenuSize > clientWidth) {
|
||||
setChangeEditorMenuPosition({
|
||||
top: positionBottom - buttonParentRect.height / 2,
|
||||
right: clientWidth - buttonRect.left,
|
||||
bottom: 'auto',
|
||||
});
|
||||
} else {
|
||||
setChangeEditorMenuPosition({
|
||||
bottom: positionBottom,
|
||||
left: buttonRect.right,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setChangeEditorMenuOpen(!changeEditorMenuOpen);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (changeEditorMenuOpen) {
|
||||
const defaultFontSize = window.getComputedStyle(
|
||||
document.documentElement
|
||||
).fontSize;
|
||||
const maxChangeEditorMenuSize =
|
||||
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
|
||||
const changeEditorMenuBoundingRect =
|
||||
changeEditorMenuRef.current?.getBoundingClientRect();
|
||||
const buttonRect = changeEditorButtonRef.current?.getBoundingClientRect();
|
||||
|
||||
if (changeEditorMenuBoundingRect && buttonRect) {
|
||||
if (changeEditorMenuBoundingRect.y < MENU_MARGIN_FROM_APP_BORDER) {
|
||||
if (
|
||||
buttonRect.right + maxChangeEditorMenuSize >
|
||||
document.documentElement.clientWidth
|
||||
) {
|
||||
setChangeEditorMenuPosition({
|
||||
...changeEditorMenuPosition,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER + buttonRect.height,
|
||||
bottom: 'auto',
|
||||
});
|
||||
} else {
|
||||
setChangeEditorMenuPosition({
|
||||
...changeEditorMenuPosition,
|
||||
top: MENU_MARGIN_FROM_APP_BORDER,
|
||||
bottom: 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [changeEditorMenuOpen, changeEditorMenuPosition]);
|
||||
|
||||
const selectComponent = async (component: SNComponent | null) => {
|
||||
if (component) {
|
||||
if (component.conflictOf) {
|
||||
application.changeAndSaveItem(component.uuid, (mutator) => {
|
||||
mutator.conflictOf = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const transactions: TransactionalMutation[] = [];
|
||||
|
||||
if (appState.getActiveNoteController()?.isTemplateNote) {
|
||||
await appState.getActiveNoteController().insertTemplatedNote();
|
||||
}
|
||||
|
||||
if (note.locked) {
|
||||
application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!component) {
|
||||
if (!note.prefersPlainEditor) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = true;
|
||||
},
|
||||
});
|
||||
}
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
if (currentEditor?.isExplicitlyEnabledForItem(note.uuid)) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
}
|
||||
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled));
|
||||
} else if (component.area === ComponentArea.Editor) {
|
||||
const currentEditor = application.componentManager.editorForNote(note);
|
||||
if (currentEditor && component.uuid !== currentEditor.uuid) {
|
||||
transactions.push(
|
||||
transactionForDisassociateComponentWithCurrentNote(
|
||||
currentEditor,
|
||||
note
|
||||
)
|
||||
);
|
||||
}
|
||||
const prefersPlain = note.prefersPlainEditor;
|
||||
if (prefersPlain) {
|
||||
transactions.push({
|
||||
itemUuid: note.uuid,
|
||||
mutate: (m: ItemMutator) => {
|
||||
const noteMutator = m as NoteMutator;
|
||||
noteMutator.prefersPlainEditor = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
transactions.push(
|
||||
transactionForAssociateComponentWithCurrentNote(component, note)
|
||||
);
|
||||
}
|
||||
|
||||
await application.runTransactionalMutations(transactions);
|
||||
/** Dirtying can happen above */
|
||||
application.sync();
|
||||
|
||||
setSelectedEditor(application.componentManager.editorForNote(note));
|
||||
};
|
||||
|
||||
return (
|
||||
<Disclosure open={changeEditorMenuOpen} onChange={toggleChangeEditorMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setChangeEditorMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={changeEditorButtonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="editor" className="color-neutral mr-2" />
|
||||
Change editor
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
ref={changeEditorMenuRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.Escape) {
|
||||
setChangeEditorMenuOpen(false);
|
||||
changeEditorButtonRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...changeEditorMenuPosition,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown flex flex-col py-1 max-h-120 min-w-68 fixed overflow-y-auto"
|
||||
>
|
||||
<PremiumModalProvider state={appState.features}>
|
||||
<EditorAccordionMenu
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
groups={editorMenuGroups}
|
||||
isOpen={changeEditorMenuOpen}
|
||||
selectComponent={selectComponent}
|
||||
selectedEditor={selectedEditor}
|
||||
/>
|
||||
</PremiumModalProvider>
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
571
app/assets/javascripts/components/NotesOptions/NotesOptions.tsx
Normal file
571
app/assets/javascripts/components/NotesOptions/NotesOptions.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
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, useMemo } from 'preact/hooks';
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
} from '@reach/disclosure';
|
||||
import { SNApplication, SNNote } from '@standardnotes/snjs';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { KeyboardModifier } from '@/services/ioService';
|
||||
import { FunctionComponent } from 'preact';
|
||||
import { ChangeEditorOption } from './ChangeEditorOption';
|
||||
import {
|
||||
MENU_MARGIN_FROM_APP_BORDER,
|
||||
MAX_MENU_SIZE_MULTIPLIER,
|
||||
} from '@/views/constants';
|
||||
|
||||
export type NotesOptionsProps = {
|
||||
application: WebApplication;
|
||||
appState: AppState;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
onSubmenuChange?: (submenuOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type DeletePermanentlyButtonProps = {
|
||||
closeOnBlur: NotesOptionsProps['closeOnBlur'];
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const DeletePermanentlyButton = ({
|
||||
closeOnBlur,
|
||||
onClick,
|
||||
}: DeletePermanentlyButtonProps) => (
|
||||
<button onBlur={closeOnBlur} className="sn-dropdown-item" onClick={onClick}>
|
||||
<Icon type="close" className="color-danger mr-2" />
|
||||
<span className="color-danger">Delete permanently</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const iconClass = 'color-neutral mr-2';
|
||||
|
||||
const getWordCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return text.split(/\s+/).length;
|
||||
};
|
||||
|
||||
const getParagraphCount = (text: string) => {
|
||||
if (text.trim().length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return text.replace(/\n$/gm, '').split(/\n/).length;
|
||||
};
|
||||
|
||||
const countNoteAttributes = (text: string) => {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
return {
|
||||
characters: 'N/A',
|
||||
words: 'N/A',
|
||||
paragraphs: 'N/A',
|
||||
};
|
||||
} catch {
|
||||
const characters = text.length;
|
||||
const words = getWordCount(text);
|
||||
const paragraphs = getParagraphCount(text);
|
||||
|
||||
return {
|
||||
characters,
|
||||
words,
|
||||
paragraphs,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const calculateReadTime = (words: number) => {
|
||||
const timeToRead = Math.round(words / 200);
|
||||
if (timeToRead === 0) {
|
||||
return '< 1 minute';
|
||||
} else {
|
||||
return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return;
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`;
|
||||
};
|
||||
|
||||
const NoteAttributes: FunctionComponent<{
|
||||
application: SNApplication;
|
||||
note: SNNote;
|
||||
}> = ({ application, note }) => {
|
||||
const { words, characters, paragraphs } = useMemo(
|
||||
() => countNoteAttributes(note.text),
|
||||
[note.text]
|
||||
);
|
||||
|
||||
const readTime = useMemo(
|
||||
() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'),
|
||||
[words]
|
||||
);
|
||||
|
||||
const dateLastModified = useMemo(
|
||||
() => formatDate(note.userModifiedDate),
|
||||
[note.userModifiedDate]
|
||||
);
|
||||
|
||||
const dateCreated = useMemo(
|
||||
() => formatDate(note.created_at),
|
||||
[note.created_at]
|
||||
);
|
||||
|
||||
const editor = application.componentManager.editorForNote(note);
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
|
||||
return (
|
||||
<div className="px-3 pt-1.5 pb-1 text-xs color-neutral font-medium">
|
||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||
<>
|
||||
<div className="mb-1">
|
||||
{words} words · {characters} characters · {paragraphs} paragraphs
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Read time:</span> {readTime}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Last modified:</span> {dateLastModified}
|
||||
</div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Created:</span> {dateCreated}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SpellcheckOptions: FunctionComponent<{
|
||||
appState: AppState;
|
||||
note: SNNote;
|
||||
}> = ({ appState, note }) => {
|
||||
const editor = appState.application.componentManager.editorForNote(note);
|
||||
const spellcheckControllable = Boolean(
|
||||
!editor ||
|
||||
appState.application.getFeature(editor.identifier)?.spellcheckControl
|
||||
);
|
||||
const noteSpellcheck = !spellcheckControllable
|
||||
? true
|
||||
: note
|
||||
? appState.notes.getSpellcheckStateForNote(note)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col px-3 py-1.5">
|
||||
<Switch
|
||||
className="px-0 py-0"
|
||||
checked={noteSpellcheck}
|
||||
disabled={!spellcheckControllable}
|
||||
onChange={() => {
|
||||
appState.notes.toggleGlobalSpellcheckForNote(note);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="spellcheck" className={iconClass} />
|
||||
Spellcheck
|
||||
</span>
|
||||
</Switch>
|
||||
{!spellcheckControllable && (
|
||||
<p className="text-xs pt-1.5">
|
||||
Spellcheck cannot be controlled for this editor.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NotesOptions = observer(
|
||||
({
|
||||
application,
|
||||
appState,
|
||||
closeOnBlur,
|
||||
onSubmenuChange,
|
||||
}: NotesOptionsProps) => {
|
||||
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
|
||||
const [tagsMenuPosition, setTagsMenuPosition] = useState<{
|
||||
top: number;
|
||||
right?: number;
|
||||
left?: number;
|
||||
}>({
|
||||
top: 0,
|
||||
right: 0,
|
||||
});
|
||||
const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState<number | 'auto'>(
|
||||
'auto'
|
||||
);
|
||||
const [altKeyDown, setAltKeyDown] = useState(false);
|
||||
|
||||
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 errored = notes.some((note) => note.errorDecrypting);
|
||||
|
||||
const tagsButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSubmenuChange) {
|
||||
onSubmenuChange(tagsMenuOpen);
|
||||
}
|
||||
}, [tagsMenuOpen, onSubmenuChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const removeAltKeyObserver = application.io.addKeyObserver({
|
||||
modifiers: [KeyboardModifier.Alt],
|
||||
onKeyDown: () => {
|
||||
setAltKeyDown(true);
|
||||
},
|
||||
onKeyUp: () => {
|
||||
setAltKeyDown(false);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeAltKeyObserver();
|
||||
};
|
||||
}, [application]);
|
||||
|
||||
const openTagsMenu = () => {
|
||||
const defaultFontSize = window.getComputedStyle(
|
||||
document.documentElement
|
||||
).fontSize;
|
||||
const maxTagsMenuSize =
|
||||
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
|
||||
const { clientWidth, clientHeight } = document.documentElement;
|
||||
const buttonRect = tagsButtonRef.current?.getBoundingClientRect();
|
||||
const footerElementRect = document
|
||||
.getElementById('footer-bar')
|
||||
?.getBoundingClientRect();
|
||||
const footerHeightInPx = footerElementRect?.height;
|
||||
|
||||
if (buttonRect && footerHeightInPx) {
|
||||
if (
|
||||
buttonRect.top + maxTagsMenuSize >
|
||||
clientHeight - footerHeightInPx
|
||||
) {
|
||||
setTagsMenuMaxHeight(
|
||||
clientHeight -
|
||||
buttonRect.top -
|
||||
footerHeightInPx -
|
||||
MENU_MARGIN_FROM_APP_BORDER
|
||||
);
|
||||
}
|
||||
|
||||
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
right: clientWidth - buttonRect.left,
|
||||
});
|
||||
} else {
|
||||
setTagsMenuPosition({
|
||||
top: buttonRect.top,
|
||||
left: buttonRect.right,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setTagsMenuOpen(!tagsMenuOpen);
|
||||
};
|
||||
|
||||
const downloadSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
const editor = application.componentManager.editorForNote(note);
|
||||
const format = editor?.package_info?.file_type || 'txt';
|
||||
const downloadAnchor = document.createElement('a');
|
||||
downloadAnchor.setAttribute(
|
||||
'href',
|
||||
'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text)
|
||||
);
|
||||
downloadAnchor.setAttribute('download', `${note.title}.${format}`);
|
||||
downloadAnchor.click();
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateSelectedItems = () => {
|
||||
notes.forEach((note) => {
|
||||
application.duplicateItem(note);
|
||||
});
|
||||
};
|
||||
|
||||
if (errored) {
|
||||
return (
|
||||
<>
|
||||
{notes.length === 1 ? (
|
||||
<div className="px-3 pt-1.5 pb-1 text-xs color-neutral font-medium">
|
||||
<div>
|
||||
<span className="font-semibold">Note ID:</span> {notes[0].uuid}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={locked}
|
||||
onChange={() => {
|
||||
appState.notes.setLockSelectedNotes(!locked);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="pencil-off" className={iconClass} />
|
||||
Prevent editing
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={!hidePreviews}
|
||||
onChange={() => {
|
||||
appState.notes.setHideSelectedNotePreviews(!hidePreviews);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="rich-text" className={iconClass} />
|
||||
Show preview
|
||||
</span>
|
||||
</Switch>
|
||||
<Switch
|
||||
onBlur={closeOnBlur}
|
||||
className="px-3 py-1.5"
|
||||
checked={protect}
|
||||
onChange={() => {
|
||||
appState.notes.setProtectSelectedNotes(!protect);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Icon type="password" className={iconClass} />
|
||||
Protect
|
||||
</span>
|
||||
</Switch>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
{appState.tags.tagsCount > 0 && (
|
||||
<Disclosure open={tagsMenuOpen} onChange={openTagsMenu}>
|
||||
<DisclosureButton
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTagsMenuOpen(false);
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={tagsButtonRef}
|
||||
className="sn-dropdown-item justify-between"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="hashtag" className={iconClass} />
|
||||
{'Add tag'}
|
||||
</div>
|
||||
<Icon type="chevron-right" className="color-neutral" />
|
||||
</DisclosureButton>
|
||||
<DisclosurePanel
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setTagsMenuOpen(false);
|
||||
tagsButtonRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
...tagsMenuPosition,
|
||||
maxHeight: tagsMenuMaxHeight,
|
||||
position: 'fixed',
|
||||
}}
|
||||
className="sn-dropdown min-w-80 flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-auto"
|
||||
>
|
||||
{appState.tags.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.title}
|
||||
className="sn-dropdown-item sn-dropdown-item--no-icon max-w-80"
|
||||
onBlur={closeOnBlur}
|
||||
onClick={() => {
|
||||
appState.notes.isTagInSelectedNotes(tag)
|
||||
? appState.notes.removeTagFromSelectedNotes(tag)
|
||||
: appState.notes.addTagToSelectedNotes(tag);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
|
||||
${
|
||||
appState.notes.isTagInSelectedNotes(tag)
|
||||
? 'font-bold'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{tag.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</DisclosurePanel>
|
||||
</Disclosure>
|
||||
)}
|
||||
{unpinned && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(true);
|
||||
}}
|
||||
>
|
||||
<Icon type="pin" className={iconClass} />
|
||||
Pin to top
|
||||
</button>
|
||||
)}
|
||||
{pinned && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setPinSelectedNotes(false);
|
||||
}}
|
||||
>
|
||||
<Icon type="unpin" className={iconClass} />
|
||||
Unpin
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={downloadSelectedItems}
|
||||
>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={duplicateSelectedItems}
|
||||
>
|
||||
<Icon type="copy" className={iconClass} />
|
||||
Duplicate
|
||||
</button>
|
||||
{unarchived && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(true);
|
||||
}}
|
||||
>
|
||||
<Icon type="archive" className={iconClass} />
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
{archived && (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={() => {
|
||||
appState.notes.setArchiveSelectedNotes(false);
|
||||
}}
|
||||
>
|
||||
<Icon type="unarchive" className={iconClass} />
|
||||
Unarchive
|
||||
</button>
|
||||
)}
|
||||
{notTrashed &&
|
||||
(altKeyDown ? (
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(true);
|
||||
}}
|
||||
>
|
||||
<Icon type="trash" className={iconClass} />
|
||||
Move to Trash
|
||||
</button>
|
||||
))}
|
||||
{trashed && (
|
||||
<>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.setTrashSelectedNotes(false);
|
||||
}}
|
||||
>
|
||||
<Icon type="restore" className={iconClass} />
|
||||
Restore
|
||||
</button>
|
||||
<DeletePermanentlyButton
|
||||
closeOnBlur={closeOnBlur}
|
||||
onClick={async () => {
|
||||
await appState.notes.deleteNotesPermanently();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onBlur={closeOnBlur}
|
||||
className="sn-dropdown-item"
|
||||
onClick={async () => {
|
||||
await appState.notes.emptyTrash();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<Icon type="trash-sweep" className="color-danger mr-2" />
|
||||
<div className="flex-row">
|
||||
<div className="color-danger">Empty Trash</div>
|
||||
<div className="text-xs">
|
||||
{appState.notes.trashedNotesCount} notes in Trash
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{notes.length === 1 ? (
|
||||
<>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<ChangeEditorOption
|
||||
appState={appState}
|
||||
application={application}
|
||||
closeOnBlur={closeOnBlur}
|
||||
note={notes[0]}
|
||||
/>
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<SpellcheckOptions appState={appState} note={notes[0]} />
|
||||
<div className="min-h-1px my-2 bg-border"></div>
|
||||
<NoteAttributes application={application} note={notes[0]} />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { Icon } from '@/components/Icon';
|
||||
import { usePremiumModal } from '@/components/Premium';
|
||||
import { KeyboardKey } from '@/services/ioService';
|
||||
import { WebApplication } from '@/ui_models/application';
|
||||
import { SNComponent } from '@standardnotes/snjs';
|
||||
import { Fragment, FunctionComponent } from 'preact';
|
||||
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
import { PLAIN_EDITOR_NAME } from './createEditorMenuGroups';
|
||||
|
||||
type EditorAccordionMenuProps = {
|
||||
application: WebApplication;
|
||||
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
|
||||
groups: EditorMenuGroup[];
|
||||
isOpen: boolean;
|
||||
selectComponent: (component: SNComponent | null) => Promise<void>;
|
||||
selectedEditor: SNComponent | undefined;
|
||||
};
|
||||
|
||||
const getGroupId = (group: EditorMenuGroup) =>
|
||||
group.title.toLowerCase().replace(/\s/, '-');
|
||||
|
||||
const getGroupBtnId = (groupId: string) => groupId + '-button';
|
||||
|
||||
export const EditorAccordionMenu: FunctionComponent<
|
||||
EditorAccordionMenuProps
|
||||
> = ({
|
||||
application,
|
||||
closeOnBlur,
|
||||
groups,
|
||||
isOpen,
|
||||
selectComponent,
|
||||
selectedEditor,
|
||||
}) => {
|
||||
const [activeGroupId, setActiveGroupId] = useState('');
|
||||
const menuItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [focusedItemIndex, setFocusedItemIndex] = useState<number>();
|
||||
const premiumModal = usePremiumModal();
|
||||
|
||||
const isSelectedEditor = useCallback(
|
||||
(item: EditorMenuItem) => {
|
||||
if (selectedEditor) {
|
||||
if (item?.component?.identifier === selectedEditor.identifier) {
|
||||
return true;
|
||||
}
|
||||
} else if (item.name === PLAIN_EDITOR_NAME) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[selectedEditor]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const activeGroup = groups.find((group) => {
|
||||
return group.items.some(isSelectedEditor);
|
||||
});
|
||||
|
||||
if (activeGroup) {
|
||||
const newActiveGroupId = getGroupId(activeGroup);
|
||||
setActiveGroupId(newActiveGroupId);
|
||||
}
|
||||
}, [groups, selectedEditor, isSelectedEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof focusedItemIndex === 'undefined' &&
|
||||
activeGroupId.length &&
|
||||
menuItemRefs.current.length
|
||||
) {
|
||||
const activeGroupIndex = menuItemRefs.current.findIndex(
|
||||
(item) => item?.id === getGroupBtnId(activeGroupId)
|
||||
);
|
||||
setFocusedItemIndex(activeGroupIndex);
|
||||
}
|
||||
}, [activeGroupId, focusedItemIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof focusedItemIndex === 'number' &&
|
||||
focusedItemIndex > -1 &&
|
||||
isOpen
|
||||
) {
|
||||
const focusedItem = menuItemRefs.current[focusedItemIndex];
|
||||
const containingGroupId = focusedItem?.closest(
|
||||
'[data-accordion-group]'
|
||||
)?.id;
|
||||
if (
|
||||
!focusedItem?.id &&
|
||||
containingGroupId &&
|
||||
containingGroupId !== activeGroupId
|
||||
) {
|
||||
setActiveGroupId(containingGroupId);
|
||||
}
|
||||
focusedItem?.focus();
|
||||
}
|
||||
}, [activeGroupId, focusedItemIndex, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case KeyboardKey.Up: {
|
||||
if (
|
||||
typeof focusedItemIndex === 'number' &&
|
||||
menuItemRefs.current.length
|
||||
) {
|
||||
let previousItemIndex = focusedItemIndex - 1;
|
||||
if (previousItemIndex < 0) {
|
||||
previousItemIndex = menuItemRefs.current.length - 1;
|
||||
}
|
||||
setFocusedItemIndex(previousItemIndex);
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
case KeyboardKey.Down: {
|
||||
if (
|
||||
typeof focusedItemIndex === 'number' &&
|
||||
menuItemRefs.current.length
|
||||
) {
|
||||
let nextItemIndex = focusedItemIndex + 1;
|
||||
if (nextItemIndex > menuItemRefs.current.length - 1) {
|
||||
nextItemIndex = 0;
|
||||
}
|
||||
setFocusedItemIndex(nextItemIndex);
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [focusedItemIndex, groups]);
|
||||
|
||||
const selectEditor = (item: EditorMenuItem) => {
|
||||
if (item.component) {
|
||||
selectComponent(item.component);
|
||||
} else if (item.isPremiumFeature) {
|
||||
premiumModal.activate(item.name);
|
||||
} else {
|
||||
selectComponent(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => {
|
||||
const groupId = getGroupId(group);
|
||||
const buttonId = getGroupBtnId(groupId);
|
||||
const contentId = `${groupId}-content`;
|
||||
|
||||
if (!group.items || !group.items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={groupId}>
|
||||
<div id={groupId} data-accordion-group>
|
||||
<h3 className="m-0">
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={activeGroupId === groupId}
|
||||
className="sn-dropdown-item focus:bg-info-backdrop justify-between py-2.5"
|
||||
id={buttonId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (activeGroupId !== groupId) {
|
||||
setActiveGroupId(groupId);
|
||||
} else {
|
||||
setActiveGroupId('');
|
||||
}
|
||||
}}
|
||||
onBlur={closeOnBlur}
|
||||
ref={(button) => {
|
||||
if (!menuItemRefs.current?.includes(button) && button) {
|
||||
menuItemRefs.current.push(button);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{group.icon && (
|
||||
<Icon
|
||||
type={group.icon}
|
||||
className={`mr-2 ${group.iconClassName}`}
|
||||
/>
|
||||
)}
|
||||
<div className="font-semibold text-input">
|
||||
{group.title}
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
type="chevron-down"
|
||||
className={`sn-dropdown-arrow color-grey-1 ${
|
||||
activeGroupId === groupId && 'sn-dropdown-arrow-flipped'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
id={contentId}
|
||||
aria-labelledby={buttonId}
|
||||
className={activeGroupId !== groupId ? 'hidden' : ''}
|
||||
>
|
||||
<div role="radiogroup">
|
||||
{group.items.map((item) => {
|
||||
return (
|
||||
<button
|
||||
role="radio"
|
||||
onClick={() => {
|
||||
selectEditor(item);
|
||||
}}
|
||||
className={`sn-dropdown-item py-2 text-input focus:bg-info-backdrop focus:shadow-none ${
|
||||
item.isPremiumFeature && 'justify-between'
|
||||
}`}
|
||||
aria-checked={false}
|
||||
onBlur={closeOnBlur}
|
||||
ref={(button) => {
|
||||
if (
|
||||
!menuItemRefs.current?.includes(button) &&
|
||||
button
|
||||
) {
|
||||
menuItemRefs.current.push(button);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`pseudo-radio-btn ${
|
||||
isSelectedEditor(item)
|
||||
? 'pseudo-radio-btn--checked'
|
||||
: ''
|
||||
} ml-0.5 mr-2`}
|
||||
></div>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.isPremiumFeature && (
|
||||
<Icon type="premium-feature" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-1px my-1 bg-border hide-if-last-child"></div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
ComponentArea,
|
||||
FeatureDescription,
|
||||
Features,
|
||||
NoteType,
|
||||
} from '@standardnotes/features';
|
||||
import { ContentType, SNComponent } from '@standardnotes/snjs';
|
||||
import { EditorMenuItem, EditorMenuGroup } from '../ChangeEditorOption';
|
||||
|
||||
/** @todo Implement interchangeable alert */
|
||||
|
||||
export const PLAIN_EDITOR_NAME = 'Plain Editor';
|
||||
|
||||
type EditorGroup = NoteType | 'plain' | 'others';
|
||||
|
||||
const getEditorGroup = (
|
||||
featureDescription: FeatureDescription
|
||||
): EditorGroup => {
|
||||
if (featureDescription.note_type) {
|
||||
return featureDescription.note_type;
|
||||
} else if (featureDescription.file_type) {
|
||||
switch (featureDescription.file_type) {
|
||||
case 'txt':
|
||||
return 'plain';
|
||||
case 'html':
|
||||
return NoteType.RichText;
|
||||
case 'md':
|
||||
return NoteType.Markdown;
|
||||
default:
|
||||
return 'others';
|
||||
}
|
||||
}
|
||||
return 'others';
|
||||
};
|
||||
|
||||
export const createEditorMenuGroups = (editors: SNComponent[]) => {
|
||||
const editorItems: Record<EditorGroup, EditorMenuItem[]> = {
|
||||
plain: [
|
||||
{
|
||||
name: PLAIN_EDITOR_NAME,
|
||||
},
|
||||
],
|
||||
'rich-text': [],
|
||||
markdown: [],
|
||||
task: [],
|
||||
code: [],
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
};
|
||||
|
||||
Features.filter(
|
||||
(feature) =>
|
||||
feature.content_type === ContentType.Component &&
|
||||
feature.area === ComponentArea.Editor
|
||||
).forEach((editorFeature) => {
|
||||
if (
|
||||
!editors.find((editor) => editor.identifier === editorFeature.identifier)
|
||||
) {
|
||||
editorItems[getEditorGroup(editorFeature)].push({
|
||||
name: editorFeature.name as string,
|
||||
isPremiumFeature: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editors.forEach((editor) => {
|
||||
const editorItem: EditorMenuItem = {
|
||||
name: editor.name,
|
||||
component: editor,
|
||||
};
|
||||
|
||||
editorItems[getEditorGroup(editor.package_info)].push(editorItem);
|
||||
});
|
||||
|
||||
const editorMenuGroups: EditorMenuGroup[] = [
|
||||
{
|
||||
icon: 'plain-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Plain text',
|
||||
items: editorItems.plain,
|
||||
},
|
||||
{
|
||||
icon: 'rich-text',
|
||||
iconClassName: 'color-accessory-tint-1',
|
||||
title: 'Rich text',
|
||||
items: editorItems['rich-text'],
|
||||
},
|
||||
{
|
||||
icon: 'markdown',
|
||||
iconClassName: 'color-accessory-tint-2',
|
||||
title: 'Markdown text',
|
||||
items: editorItems.markdown,
|
||||
},
|
||||
{
|
||||
icon: 'tasks',
|
||||
iconClassName: 'color-accessory-tint-3',
|
||||
title: 'Todo',
|
||||
items: editorItems.task,
|
||||
},
|
||||
{
|
||||
icon: 'code',
|
||||
iconClassName: 'color-accessory-tint-4',
|
||||
title: 'Code',
|
||||
items: editorItems.code,
|
||||
},
|
||||
{
|
||||
icon: 'spreadsheets',
|
||||
iconClassName: 'color-accessory-tint-5',
|
||||
title: 'Spreadsheet',
|
||||
items: editorItems.spreadsheet,
|
||||
},
|
||||
{
|
||||
icon: 'authenticator',
|
||||
iconClassName: 'color-accessory-tint-6',
|
||||
title: 'Authentication',
|
||||
items: editorItems.authentication,
|
||||
},
|
||||
{
|
||||
icon: 'editor',
|
||||
iconClassName: 'color-neutral',
|
||||
title: 'Others',
|
||||
items: editorItems.others,
|
||||
},
|
||||
];
|
||||
|
||||
return editorMenuGroups;
|
||||
};
|
||||
Reference in New Issue
Block a user