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:
Aman Harwara
2022-01-29 01:53:39 +05:30
committed by GitHub
parent 6aa926c2b0
commit b932e2a45e
21 changed files with 932 additions and 384 deletions

View File

@@ -64,7 +64,6 @@ import {
} from './directives/functional';
import {
ActionsMenu,
EditorMenu,
HistoryMenu,
InputModal,
MenuRow,
@@ -160,7 +159,6 @@ const startApplication: StartApplication = async function startApplication(
.directive('actionsMenu', () => new ActionsMenu())
.directive('challengeModal', () => new ChallengeModal())
.directive('componentView', ComponentViewDirective)
.directive('editorMenu', () => new EditorMenu())
.directive('inputModal', () => new InputModal())
.directive('menuRow', () => new MenuRow())
.directive('panelResizer', () => new PanelResizer())

View File

@@ -1,3 +1,4 @@
import EditorIcon from '../../icons/ic-editor.svg';
import PremiumFeatureIcon from '../../icons/ic-premium-feature.svg';
import PencilOffIcon from '../../icons/ic-pencil-off.svg';
import PlainTextIcon from '../../icons/ic-text-paragraph.svg';
@@ -68,6 +69,7 @@ import { toDirective } from './utils';
import { FunctionalComponent } from 'preact';
const ICONS = {
'editor': EditorIcon,
'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-right': MenuArrowRight,
notes: NotesIcon,

View File

@@ -1,7 +1,7 @@
import { AppState } from '@/ui_models/app_state';
import { toDirective, useCloseOnBlur, useCloseOnClickOutside } from './utils';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { NotesOptions } from './NotesOptions/NotesOptions';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
@@ -11,21 +11,16 @@ type Props = {
};
const NotesContextMenu = observer(({ application, appState }: Props) => {
const {
contextMenuOpen,
contextMenuPosition,
contextMenuMaxHeight,
} = appState.notes;
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
appState.notes;
const contextMenuRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) =>
appState.notes.setContextMenuOpen(open)
);
useCloseOnClickOutside(
contextMenuRef as any,
(open: boolean) => appState.notes.setContextMenuOpen(open)
useCloseOnClickOutside(contextMenuRef, (open: boolean) =>
appState.notes.setContextMenuOpen(open)
);
const reloadContextMenuLayout = useCallback(() => {

View File

@@ -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>
);
};

View File

@@ -1,6 +1,6 @@
import { AppState } from '@/ui_models/app_state';
import { Icon } from './Icon';
import { Switch } from './Switch';
import { Icon } from '../Icon';
import { Switch } from '../Switch';
import { observer } from 'mobx-react-lite';
import { useRef, useState, useEffect, useMemo } from 'preact/hooks';
import {
@@ -8,12 +8,17 @@ import {
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { SNApplication, SNNote } from '@standardnotes/snjs/dist/@types';
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';
type Props = {
export type NotesOptionsProps = {
application: WebApplication;
appState: AppState;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
@@ -21,7 +26,7 @@ type Props = {
};
type DeletePermanentlyButtonProps = {
closeOnBlur: Props['closeOnBlur'];
closeOnBlur: NotesOptionsProps['closeOnBlur'];
onClick: () => void;
};
@@ -86,7 +91,10 @@ const formatDate = (date: Date | undefined) => {
return `${date.toDateString()} ${date.toLocaleTimeString()}`;
};
const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNote }> = ({ application, note }) => {
const NoteAttributes: FunctionComponent<{
application: SNApplication;
note: SNNote;
}> = ({ application, note }) => {
const { words, characters, paragraphs } = useMemo(
() => countNoteAttributes(note.text),
[note.text]
@@ -136,15 +144,19 @@ const NoteAttributes: FunctionComponent<{ application: SNApplication, note: SNNo
};
const SpellcheckOptions: FunctionComponent<{
appState: AppState, note: SNNote
appState: AppState;
note: SNNote;
}> = ({ appState, note }) => {
const editor = appState.application.componentManager.editorForNote(note);
const spellcheckControllable = Boolean(
!editor ||
appState.application.getFeature(editor.identifier)?.spellcheckControl
appState.application.getFeature(editor.identifier)?.spellcheckControl
);
const noteSpellcheck = !spellcheckControllable ? true : note ? appState.notes.getSpellcheckStateForNote(note) : undefined;
const noteSpellcheck = !spellcheckControllable
? true
: note
? appState.notes.getSpellcheckStateForNote(note)
: undefined;
return (
<div className="flex flex-col px-3 py-1.5">
@@ -157,19 +169,26 @@ const SpellcheckOptions: FunctionComponent<{
}}
>
<span className="flex items-center">
<Icon type='spellcheck' className={iconClass} />
<Icon type="spellcheck" className={iconClass} />
Spellcheck
</span>
</Switch>
{!spellcheckControllable && (
<p className="text-xs pt-1.5">Spellcheck cannot be controlled for this editor.</p>
<p className="text-xs pt-1.5">
Spellcheck cannot be controlled for this editor.
</p>
)}
</div>
);
};
export const NotesOptions = observer(
({ application, appState, closeOnBlur, onSubmenuChange }: Props) => {
({
application,
appState,
closeOnBlur,
onSubmenuChange,
}: NotesOptionsProps) => {
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
const [tagsMenuPosition, setTagsMenuPosition] = useState<{
top: number;
@@ -232,25 +251,39 @@ export const NotesOptions = observer(
const defaultFontSize = window.getComputedStyle(
document.documentElement
).fontSize;
const maxTagsMenuSize = parseFloat(defaultFontSize) * 30;
const maxTagsMenuSize =
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = tagsButtonRef.current!.getBoundingClientRect();
const footerHeight = 32;
const buttonRect = tagsButtonRef.current?.getBoundingClientRect();
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (buttonRect.top + maxTagsMenuSize > clientHeight - footerHeight) {
setTagsMenuMaxHeight(clientHeight - buttonRect.top - footerHeight - 2);
}
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,
});
if (buttonRect.right + maxTagsMenuSize > clientWidth) {
setTagsMenuPosition({
top: buttonRect.top,
right: clientWidth - buttonRect.left,
});
} else {
setTagsMenuPosition({
top: buttonRect.top,
left: buttonRect.right,
});
}
}
setTagsMenuOpen(!tagsMenuOpen);
@@ -360,7 +393,7 @@ export const NotesOptions = observer(
onKeyDown={(event) => {
if (event.key === 'Escape') {
setTagsMenuOpen(false);
tagsButtonRef.current!.focus();
tagsButtonRef.current?.focus();
}
}}
style={{
@@ -383,9 +416,10 @@ export const NotesOptions = observer(
>
<span
className={`whitespace-nowrap overflow-hidden overflow-ellipsis
${appState.notes.isTagInSelectedNotes(tag)
? 'font-bold'
: ''
${
appState.notes.isTagInSelectedNotes(tag)
? 'font-bold'
: ''
}`}
>
{tag.title}
@@ -516,16 +550,18 @@ export const NotesOptions = observer(
</button>
</>
)}
{notes.length === 1 ? (
<>
<div className="min-h-1px my-2 bg-border"></div>
<SpellcheckOptions appState={appState} note={notes[0]} />
<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}

View File

@@ -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>
);
})}
</>
);
};

View File

@@ -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;
};

View File

@@ -9,7 +9,7 @@ import {
} from '@reach/disclosure';
import { useRef, useState } from 'preact/hooks';
import { observer } from 'mobx-react-lite';
import { NotesOptions } from './NotesOptions';
import { NotesOptions } from './NotesOptions/NotesOptions';
import { WebApplication } from '@/ui_models/application';
type Props = {
@@ -17,76 +17,85 @@ type Props = {
appState: AppState;
};
export const NotesOptionsPanel = observer(({ application, appState }: Props) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef as any, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
export const NotesOptionsPanel = observer(
({ application, appState }: Props) => {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
right: 0,
});
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto');
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen);
const [submenuOpen, setSubmenuOpen] = useState(false);
const onSubmenuChange = (open: boolean) => {
setSubmenuOpen(open);
};
const onSubmenuChange = (open: boolean) => {
setSubmenuOpen(open);
};
return (
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current!.getBoundingClientRect();
const { clientHeight } = document.documentElement;
const footerHeight = 32;
setMaxHeight(clientHeight - rect.bottom - footerHeight - 2);
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
setOpen(!open);
}}
>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
return (
<Disclosure
open={open}
onChange={() => {
const rect = buttonRef.current?.getBoundingClientRect();
if (rect) {
const { clientHeight } = document.documentElement;
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - 2);
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
});
setOpen(!open);
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button"
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current!.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{open && (
<NotesOptions
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
</Disclosure>
);
});
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="sn-icon-button"
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape' && !submenuOpen) {
setOpen(false);
buttonRef.current?.focus();
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="sn-dropdown sn-dropdown--animated min-w-80 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
onBlur={closeOnBlur}
>
{open && (
<NotesOptions
application={application}
appState={appState}
closeOnBlur={closeOnBlur}
onSubmenuChange={onSubmenuChange}
/>
)}
</DisclosurePanel>
</Disclosure>
);
}
);
export const NotesOptionsPanelDirective = toDirective<Props>(NotesOptionsPanel);

View File

@@ -8,7 +8,7 @@ import { StateUpdater, useCallback, useState, useEffect } from 'preact/hooks';
* monitored.
*/
export function useCloseOnBlur(
container: { current?: HTMLDivElement },
container: { current?: HTMLDivElement | null },
setOpen: (open: boolean) => void
): [
(event: { relatedTarget: EventTarget | null }) => void,

View File

@@ -1,81 +0,0 @@
import { WebDirective } from './../../types';
import { WebApplication } from '@/ui_models/application';
import { SNComponent, SNItem, ComponentArea } from '@standardnotes/snjs';
import { isDesktopApplication } from '@/utils';
import template from '%/directives/editor-menu.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
interface EditorMenuScope {
callback: (component: SNComponent) => void;
selectedEditorUuid: string;
currentItem: SNItem;
application: WebApplication;
}
class EditorMenuCtrl extends PureViewCtrl implements EditorMenuScope {
callback!: () => (component: SNComponent) => void;
selectedEditorUuid!: string;
currentItem!: SNItem;
application!: WebApplication;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
this.state = {
isDesktop: isDesktopApplication(),
};
}
public isEditorSelected(editor: SNComponent) {
if (!this.selectedEditorUuid) {
return false;
}
return this.selectedEditorUuid === editor.uuid;
}
$onInit() {
super.$onInit();
const editors = this.application.componentManager
.componentsForArea(ComponentArea.Editor)
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
});
this.setState({
editors: editors,
});
}
selectComponent(component: SNComponent) {
if (component) {
if (component.conflictOf) {
this.application.changeAndSaveItem(component.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
}
this.$timeout(() => {
this.callback()(component);
});
}
offlineAvailableForComponent(component: SNComponent) {
return component.local_url && this.state.isDesktop;
}
}
export class EditorMenu extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.template = template;
this.controller = EditorMenuCtrl;
this.controllerAs = 'self';
this.bindToController = true;
this.scope = {
callback: '&',
selectedEditorUuid: '=',
currentItem: '=',
application: '=',
};
}
}

View File

@@ -1,5 +1,4 @@
export { ActionsMenu } from './actionsMenu';
export { EditorMenu } from './editorMenu';
export { InputModal } from './inputModal';
export { MenuRow } from './menuRow';
export { PanelResizer } from './panelResizer';

View File

@@ -5,6 +5,7 @@ export enum KeyboardKey {
Up = 'ArrowUp',
Down = 'ArrowDown',
Enter = 'Enter',
Escape = 'Escape',
}
export enum KeyboardModifier {

View File

@@ -1,6 +1,7 @@
import { confirmDialog } from '@/services/alertService';
import { KeyboardModifier } from '@/services/ioService';
import { StringEmptyTrash, Strings, StringUtils } from '@/strings';
import { MENU_MARGIN_FROM_APP_BORDER } from '@/views/constants';
import {
UuidString,
SNNote,
@@ -205,32 +206,39 @@ export class NotesState {
document.documentElement
).fontSize;
const maxContextMenuHeight = parseFloat(defaultFontSize) * 30;
const footerHeight = 32;
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height;
// Open up-bottom is default behavior
let openUpBottom = true;
const bottomSpace =
clientHeight - footerHeight - this.contextMenuClickLocation.y;
const upSpace = this.contextMenuClickLocation.y;
if (footerHeightInPx) {
const bottomSpace =
clientHeight - footerHeightInPx - this.contextMenuClickLocation.y;
const upSpace = this.contextMenuClickLocation.y;
// If not enough space to open up-bottom
if (maxContextMenuHeight > bottomSpace) {
// If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) {
openUpBottom = false;
this.setContextMenuMaxHeight('auto');
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else {
if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight(upSpace - 2);
// If not enough space to open up-bottom
if (maxContextMenuHeight > bottomSpace) {
// If there's enough space, open bottom-up
if (upSpace > maxContextMenuHeight) {
openUpBottom = false;
this.setContextMenuMaxHeight('auto');
// Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space
} else {
this.setContextMenuMaxHeight(bottomSpace - 2);
if (upSpace > bottomSpace) {
this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER);
openUpBottom = false;
} else {
this.setContextMenuMaxHeight(
bottomSpace - MENU_MARGIN_FROM_APP_BORDER
);
}
}
} else {
this.setContextMenuMaxHeight('auto');
}
} else {
this.setContextMenuMaxHeight('auto');
}
if (openUpBottom) {

View File

@@ -2,3 +2,5 @@ export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_NAVIGATION = 'navigation';
export const EMAIL_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
export const MENU_MARGIN_FROM_APP_BORDER = 5;
export const MAX_MENU_SIZE_MULTIPLIER = 30;

View File

@@ -63,20 +63,6 @@
.sn-component(ng-if='self.note')
#editor-menu-bar.sk-app-bar.no-edges
.left
.sk-app-bar-item(
click-outside=`self.setMenuState('showEditorMenu', false)`
is-open='self.state.showEditorMenu',
ng-class="{'selected' : self.state.showEditorMenu}",
ng-click="self.toggleMenu('showEditorMenu')"
)
.sk-label Editor
editor-menu(
callback='self.editorMenuOnSelect',
current-item='self.note',
ng-if='self.state.showEditorMenu',
selected-editor-uuid='self.state.editorComponentViewer && self.state.editorComponentViewer.component.uuid',
application='self.application'
)
.sk-app-bar-item(
click-outside=`self.setMenuState('showActionsMenu', false)`,
is-open='self.state.showActionsMenu',

View File

@@ -8,7 +8,6 @@ import {
ContentType,
SNComponent,
SNNote,
NoteMutator,
ComponentArea,
PrefKey,
ComponentMutator,
@@ -28,7 +27,6 @@ import { EventSource } from '@/ui_models/app_state';
import {
STRING_DELETE_PLACEHOLDER_ATTEMPT,
STRING_DELETE_LOCKED_ATTEMPT,
STRING_EDIT_LOCKED_ATTEMPT,
StringDeleteNote,
} from '@/strings';
import { confirmDialog } from '@/services/alertService';
@@ -59,7 +57,6 @@ type EditorState = {
isDesktop?: boolean;
syncTakingTooLong: boolean;
showActionsMenu: boolean;
showEditorMenu: boolean;
showHistoryMenu: boolean;
spellcheck: boolean;
/** Setting to true then false will allow the main content textarea to be destroyed
@@ -83,6 +80,46 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] {
);
}
export const transactionForAssociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeDisassociatedItemId(note.uuid);
mutator.associateWithItem(note.uuid);
},
};
return transaction;
};
export const transactionForDisassociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeAssociatedItemId(note.uuid);
mutator.disassociateWithItem(note.uuid);
},
};
return transaction;
};
export const reloadFont = (monospaceFont?: boolean) => {
const root = document.querySelector(':root') as HTMLElement;
const propertyName = '--sn-stylekit-editor-font-family';
if (monospaceFont) {
root.style.setProperty(propertyName, 'var(--sn-stylekit-monospace-font)');
} else {
root.style.setProperty(propertyName, 'var(--sn-stylekit-sans-serif-font)');
}
};
export class NoteView extends PureViewCtrl<unknown, EditorState> {
/** Passed through template */
readonly application!: WebApplication;
@@ -114,7 +151,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
onReady: () => this.reloadPreferences(),
};
this.editorMenuOnSelect = this.editorMenuOnSelect.bind(this);
this.onPanelResizeFinish = this.onPanelResizeFinish.bind(this);
this.setScrollPosition = this.setScrollPosition.bind(this);
this.resetScrollPosition = this.resetScrollPosition.bind(this);
@@ -146,7 +182,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
this.onEditorComponentLoad = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as unknown) = undefined;
super.deinit();
}
@@ -248,7 +283,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
spellcheck: true,
syncTakingTooLong: false,
showActionsMenu: false,
showEditorMenu: false,
showHistoryMenu: false,
noteStatus: undefined,
textareaUnloading: false,
@@ -441,7 +475,7 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
editorStateDidLoad: true,
});
}
this.reloadFont();
reloadFont(this.state.monospaceFont);
} else {
await this.setState({
editorStateDidLoad: true,
@@ -462,7 +496,7 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
}
closeAllMenus(exclude?: string) {
const allMenus = ['showEditorMenu', 'showActionsMenu', 'showHistoryMenu'];
const allMenus = ['showActionsMenu', 'showHistoryMenu'];
const menuState: any = {};
for (const candidate of allMenus) {
if (candidate !== exclude) {
@@ -472,69 +506,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
this.setState(menuState);
}
async editorMenuOnSelect(component?: SNComponent) {
const transactions: TransactionalMutation[] = [];
this.setMenuState('showEditorMenu', false);
if (this.appState.getActiveNoteController()?.isTemplateNote) {
await this.appState.getActiveNoteController().insertTemplatedNote();
}
if (this.note.locked) {
this.application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
return;
}
if (!component) {
if (!this.note.prefersPlainEditor) {
transactions.push({
itemUuid: this.note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = true;
},
});
}
if (
this.state.editorComponentViewer?.component.isExplicitlyEnabledForItem(
this.note.uuid
)
) {
transactions.push(
this.transactionForDisassociateComponentWithCurrentNote(
this.state.editorComponentViewer.component
)
);
}
this.reloadFont();
} else if (component.area === ComponentArea.Editor) {
const currentEditor = this.state.editorComponentViewer?.component;
if (currentEditor && component.uuid !== currentEditor.uuid) {
transactions.push(
this.transactionForDisassociateComponentWithCurrentNote(currentEditor)
);
}
const prefersPlain = this.note.prefersPlainEditor;
if (prefersPlain) {
transactions.push({
itemUuid: this.note.uuid,
mutate: (m: ItemMutator) => {
const noteMutator = m as NoteMutator;
noteMutator.prefersPlainEditor = false;
},
});
}
transactions.push(
this.transactionForAssociateComponentWithCurrentNote(component)
);
}
await this.application.runTransactionalMutations(transactions);
/** Dirtying can happen above */
this.application.sync();
}
hasAvailableExtensions() {
return (
this.application.actionsManager.extensionsInContextOfItem(this.note)
@@ -702,7 +673,7 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
if (spellcheck !== this.state.spellcheck) {
await this.setState({ textareaUnloading: true });
await this.setState({ textareaUnloading: false });
this.reloadFont();
reloadFont(this.state.monospaceFont);
await this.setState({
spellcheck,
@@ -733,7 +704,7 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
return;
}
this.reloadFont();
reloadFont(this.state.monospaceFont);
if (
this.state.marginResizersEnabled &&
@@ -753,19 +724,6 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
}
}
reloadFont() {
const root = document.querySelector(':root') as HTMLElement;
const propertyName = '--sn-stylekit-editor-font-family';
if (this.state.monospaceFont) {
root.style.setProperty(propertyName, 'var(--sn-stylekit-monospace-font)');
} else {
root.style.setProperty(
propertyName,
'var(--sn-stylekit-sans-serif-font)'
);
}
}
/** @components */
registerComponentManagerEventObserver() {
@@ -844,42 +802,16 @@ export class NoteView extends PureViewCtrl<unknown, EditorState> {
async disassociateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
this.transactionForDisassociateComponentWithCurrentNote(component)
transactionForDisassociateComponentWithCurrentNote(component, this.note)
);
}
transactionForDisassociateComponentWithCurrentNote(component: SNComponent) {
const note = this.note;
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeAssociatedItemId(note.uuid);
mutator.disassociateWithItem(note.uuid);
},
};
return transaction;
}
async associateComponentWithCurrentNote(component: SNComponent) {
return this.application.runTransactionalMutation(
this.transactionForAssociateComponentWithCurrentNote(component)
transactionForAssociateComponentWithCurrentNote(component, this.note)
);
}
transactionForAssociateComponentWithCurrentNote(component: SNComponent) {
const note = this.note;
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator;
mutator.removeDisassociatedItemId(note.uuid);
mutator.associateWithItem(note.uuid);
},
};
return transaction;
}
registerKeyboardShortcuts() {
this.removeTrashKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Backspace,