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

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