feat: add "Listed actions" option in note context menu (#891)

This commit is contained in:
Aman Harwara
2022-02-23 20:51:34 +05:30
committed by GitHub
parent 5265a0d010
commit 209bd99fe5
7 changed files with 392 additions and 402 deletions

View File

@@ -1,329 +0,0 @@
import { WebApplication } from '@/ui_models/application';
import {
Action,
SNActionsExtension,
UuidString,
SNNote,
ListedAccount,
} from '@standardnotes/snjs';
import { ActionResponse } from '@standardnotes/snjs';
import { render } from 'preact';
import { PureComponent } from './Abstract/PureComponent';
import { MenuRow } from './MenuRow';
import { RevisionPreviewModal } from './RevisionPreviewModal';
type ActionRow = Action & {
running?: boolean;
spinnerClass?: string;
subtitle?: string;
};
type MenuSection = {
uuid: UuidString;
name: string;
loading?: boolean;
error?: boolean;
hidden?: boolean;
deprecation?: string;
extension?: SNActionsExtension;
rows?: ActionRow[];
listedAccount?: ListedAccount;
};
type State = {
menuSections: MenuSection[];
selectedActionIdentifier?: string;
};
type Props = {
application: WebApplication;
note: SNNote;
};
export class ActionsMenu extends PureComponent<Props, State> {
constructor(props: Props) {
super(props, props.application);
this.state = {
menuSections: [],
};
this.loadExtensions();
}
private async loadExtensions(): Promise<void> {
const unresolvedListedSections =
await this.getNonresolvedListedMenuSections();
const unresolvedGenericSections =
await this.getNonresolvedGenericMenuSections();
this.setState(
{
menuSections: unresolvedListedSections.concat(
unresolvedGenericSections
),
},
() => {
this.state.menuSections.forEach((menuSection) => {
this.resolveMenuSection(menuSection);
});
}
);
}
private async getNonresolvedGenericMenuSections(): Promise<MenuSection[]> {
const genericExtensions = this.props.application.actionsManager
.getExtensions()
.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1;
});
return genericExtensions.map((extension) => {
const menuSection: MenuSection = {
uuid: extension.uuid,
name: extension.name,
extension: extension,
loading: true,
hidden: this.appState.actionsMenu.hiddenSections[extension.uuid],
};
return menuSection;
});
}
private async getNonresolvedListedMenuSections(): Promise<MenuSection[]> {
const listedAccountEntries =
await this.props.application.getListedAccounts();
return listedAccountEntries.map((entry) => {
const menuSection: MenuSection = {
uuid: entry.authorId,
name: `Listed ${entry.authorId}`,
loading: true,
listedAccount: entry,
hidden: this.appState.actionsMenu.hiddenSections[entry.authorId],
};
return menuSection;
});
}
private resolveMenuSection(menuSection: MenuSection): void {
if (menuSection.listedAccount) {
this.props.application
.getListedAccountInfo(menuSection.listedAccount, this.props.note.uuid)
.then((accountInfo) => {
if (!accountInfo) {
this.promoteMenuSection({
...menuSection,
loading: false,
});
return;
}
const existingMenuSection = this.state.menuSections.find(
(item) => item.uuid === menuSection.listedAccount?.authorId
) as MenuSection;
const resolvedMenuSection: MenuSection = {
...existingMenuSection,
loading: false,
error: false,
name: accountInfo.display_name,
rows: accountInfo?.actions,
};
this.promoteMenuSection(resolvedMenuSection);
});
} else if (menuSection.extension) {
this.props.application.actionsManager
.loadExtensionInContextOfItem(menuSection.extension, this.props.note)
.then((resolvedExtension) => {
if (!resolvedExtension) {
this.promoteMenuSection({
...menuSection,
loading: false,
});
return;
}
const actions = resolvedExtension.actionsWithContextForItem(
this.props.note
);
const resolvedMenuSection: MenuSection = {
...menuSection,
rows: actions,
deprecation: resolvedExtension.deprecation,
loading: false,
error: false,
};
this.promoteMenuSection(resolvedMenuSection);
});
}
}
private promoteMenuSection(newItem: MenuSection): void {
const menuSections = this.state.menuSections.map((menuSection) => {
if (menuSection.uuid === newItem.uuid) {
return newItem;
} else {
return menuSection;
}
});
this.setState({ menuSections });
}
private promoteAction(newAction: Action, section: MenuSection): void {
const newSection: MenuSection = {
...section,
rows: section.rows?.map((action) => {
if (action.url === newAction.url) {
return newAction;
} else {
return action;
}
}),
};
this.promoteMenuSection(newSection);
}
private idForAction(action: Action) {
return `${action.label}:${action.verb}:${action.desc}`;
}
executeAction = async (action: Action, section: MenuSection) => {
const isLegacyNoteHistoryExt = action.verb === 'nested';
if (isLegacyNoteHistoryExt) {
const showRevisionAction = action.subactions![0];
action = showRevisionAction;
}
this.promoteAction(
{
...action,
running: true,
},
section
);
const response = await this.props.application.actionsManager.runAction(
action,
this.props.note
);
this.promoteAction(
{
...action,
running: false,
},
section
);
if (!response || response.error) {
return;
}
this.handleActionResponse(action, response);
this.resolveMenuSection(section);
};
handleActionResponse(action: Action, result: ActionResponse) {
switch (action.verb) {
case 'render': {
const item = result.item;
render(
<RevisionPreviewModal
application={this.application}
uuid={item.uuid}
content={item.content}
/>,
document.body.appendChild(document.createElement('div'))
);
}
}
}
public toggleSectionVisibility(menuSection: MenuSection) {
this.appState.actionsMenu.toggleSectionVisibility(menuSection.uuid);
this.promoteMenuSection({
...menuSection,
hidden: !menuSection.hidden,
});
}
renderMenuSection(section: MenuSection) {
return (
<div>
<div
key={section.uuid}
className="sk-menu-panel-header"
onClick={($event) => {
this.toggleSectionVisibility(section);
$event.stopPropagation();
}}
>
<div className="sk-menu-panel-column">
<div className="sk-menu-panel-header-title">{section.name}</div>
{section.hidden && <div></div>}
{section.deprecation && !section.hidden && (
<div className="sk-menu-panel-header-subtitle">
{section.deprecation}
</div>
)}
</div>
{section.loading && <div className="sk-spinner small loading" />}
</div>
<div>
{section.error && !section.hidden && (
<MenuRow
faded={true}
label="Error loading actions"
subtitle="Please try again later."
/>
)}
{!section.rows?.length && !section.hidden && (
<MenuRow faded={true} label="No Actions Available" />
)}
{!section.hidden &&
!section.loading &&
!section.error &&
section.rows?.map((action, index) => {
return (
<MenuRow
key={index}
action={() => {
this.executeAction(action, section);
}}
label={action.label}
disabled={action.running}
spinnerClass={action.running ? 'info' : undefined}
subtitle={action.desc}
>
{action.access_type && (
<div className="sk-sublabel">
{'Uses '}
<strong>{action.access_type}</strong>
{' access to this note.'}
</div>
)}
</MenuRow>
);
})}
</div>
</div>
);
}
render() {
return (
<div className="sn-component">
<div className="sk-menu-panel dropdown-menu">
{this.state.menuSections.length == 0 && (
<MenuRow label="No Actions" />
)}
{this.state.menuSections.map((extension) =>
this.renderMenuSection(extension)
)}
</div>
</div>
);
}
}

View File

@@ -33,7 +33,6 @@ import { Icon } from '../Icon';
import { PinNoteButton } from '../PinNoteButton';
import { NotesOptionsPanel } from '../NotesOptionsPanel';
import { NoteTagsContainer } from '../NoteTagsContainer';
import { ActionsMenu } from '../ActionsMenu';
import { ComponentView } from '../ComponentView';
import { PanelSide, PanelResizer, PanelResizeType } from '../PanelResizer';
import { ElementIds } from '@/element_ids';
@@ -107,7 +106,6 @@ type State = {
noteLocked: boolean;
noteStatus?: NoteStatus;
saveError?: any;
showActionsMenu: boolean;
showLockedIcon: boolean;
showProtectedWarning: boolean;
spellcheck: boolean;
@@ -173,7 +171,6 @@ export class NoteView extends PureComponent<Props, State> {
lockText: 'Note Editing Disabled',
noteStatus: undefined,
noteLocked: this.controller.note.locked,
showActionsMenu: false,
showLockedIcon: true,
showProtectedWarning: false,
spellcheck: true,
@@ -319,7 +316,6 @@ export class NoteView extends PureComponent<Props, State> {
async onAppLaunch() {
await super.onAppLaunch();
this.streamItems();
this.registerComponentManagerEventObserver();
}
/** @override */
@@ -505,32 +501,6 @@ export class NoteView extends PureComponent<Props, State> {
}
}
setMenuState(menu: string, state: boolean) {
this.setState({
[menu]: state,
});
this.closeAllMenus(menu);
}
toggleMenu = (menu: keyof State) => {
this.setMenuState(menu, !this.state[menu]);
this.application.getAppState().notes.setContextMenuOpen(false);
};
closeAllMenus = (exclude?: string) => {
if (!this.state.showActionsMenu) {
return;
}
const allMenus = ['showActionsMenu'];
const menuState: any = {};
for (const candidate of allMenus) {
if (candidate !== exclude) {
menuState[candidate] = false;
}
}
this.setState(menuState);
};
hasAvailableExtensions() {
return (
this.application.actionsManager.extensionsInContextOfItem(this.note)
@@ -646,10 +616,6 @@ export class NoteView extends PureComponent<Props, State> {
document.getElementById(ElementIds.NoteTitleEditor)?.focus();
}
clickedTextArea = () => {
this.closeAllMenus();
};
onContentFocus = () => {
this.application
.getAppState()
@@ -772,18 +738,6 @@ export class NoteView extends PureComponent<Props, State> {
/** @components */
registerComponentManagerEventObserver() {
this.removeComponentManagerObserver =
this.application.componentManager.addEventObserver((eventName, data) => {
if (eventName === ComponentManagerEvent.ViewerDidFocus) {
const viewer = data?.componentViewer;
if (viewer?.component.isEditor) {
this.closeAllMenus();
}
}
});
}
async reloadStackComponents() {
const stackComponents = sortAlphabetically(
this.application.componentManager
@@ -1103,30 +1057,6 @@ export class NoteView extends PureComponent<Props, State> {
</div>
)}
{this.note && (
<div className="sn-component">
<div id="editor-menu-bar" className="sk-app-bar no-edges">
<div className="left">
<div
className={
(this.state.showActionsMenu ? 'selected' : '') +
' sk-app-bar-item'
}
onClick={() => this.toggleMenu('showActionsMenu')}
>
<div className="sk-label">Actions</div>
{this.state.showActionsMenu && (
<ActionsMenu
note={this.note}
application={this.application}
/>
)}
</div>
</div>
</div>
</div>
)}
{!this.note.errorDecrypting && (
<div
id={ElementIds.EditorContent}
@@ -1171,7 +1101,6 @@ export class NoteView extends PureComponent<Props, State> {
onChange={this.onTextAreaChange}
value={this.state.editorText}
readonly={this.state.noteLocked}
onClick={this.clickedTextArea}
onFocus={this.onContentFocus}
spellcheck={this.state.spellcheck}
ref={(ref) => this.onSystemEditorLoad(ref)}

View File

@@ -0,0 +1,299 @@
import { WebApplication } from '@/ui_models/application';
import {
calculateSubmenuStyle,
SubmenuStyle,
} from '@/utils/calculateSubmenuStyle';
import {
Disclosure,
DisclosureButton,
DisclosurePanel,
} from '@reach/disclosure';
import { Action, ListedAccount, SNNote } from '@standardnotes/snjs';
import { Fragment, FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { Icon } from '../Icon';
type Props = {
application: WebApplication;
note: SNNote;
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void;
};
type ListedMenuGroup = {
name: string;
account: ListedAccount;
actions: Action[];
};
type ListedMenuItemProps = {
action: Action;
note: SNNote;
group: ListedMenuGroup;
application: WebApplication;
reloadMenuGroup: (group: ListedMenuGroup) => Promise<void>;
};
const ListedMenuItem: FunctionComponent<ListedMenuItemProps> = ({
action,
note,
application,
group,
reloadMenuGroup,
}) => {
const [isRunning, setIsRunning] = useState(false);
const handleClick = async () => {
if (isRunning) {
return;
}
setIsRunning(true);
await application.actionsManager.runAction(action, note);
setIsRunning(false);
reloadMenuGroup(group);
};
return (
<button
key={action.url}
onClick={handleClick}
className="sn-dropdown-item flex justify-between py-2 text-input focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex flex-col">
<div className="font-semibold">{action.label}</div>
{action.access_type && (
<div className="text-xs mt-0.5 color-grey-0">
{'Uses '}
<strong>{action.access_type}</strong>
{' access to this note.'}
</div>
)}
</div>
{isRunning && <div className="sk-spinner spinner-info w-3 h-3" />}
</button>
);
};
type ListedActionsMenuProps = {
application: WebApplication;
note: SNNote;
recalculateMenuStyle: () => void;
};
const ListedActionsMenu: FunctionComponent<ListedActionsMenuProps> = ({
application,
note,
recalculateMenuStyle,
}) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([]);
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true);
const reloadMenuGroup = async (group: ListedMenuGroup) => {
const updatedAccountInfo = await application.getListedAccountInfo(
group.account,
note.uuid
);
if (!updatedAccountInfo) {
return;
}
const updatedGroup: ListedMenuGroup = {
name: updatedAccountInfo.display_name,
account: group.account,
actions: updatedAccountInfo.actions,
};
const updatedGroups = menuGroups.map((group) => {
if (updatedGroup.account.authorId === group.account.authorId) {
return updatedGroup;
} else {
return group;
}
});
setMenuGroups(updatedGroups);
};
useEffect(() => {
const fetchListedAccounts = async () => {
if (!application.hasAccount()) {
setIsFetchingAccounts(false);
return;
}
try {
const listedAccountEntries = await application.getListedAccounts();
if (!listedAccountEntries.length) {
throw new Error('No Listed accounts found');
}
const menuGroups: ListedMenuGroup[] = [];
await Promise.all(
listedAccountEntries.map(async (account) => {
const accountInfo = await application.getListedAccountInfo(
account,
note.uuid
);
if (accountInfo) {
menuGroups.push({
name: accountInfo.display_name,
account,
actions: accountInfo.actions,
});
} else {
menuGroups.push({
name: account.authorId,
account,
actions: [],
});
}
})
);
setMenuGroups(menuGroups);
} catch (err) {
console.error(err);
} finally {
setIsFetchingAccounts(false);
setTimeout(() => {
recalculateMenuStyle();
});
}
};
fetchListedAccounts();
}, [application, note.uuid, recalculateMenuStyle]);
return (
<>
{isFetchingAccounts && (
<div className="w-full flex items-center justify-center p-4">
<div className="sk-spinner w-5 h-5 spinner-info" />
</div>
)}
{!isFetchingAccounts && menuGroups.length ? (
<>
{menuGroups.map((group, index) => (
<Fragment key={group.account.authorId}>
<div
className={`w-full px-2.5 py-2 text-input font-semibold color-text border-0 border-y-1px border-solid border-main ${
index === 0 ? 'border-t-0 mb-1' : 'my-1'
}`}
>
{group.name}
</div>
{group.actions.length ? (
group.actions.map((action) => (
<ListedMenuItem
action={action}
note={note}
key={action.url}
group={group}
application={application}
reloadMenuGroup={reloadMenuGroup}
/>
))
) : (
<div className="px-3 py-2 color-grey-0 select-none">
No actions available
</div>
)}
</Fragment>
))}
</>
) : null}
{!isFetchingAccounts && !menuGroups.length ? (
<div className="w-full flex items-center justify-center px-4 py-6">
<div className="color-grey-0 select-none">
No Listed accounts found
</div>
</div>
) : null}
</>
);
};
export const ListedActionsOption: FunctionComponent<Props> = ({
application,
note,
closeOnBlur,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
});
const toggleListedMenu = () => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current);
if (menuPosition) {
setMenuStyle(menuPosition);
}
}
setIsMenuOpen(!isMenuOpen);
};
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(
menuButtonRef.current,
menuRef.current
);
if (newMenuPosition) {
setMenuStyle(newMenuPosition);
}
}, []);
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle();
});
}
}, [isMenuOpen, recalculateMenuStyle]);
return (
<Disclosure open={isMenuOpen} onChange={toggleListedMenu}>
<DisclosureButton
ref={menuButtonRef}
onBlur={closeOnBlur}
className="sn-dropdown-item justify-between"
>
<div className="flex items-center">
<Icon type="listed" className="color-neutral mr-2" />
Listed actions
</div>
<Icon type="chevron-right" className="color-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
style={{
...menuStyle,
position: 'fixed',
}}
className="sn-dropdown flex flex-col max-h-120 min-w-68 pb-1 fixed overflow-y-auto"
>
{isMenuOpen && (
<ListedActionsMenu
application={application}
note={note}
recalculateMenuStyle={recalculateMenuStyle}
/>
)}
</DisclosurePanel>
</Disclosure>
);
};

View File

@@ -18,6 +18,7 @@ import {
MAX_MENU_SIZE_MULTIPLIER,
BYTES_IN_ONE_MEGABYTE,
} from '@/constants';
import { ListedActionsOption } from './ListedActionsOption';
export type NotesOptionsProps = {
application: WebApplication;
@@ -602,6 +603,12 @@ export const NotesOptions = observer(
)}
{notes.length === 1 ? (
<>
<div className="min-h-1px my-2 bg-border"></div>
<ListedActionsOption
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>

View File

@@ -0,0 +1,77 @@
import {
MAX_MENU_SIZE_MULTIPLIER,
MENU_MARGIN_FROM_APP_BORDER,
} from '@/constants';
export type SubmenuStyle = {
top?: number | 'auto';
right?: number | 'auto';
bottom: number | 'auto';
left?: number | 'auto';
visibility?: 'hidden' | 'visible';
maxHeight: number | 'auto';
};
export const calculateSubmenuStyle = (
button: HTMLButtonElement | null,
menu?: HTMLDivElement | null
): SubmenuStyle | undefined => {
const defaultFontSize = window.getComputedStyle(
document.documentElement
).fontSize;
const maxChangeEditorMenuSize =
parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER;
const { clientWidth, clientHeight } = document.documentElement;
const buttonRect = button?.getBoundingClientRect();
const buttonParentRect = button?.parentElement?.getBoundingClientRect();
const menuBoundingRect = menu?.getBoundingClientRect();
const footerElementRect = document
.getElementById('footer-bar')
?.getBoundingClientRect();
const footerHeightInPx = footerElementRect?.height ?? 0;
let position: SubmenuStyle = {
bottom: 'auto',
maxHeight: 'auto',
};
if (buttonRect && buttonParentRect) {
let positionBottom =
clientHeight - buttonRect.bottom - buttonRect.height / 2;
if (positionBottom < footerHeightInPx) {
positionBottom = footerHeightInPx + MENU_MARGIN_FROM_APP_BORDER;
}
position = {
bottom: positionBottom,
visibility: 'hidden',
maxHeight: 'auto',
};
if (buttonRect.right + maxChangeEditorMenuSize > clientWidth) {
position.right = clientWidth - buttonRect.left;
} else {
position.left = buttonRect.right;
}
}
if (menuBoundingRect?.height && buttonRect && position.bottom !== 'auto') {
position.visibility = 'visible';
if (menuBoundingRect.y < MENU_MARGIN_FROM_APP_BORDER) {
position.bottom =
position.bottom + menuBoundingRect.y - MENU_MARGIN_FROM_APP_BORDER * 2;
}
if (footerElementRect && menuBoundingRect.height > footerElementRect.y) {
position.bottom = footerElementRect.height + MENU_MARGIN_FROM_APP_BORDER;
position.maxHeight =
clientHeight -
footerElementRect.height -
MENU_MARGIN_FROM_APP_BORDER * 2;
}
}
return position;
};

View File

@@ -34,7 +34,7 @@ $heading-height: 75px;
padding-bottom: 10px;
padding-right: 14px;
border-bottom: none;
border-bottom: 1px solid var(--sn-stylekit-border-color);
z-index: $z-index-editor-title-bar;
height: auto;
@@ -118,7 +118,6 @@ $heading-height: 75px;
border: none;
outline: none;
padding: 15px;
padding-top: 11px;
font-size: var(--sn-stylekit-font-size-editor);
resize: none;
}

View File

@@ -296,6 +296,10 @@
margin-top: 0;
}
.mt-0\.5 {
margin-top: 0.125rem;
}
.mt-2\.5 {
margin-top: 0.625rem;
}
@@ -435,6 +439,10 @@
min-height: 1.5rem;
}
.min-h-16 {
min-height: 4rem;
}
.max-h-5 {
max-height: 1.25rem;
}