Skip to content

Commit

Permalink
feat: Add new "Change Editor" option to note context menu (#823)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
amanharwara committed Jan 28, 2022
1 parent 6aa926c commit b932e2a
Show file tree
Hide file tree
Showing 21 changed files with 933 additions and 385 deletions.
3 changes: 3 additions & 0 deletions app/assets/icons/ic-editor.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 0 additions & 2 deletions app/assets/javascripts/app.ts
Expand Up @@ -64,7 +64,6 @@ import {
} from './directives/functional';
import {
ActionsMenu,
EditorMenu,
HistoryMenu,
InputModal,
MenuRow,
Expand Down Expand Up @@ -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())
Expand Down
2 changes: 2 additions & 0 deletions app/assets/javascripts/components/Icon.tsx
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 7 additions & 12 deletions app/assets/javascripts/components/NotesContextMenu.tsx
@@ -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';

Expand All @@ -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(() => {
Expand Down
288 changes: 288 additions & 0 deletions app/assets/javascripts/components/NotesOptions/ChangeEditorOption.tsx
@@ -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>
);
};

0 comments on commit b932e2a

Please sign in to comment.