diff --git a/app/assets/javascripts/components/ActionsMenu.tsx b/app/assets/javascripts/components/ActionsMenu.tsx deleted file mode 100644 index 40393a52ef5..00000000000 --- a/app/assets/javascripts/components/ActionsMenu.tsx +++ /dev/null @@ -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 { - constructor(props: Props) { - super(props, props.application); - - this.state = { - menuSections: [], - }; - - this.loadExtensions(); - } - - private async loadExtensions(): Promise { - 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 { - 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 { - 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( - , - 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 ( -
-
{ - this.toggleSectionVisibility(section); - $event.stopPropagation(); - }} - > -
-
{section.name}
- {section.hidden &&
} - {section.deprecation && !section.hidden && ( -
- {section.deprecation} -
- )} -
- - {section.loading &&
} -
- -
- {section.error && !section.hidden && ( - - )} - - {!section.rows?.length && !section.hidden && ( - - )} - - {!section.hidden && - !section.loading && - !section.error && - section.rows?.map((action, index) => { - return ( - { - this.executeAction(action, section); - }} - label={action.label} - disabled={action.running} - spinnerClass={action.running ? 'info' : undefined} - subtitle={action.desc} - > - {action.access_type && ( -
- {'Uses '} - {action.access_type} - {' access to this note.'} -
- )} -
- ); - })} -
-
- ); - } - - render() { - return ( -
-
- {this.state.menuSections.length == 0 && ( - - )} - {this.state.menuSections.map((extension) => - this.renderMenuSection(extension) - )} -
-
- ); - } -} diff --git a/app/assets/javascripts/components/NoteView/NoteView.tsx b/app/assets/javascripts/components/NoteView/NoteView.tsx index 2edd3bfa04d..49e881e45cf 100644 --- a/app/assets/javascripts/components/NoteView/NoteView.tsx +++ b/app/assets/javascripts/components/NoteView/NoteView.tsx @@ -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 { 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 { async onAppLaunch() { await super.onAppLaunch(); this.streamItems(); - this.registerComponentManagerEventObserver(); } /** @override */ @@ -505,32 +501,6 @@ export class NoteView extends PureComponent { } } - 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 { document.getElementById(ElementIds.NoteTitleEditor)?.focus(); } - clickedTextArea = () => { - this.closeAllMenus(); - }; - onContentFocus = () => { this.application .getAppState() @@ -772,18 +738,6 @@ export class NoteView extends PureComponent { /** @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 {
)} - {this.note && ( -
-
-
-
this.toggleMenu('showActionsMenu')} - > -
Actions
- {this.state.showActionsMenu && ( - - )} -
-
-
-
- )} - {!this.note.errorDecrypting && (
{ 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)} diff --git a/app/assets/javascripts/components/NotesOptions/ListedActionsOption.tsx b/app/assets/javascripts/components/NotesOptions/ListedActionsOption.tsx new file mode 100644 index 00000000000..52e8615d4a6 --- /dev/null +++ b/app/assets/javascripts/components/NotesOptions/ListedActionsOption.tsx @@ -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; +}; + +const ListedMenuItem: FunctionComponent = ({ + 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 ( + + ); +}; + +type ListedActionsMenuProps = { + application: WebApplication; + note: SNNote; + recalculateMenuStyle: () => void; +}; + +const ListedActionsMenu: FunctionComponent = ({ + application, + note, + recalculateMenuStyle, +}) => { + const [menuGroups, setMenuGroups] = useState([]); + 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 && ( +
+
+
+ )} + {!isFetchingAccounts && menuGroups.length ? ( + <> + {menuGroups.map((group, index) => ( + +
+ {group.name} +
+ {group.actions.length ? ( + group.actions.map((action) => ( + + )) + ) : ( +
+ No actions available +
+ )} +
+ ))} + + ) : null} + {!isFetchingAccounts && !menuGroups.length ? ( +
+
+ No Listed accounts found +
+
+ ) : null} + + ); +}; + +export const ListedActionsOption: FunctionComponent = ({ + application, + note, + closeOnBlur, +}) => { + const menuRef = useRef(null); + const menuButtonRef = useRef(null); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [menuStyle, setMenuStyle] = useState({ + 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 ( + + +
+ + Listed actions +
+ +
+ + {isMenuOpen && ( + + )} + +
+ ); +}; diff --git a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx index 323b1274651..8a00573b297 100644 --- a/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx +++ b/app/assets/javascripts/components/NotesOptions/NotesOptions.tsx @@ -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 ? ( <> +
+
diff --git a/app/assets/javascripts/utils/calculateSubmenuStyle.tsx b/app/assets/javascripts/utils/calculateSubmenuStyle.tsx new file mode 100644 index 00000000000..f6791f2a4e5 --- /dev/null +++ b/app/assets/javascripts/utils/calculateSubmenuStyle.tsx @@ -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; +}; diff --git a/app/assets/stylesheets/_editor.scss b/app/assets/stylesheets/_editor.scss index 61629478ab0..28ca04fe8a2 100644 --- a/app/assets/stylesheets/_editor.scss +++ b/app/assets/stylesheets/_editor.scss @@ -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; } diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 10b7171b497..29094ef7007 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -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; }