feat: responsive popovers & menus (#1323)

This commit is contained in:
Aman Harwara
2022-07-21 02:20:14 +05:30
committed by GitHub
parent baf7fb0019
commit 2573407851
44 changed files with 1308 additions and 1415 deletions

View File

@@ -1,26 +1,24 @@
import { observer } from 'mobx-react-lite'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { WebApplication } from '@/Application/Application'
import { useCallback, useRef, FunctionComponent, KeyboardEventHandler } from 'react'
import { useCallback, FunctionComponent, KeyboardEventHandler } from 'react'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { AccountMenuPane } from './AccountMenuPane'
import MenuPaneSelector from './MenuPaneSelector'
type Props = {
export type AccountMenuProps = {
viewControllerManager: ViewControllerManager
application: WebApplication
onClickOutside: () => void
mainApplicationGroup: ApplicationGroup
}
const AccountMenu: FunctionComponent<Props> = ({
const AccountMenu: FunctionComponent<AccountMenuProps> = ({
application,
viewControllerManager,
onClickOutside,
mainApplicationGroup,
}) => {
const { currentPane, shouldAnimateCloseMenu } = viewControllerManager.accountMenuController
const { currentPane } = viewControllerManager.accountMenuController
const closeAccountMenu = useCallback(() => {
viewControllerManager.accountMenuController.closeAccountMenu()
@@ -33,11 +31,6 @@ const AccountMenu: FunctionComponent<Props> = ({
[viewControllerManager],
)
const ref = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(ref, () => {
onClickOutside()
})
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
(event) => {
switch (event.key) {
@@ -56,22 +49,15 @@ const AccountMenu: FunctionComponent<Props> = ({
)
return (
<div ref={ref} id="account-menu" className="sn-component">
<div
className={`max-h-120 absolute bottom-full left-0 z-footer-bar-item-panel flex min-w-80 max-w-xs cursor-auto flex-col overflow-y-auto rounded bg-default py-2 shadow-main ${
shouldAnimateCloseMenu ? 'slide-up-animation' : 'slide-down-animation transition-transform duration-150'
}`}
onKeyDown={handleKeyDown}
>
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
</div>
<div id="account-menu" className="sn-component" onKeyDown={handleKeyDown}>
<MenuPaneSelector
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
menuPane={currentPane}
setMenuPane={setCurrentPane}
closeMenu={closeAccountMenu}
/>
</div>
)
}

View File

@@ -23,7 +23,9 @@ const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
isOpen,
hideWorkspaceOptions = false,
}: Props) => {
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>([])
const [applicationDescriptors, setApplicationDescriptors] = useState<ApplicationDescriptor[]>(
mainApplicationGroup.getDescriptors(),
)
useEffect(() => {
const applicationDescriptors = mainApplicationGroup.getDescriptors()

View File

@@ -1,13 +1,13 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import WorkspaceSwitcherMenu from './WorkspaceSwitcherMenu'
import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import Popover from '@/Components/Popover/Popover'
type Props = {
mainApplicationGroup: ApplicationGroup
@@ -16,32 +16,11 @@ type Props = {
const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
const toggleMenu = useCallback(() => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsOpen(!isOpen)
}, [isOpen, setIsOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
})
}
}, [isOpen])
setIsOpen((isOpen) => !isOpen)
}, [])
return (
<>
@@ -58,19 +37,20 @@ const WorkspaceSwitcherOption: FunctionComponent<Props> = ({ mainApplicationGrou
</div>
<Icon type="chevron-right" className="text-neutral" />
</MenuItem>
{isOpen && (
<div
ref={menuRef}
className="max-h-120 fixed min-w-68 overflow-y-auto rounded bg-default py-2 shadow-main"
style={menuStyle}
>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
/>
</div>
)}
<Popover
align="end"
anchorElement={buttonRef.current}
className="py-2"
open={isOpen}
side="right"
togglePopover={toggleMenu}
>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
/>
</Popover>
</>
)
}

View File

@@ -1,11 +1,7 @@
import { WebApplication } from '@/Application/Application'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import AttachedFilesPopover from './AttachedFilesPopover'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { PopoverTabs } from './PopoverTabs'
@@ -18,6 +14,8 @@ import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { useFileDragNDrop } from '@/Components/FileDragNDropProvider/FileDragNDropProvider'
import { FileItem, SNNote } from '@standardnotes/snjs'
import { addToast, ToastType } from '@standardnotes/toast'
import { classNames } from '@/Utils/ConcatenateClassNames'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
@@ -34,7 +32,6 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
application,
featuresController,
filesController,
filePreviewModalController,
navigationController,
notesController,
selectionController,
@@ -46,24 +43,9 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
const premiumModal = usePremiumModal()
const note: SNNote | undefined = notesController.firstSelectedNote
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur, keepMenuOpen] = useCloseOnBlur(containerRef, setOpen)
useEffect(() => {
if (filePreviewModalController.isOpen) {
keepMenuOpen(true)
} else {
keepMenuOpen(false)
}
}, [filePreviewModalController.isOpen, keepMenuOpen])
const [currentTab, setCurrentTab] = useState(
navigationController.isInFilesView ? PopoverTabs.AllFiles : PopoverTabs.AttachedFiles,
@@ -78,29 +60,14 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
}, [currentTab, isAttachedTabDisabled])
const toggleAttachedFilesMenu = useCallback(async () => {
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) {
const { clientHeight } = document.documentElement
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
const newOpenState = !isOpen
if (footerHeightInPx) {
setMaxHeight(clientHeight - rect.bottom - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !open
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setOpen(newOpenState)
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
}, [onClickPreprocessing, open])
setIsOpen(newOpenState)
}, [onClickPreprocessing, isOpen])
const prospectivelyShowFilesPremiumModal = useCallback(() => {
if (!featuresController.hasFiles) {
@@ -132,10 +99,10 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
const { isDraggingFiles, addFilesDragInCallback, addFilesDropCallback } = useFileDragNDrop()
useEffect(() => {
if (isDraggingFiles && !open) {
if (isDraggingFiles && !isOpen) {
void toggleAttachedFilesMenu()
}
}, [isDraggingFiles, open, toggleAttachedFilesMenu])
}, [isDraggingFiles, isOpen, toggleAttachedFilesMenu])
const filesDragInCallback = useCallback((tab: PopoverTabs) => {
setCurrentTab(tab)
@@ -162,53 +129,41 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
return (
<div ref={containerRef}>
<Disclosure open={open} onChange={toggleAttachedFilesMenuWithEntitlementCheck}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
ref={buttonRef}
className={`bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast ${
attachedFilesCount > 0 ? 'py-1 px-3' : ''
}`}
onBlur={closeOnBlur}
>
<VisuallyHidden>Attached files</VisuallyHidden>
<Icon type="attachment-file" className="block" />
{attachedFilesCount > 0 && <span className="ml-2 text-sm">{attachedFilesCount}</span>}
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="slide-down-animation max-h-120 fixed flex min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default shadow-main transition-transform duration-150"
onBlur={closeOnBlur}
>
{open && (
<AttachedFilesPopover
application={application}
filesController={filesController}
attachedFiles={attachedFiles}
allFiles={allFiles}
closeOnBlur={closeOnBlur}
currentTab={currentTab}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
attachedTabDisabled={isAttachedTabDisabled}
/>
)}
</DisclosurePanel>
</Disclosure>
<button
className={classNames(
'bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast',
attachedFilesCount > 0 ? 'py-1 px-3' : '',
)}
title="Attached files"
aria-label="Attached files"
onClick={toggleAttachedFilesMenuWithEntitlementCheck}
ref={buttonRef}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}}
>
<Icon type="attachment-file" />
{attachedFilesCount > 0 && <span className="ml-2 text-sm">{attachedFilesCount}</span>}
</button>
<Popover
togglePopover={toggleAttachedFilesMenuWithEntitlementCheck}
anchorElement={buttonRef.current}
open={isOpen}
className="pt-2 md:pt-0"
>
<AttachedFilesPopover
application={application}
filesController={filesController}
attachedFiles={attachedFiles}
allFiles={allFiles}
currentTab={currentTab}
isDraggingFiles={isDraggingFiles}
setCurrentTab={setCurrentTab}
attachedTabDisabled={isAttachedTabDisabled}
/>
</Popover>
</div>
)
}

View File

@@ -16,7 +16,6 @@ type Props = {
filesController: FilesController
allFiles: FileItem[]
attachedFiles: FileItem[]
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
currentTab: PopoverTabs
isDraggingFiles: boolean
setCurrentTab: Dispatch<SetStateAction<PopoverTabs>>
@@ -28,7 +27,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
filesController,
allFiles,
attachedFiles,
closeOnBlur,
currentTab,
isDraggingFiles,
setCurrentTab,
@@ -87,7 +85,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
onClick={() => {
setCurrentTab(PopoverTabs.AttachedFiles)
}}
onBlur={closeOnBlur}
disabled={attachedTabDisabled}
>
Attached
@@ -100,7 +97,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
onClick={() => {
setCurrentTab(PopoverTabs.AllFiles)
}}
onBlur={closeOnBlur}
>
All files
</button>
@@ -117,7 +113,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
onInput={(e) => {
setSearchQuery((e.target as HTMLInputElement).value)
}}
onBlur={closeOnBlur}
ref={searchInputRef}
/>
{searchQuery.length > 0 && (
@@ -127,7 +122,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
setSearchQuery('')
searchInputRef.current?.focus()
}}
onBlur={closeOnBlur}
>
<Icon type="clear-circle-filled" className="text-neutral" />
</button>
@@ -144,7 +138,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
isAttachedToNote={attachedFiles.includes(file)}
handleFileAction={filesController.handleFileAction}
getIconType={application.iconsController.getIconForFileType}
closeOnBlur={closeOnBlur}
previewHandler={previewHandler}
/>
)
@@ -161,7 +154,7 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
? 'No files attached to this note'
: 'No files found in this account'}
</div>
<Button onClick={handleAttachFilesClick} onBlur={closeOnBlur}>
<Button onClick={handleAttachFilesClick}>
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files
</Button>
<div className="mt-3 text-xs text-passive-0">Or drop your files here</div>
@@ -172,7 +165,6 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
<button
className="flex w-full cursor-pointer items-center border-0 border-t border-solid border-border bg-transparent px-3 py-3 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={handleAttachFilesClick}
onBlur={closeOnBlur}
>
<Icon type="add" className="mr-2 text-neutral" />
{currentTab === PopoverTabs.AttachedFiles ? 'Attach' : 'Upload'} files

View File

@@ -22,7 +22,6 @@ const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
isAttachedToNote,
handleFileAction,
getIconType,
closeOnBlur,
previewHandler,
}) => {
const [fileName, setFileName] = useState(file.name)
@@ -116,7 +115,6 @@ const PopoverFileItem: FunctionComponent<PopoverFileItemProps> = ({
isAttachedToNote={isAttachedToNote}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
closeOnBlur={closeOnBlur}
previewHandler={previewHandler}
/>
</div>

View File

@@ -8,7 +8,6 @@ type CommonProps = {
handleFileAction: (action: PopoverFileItemAction) => Promise<{
didHandleAction: boolean
}>
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
previewHandler: (file: FileItem) => void
}

View File

@@ -1,14 +1,12 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { PopoverFileSubmenuProps } from './PopoverFileItemProps'
import { PopoverFileItemActionType } from './PopoverFileItemAction'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import Popover from '../Popover/Popover'
const PopoverFileSubmenu: FunctionComponent<PopoverFileSubmenuProps> = ({
file,
@@ -19,187 +17,135 @@ const PopoverFileSubmenu: FunctionComponent<PopoverFileSubmenuProps> = ({
}) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [isFileProtected, setIsFileProtected] = useState(file.protected)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
})
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const closeMenu = useCallback(() => {
setIsMenuOpen(false)
setIsOpen(false)
}, [])
const toggleMenu = useCallback(() => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen)
}, [isMenuOpen])
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
setIsOpen((isOpen) => !isOpen)
}, [])
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle()
})
}
}, [isMenuOpen, recalculateMenuStyle])
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleMenu}>
<DisclosureButton
ref={menuButtonRef}
onBlur={closeOnBlur}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
>
<Icon type="more" className="text-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
style={{
...menuStyle,
position: 'fixed',
<button
ref={menuButtonRef}
onClick={toggleMenu}
className="h-7 w-7 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
>
<Icon type="more" className="text-neutral" />
</button>
<Popover anchorElement={menuButtonRef.current} open={isOpen} togglePopover={toggleMenu} className="py-2">
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
previewHandler(file)
closeMenu()
}}
className={`${
isMenuOpen ? 'flex' : 'hidden'
} max-h-120 fixed min-w-60 flex-col overflow-y-auto rounded bg-default py-1 shadow-main`}
>
{isMenuOpen && (
<>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
previewHandler(file)
closeMenu()
}}
>
<Icon type="file" className="mr-2 text-neutral" />
Preview file
</button>
{isAttachedToNote ? (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link-off" className="mr-2 text-neutral" />
Detach from note
</button>
) : (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link" className="mr-2 text-neutral" />
Attach to note
</button>
)}
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: { file },
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected)
},
}).catch(console.error)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" />
Password protection
</span>
<Switch
className="pointer-events-none px-0"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
checked={isFileProtected}
/>
</button>
<HorizontalSeparator classes="my-1" />
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="download" className="mr-2 text-neutral" />
Download
</button>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
setIsRenamingFile(true)
}}
>
<Icon type="pencil" className="mr-2 text-neutral" />
Rename
</button>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</button>
<div className="px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">File ID:</span> {file.uuid}
</div>
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(file.decryptedSize)}
</div>
</div>
</>
)}
</DisclosurePanel>
</Disclosure>
<Icon type="file" className="mr-2 text-neutral" />
Preview file
</button>
{isAttachedToNote ? (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DetachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link-off" className="mr-2 text-neutral" />
Detach from note
</button>
) : (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.AttachFileToNote,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="link" className="mr-2 text-neutral" />
Attach to note
</button>
)}
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: { file },
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected)
},
}).catch(console.error)
}}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" />
Password protection
</span>
<Switch
className="pointer-events-none px-0"
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
checked={isFileProtected}
/>
</button>
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="download" className="mr-2 text-neutral" />
Download
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
setIsRenamingFile(true)
}}
>
<Icon type="pencil" className="mr-2 text-neutral" />
Rename
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</button>
<div className="px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">File ID:</span> {file.uuid}
</div>
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(file.decryptedSize)}
</div>
</div>
</Popover>
</div>
)
}

View File

@@ -1,11 +1,10 @@
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import WorkspaceSwitcherMenu from '@/Components/AccountMenu/WorkspaceSwitcher/WorkspaceSwitcherMenu'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import Popover from '../Popover/Popover'
type Props = {
mainApplicationGroup: ApplicationGroup
@@ -14,36 +13,12 @@ type Props = {
const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplicationGroup, viewControllerManager }) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>()
useCloseOnClickOutside(containerRef, () => setIsOpen(false))
const toggleMenu = useCallback(() => {
if (!isOpen) {
const menuPosition = calculateSubmenuStyle(buttonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsOpen(!isOpen)
}, [isOpen])
useEffect(() => {
if (isOpen) {
const timeToWaitBeforeCheckingMenuCollision = 0
setTimeout(() => {
const newMenuPosition = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
}, timeToWaitBeforeCheckingMenuCollision)
}
}, [isOpen])
setIsOpen((isOpen) => !isOpen)
}, [])
return (
<div ref={containerRef}>
@@ -51,20 +26,22 @@ const LockscreenWorkspaceSwitcher: FunctionComponent<Props> = ({ mainApplication
<Icon type="user-switch" className="mr-2 text-neutral" />
Switch workspace
</Button>
{isOpen && (
<div
ref={menuRef}
className="max-h-120 fixed min-w-68 overflow-y-auto rounded-md bg-default py-2 shadow-main"
style={menuStyle}
>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
hideWorkspaceOptions={true}
/>
</div>
)}
<Popover
align="center"
anchorElement={buttonRef.current}
className="py-2"
open={isOpen}
overrideZIndex="z-modal"
side="right"
togglePopover={toggleMenu}
>
<WorkspaceSwitcherMenu
mainApplicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
isOpen={isOpen}
hideWorkspaceOptions={true}
/>
</Popover>
</div>
)
}

View File

@@ -1,13 +1,10 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import VisuallyHidden from '@reach/visually-hidden'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import ChangeEditorMenu from './ChangeEditorMenu'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
@@ -22,89 +19,38 @@ const ChangeEditorButton: FunctionComponent<Props> = ({
}: Props) => {
const note = viewControllerManager.notesController.firstSelectedNote
const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = 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 containerRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, setIsOpen)
const toggleChangeEditorMenu = async () => {
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 - MENU_MARGIN_FROM_APP_BORDER)
}
setPosition({
top: rect.bottom,
right: document.body.clientWidth - rect.right,
})
const newOpenState = !isOpen
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(newOpenState)
setTimeout(() => {
setIsVisible(newOpenState)
})
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
}
setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen])
return (
<div ref={containerRef}>
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
}
<button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
title="Change note type"
aria-label="Change note type"
onClick={toggleMenu}
ref={buttonRef}
>
<Icon type="dashboard" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="pt-2 md:pt-0">
<ChangeEditorMenu
application={application}
isVisible={isOpen}
note={note}
closeMenu={() => {
setIsOpen(false)
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
>
<VisuallyHidden>Change note type</VisuallyHidden>
<Icon type="dashboard" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className="slide-down-animation max-h-120 fixed flex min-w-68 max-w-xs flex-col overflow-y-auto rounded bg-default shadow-main transition-transform duration-150"
onBlur={closeOnBlur}
>
{isOpen && (
<ChangeEditorMenu
closeOnBlur={closeOnBlur}
application={application}
isVisible={isVisible}
note={note}
closeMenu={() => {
setIsOpen(false)
}}
/>
)}
</DisclosurePanel>
</Disclosure>
/>
</Popover>
</div>
)
}

View File

@@ -14,7 +14,7 @@ import {
SNNote,
TransactionalMutation,
} from '@standardnotes/snjs'
import { Fragment, FunctionComponent, useCallback, useEffect, useState } from 'react'
import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
import { createEditorMenuGroups } from './createEditorMenuGroups'
@@ -28,7 +28,6 @@ import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/Premium
type ChangeEditorMenuProps = {
application: WebApplication
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
closeMenu: () => void
isVisible: boolean
note: SNNote | undefined
@@ -36,25 +35,17 @@ type ChangeEditorMenuProps = {
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
application,
closeOnBlur,
closeMenu,
isVisible,
note,
}) => {
const [editors] = useState<SNComponent[]>(() =>
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
}),
const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({ application, closeMenu, isVisible, note }) => {
const editors = useMemo(
() =>
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
}),
[application.componentManager],
)
const [groups, setGroups] = useState<EditorMenuGroup[]>([])
const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors])
const [currentEditor, setCurrentEditor] = useState<SNComponent>()
useEffect(() => {
setGroups(createEditorMenuGroups(application, editors))
}, [application, editors])
useEffect(() => {
if (note) {
setCurrentEditor(application.componentManager.editorForNote(note))
@@ -195,7 +186,6 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
type={MenuItemType.RadioButton}
onClick={onClickEditorItem}
className={'flex-row-reverse py-2'}
onBlur={closeOnBlur}
checked={item.isEntitled ? isSelectedEditor(item) : undefined}
>
<div className="flex flex-grow items-center justify-between">

View File

@@ -1,10 +1,9 @@
import { WebApplication } from '@/Application/Application'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { memo, useCallback, useRef, useState } from 'react'
import Icon from '../../Icon/Icon'
import { DisplayOptionsMenuPositionProps } from './DisplayOptionsMenuProps'
import DisplayOptionsMenuPortal from './DisplayOptionsMenuPortal'
import StyledDisplayOptionsButton from './StyledDisplayOptionsButton'
import { classNames } from '@/Utils/ConcatenateClassNames'
import Popover from '@/Components/Popover/Popover'
import DisplayOptionsMenu from './DisplayOptionsMenu'
type Props = {
application: {
@@ -26,21 +25,12 @@ const ContentListHeader = ({
isFilesSmartView,
optionsSubtitle,
}: Props) => {
const [displayOptionsMenuPosition, setDisplayOptionsMenuPosition] = useState<DisplayOptionsMenuPositionProps>()
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
const [showDisplayOptionsMenu, setShowDisplayOptionsMenu] = useState(false)
const toggleDisplayOptionsMenu = useCallback(() => {
if (displayOptionsButtonRef.current) {
const buttonBoundingRect = displayOptionsButtonRef.current.getBoundingClientRect()
setDisplayOptionsMenuPosition({
top: buttonBoundingRect.bottom,
left: buttonBoundingRect.right - buttonBoundingRect.width,
})
}
setShowDisplayOptionsMenu((show) => !show)
}, [])
@@ -52,24 +42,30 @@ const ContentListHeader = ({
</div>
<div className="flex">
<div className="relative" ref={displayOptionsContainerRef}>
<Disclosure open={showDisplayOptionsMenu} onChange={toggleDisplayOptionsMenu}>
<StyledDisplayOptionsButton $pressed={showDisplayOptionsMenu} ref={displayOptionsButtonRef}>
<Icon type="sort-descending" />
</StyledDisplayOptionsButton>
<DisclosurePanel>
{showDisplayOptionsMenu && displayOptionsMenuPosition && (
<DisplayOptionsMenuPortal
application={application}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
containerRef={displayOptionsContainerRef}
isOpen={showDisplayOptionsMenu}
isFilesSmartView={isFilesSmartView}
top={displayOptionsMenuPosition.top}
left={displayOptionsMenuPosition.left}
/>
)}
</DisclosurePanel>
</Disclosure>
<button
className={classNames(
'bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast',
showDisplayOptionsMenu && 'bg-contrast',
)}
onClick={toggleDisplayOptionsMenu}
ref={displayOptionsButtonRef}
>
<Icon type="sort-descending" />
</button>
<Popover
open={showDisplayOptionsMenu}
anchorElement={displayOptionsButtonRef.current}
togglePopover={toggleDisplayOptionsMenu}
align="start"
className="py-2"
>
<DisplayOptionsMenu
application={application}
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
isFilesSmartView={isFilesSmartView}
isOpen={showDisplayOptionsMenu}
/>
</Popover>
</div>
<button
className="ml-3 flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-transparent bg-info text-info-contrast hover:brightness-125"

View File

@@ -97,14 +97,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
}, [application, hideEditorIcon])
return (
<Menu
className={
'slide-down-animation z-index-dropdown-menu flex min-w-70 flex-col overflow-y-auto rounded border border-solid border-border bg-default py-1 text-sm shadow-main transition-transform duration-150'
}
a11yLabel="Notes list options menu"
closeMenu={closeDisplayOptionsMenu}
isOpen={isOpen}
>
<Menu className="text-sm" a11yLabel="Notes list options menu" closeMenu={closeDisplayOptionsMenu} isOpen={isOpen}>
<div className="my-1 px-3 text-xs font-semibold uppercase text-text">Sort by</div>
<MenuItem
className="py-2"

View File

@@ -1,63 +0,0 @@
import { createPortal } from 'react-dom'
import styled from 'styled-components'
import { DisplayOptionsMenuPositionProps, DisplayOptionsMenuProps } from './DisplayOptionsMenuProps'
import DisplayOptionsMenu from './DisplayOptionsMenu'
import { useRef, useEffect, RefObject } from 'react'
type Props = DisplayOptionsMenuProps &
DisplayOptionsMenuPositionProps & {
containerRef: RefObject<HTMLDivElement>
}
const PositionedOptionsMenu = styled.div<DisplayOptionsMenuPositionProps>`
position: absolute;
top: ${(props) => props.top}px;
left: ${(props) => props.left}px;
z-index: var(--z-index-dropdown-menu);
`
const DisplayOptionsMenuPortal = ({
application,
closeDisplayOptionsMenu,
containerRef,
isFilesSmartView,
isOpen,
top,
left,
}: Props) => {
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => {
const isDescendantOfMenu = menuRef.current?.contains(event.target as Node)
const isDescendantOfContainer = containerRef.current?.contains(event.target as Node)
if (!isDescendantOfMenu && !isDescendantOfContainer) {
closeDisplayOptionsMenu()
}
}
document.addEventListener('click', closeIfClickedOutside, { capture: true })
return () => {
document.removeEventListener('click', closeIfClickedOutside, {
capture: true,
})
}
}, [closeDisplayOptionsMenu, containerRef])
return createPortal(
<PositionedOptionsMenu top={top} left={left} ref={menuRef}>
<div className="sn-component">
<DisplayOptionsMenu
application={application}
closeDisplayOptionsMenu={closeDisplayOptionsMenu}
isFilesSmartView={isFilesSmartView}
isOpen={isOpen}
/>
</div>
</PositionedOptionsMenu>,
document.body,
)
}
export default DisplayOptionsMenuPortal

View File

@@ -1,10 +1,8 @@
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/Constants'
import { FilesController } from '@/Controllers/FilesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useRef } from 'react'
import Popover from '../Popover/Popover'
import FileMenuOptions from './FileMenuOptions'
type Props = {
@@ -15,92 +13,27 @@ type Props = {
const FileContextMenu: FunctionComponent<Props> = observer(({ filesController, selectionController }) => {
const { showFileContextMenu, setShowFileContextMenu, fileContextMenuLocation } = filesController
const [contextMenuStyle, setContextMenuStyle] = useState<React.CSSProperties>({
top: 0,
left: 0,
visibility: 'hidden',
})
const [contextMenuMaxHeight, setContextMenuMaxHeight] = useState<number | 'auto'>('auto')
const contextMenuRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => setShowFileContextMenu(open))
useCloseOnClickOutside(contextMenuRef, () => filesController.setShowFileContextMenu(false))
const reloadContextMenuLayout = useCallback(() => {
const { clientHeight } = document.documentElement
const defaultFontSize = window.getComputedStyle(document.documentElement).fontSize
const maxContextMenuHeight = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER
const footerElementRect = document.getElementById('footer-bar')?.getBoundingClientRect()
const footerHeightInPx = footerElementRect?.height
let openUpBottom = true
if (footerHeightInPx) {
const bottomSpace = clientHeight - footerHeightInPx - fileContextMenuLocation.y
const upSpace = fileContextMenuLocation.y
if (maxContextMenuHeight > bottomSpace) {
if (upSpace > maxContextMenuHeight) {
openUpBottom = false
setContextMenuMaxHeight('auto')
} else {
if (upSpace > bottomSpace) {
setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER)
openUpBottom = false
} else {
setContextMenuMaxHeight(bottomSpace - MENU_MARGIN_FROM_APP_BORDER)
}
}
} else {
setContextMenuMaxHeight('auto')
}
}
if (openUpBottom) {
setContextMenuStyle({
top: fileContextMenuLocation.y,
left: fileContextMenuLocation.x,
visibility: 'visible',
})
} else {
setContextMenuStyle({
bottom: clientHeight - fileContextMenuLocation.y,
left: fileContextMenuLocation.x,
visibility: 'visible',
})
}
}, [fileContextMenuLocation.x, fileContextMenuLocation.y])
useEffect(() => {
if (showFileContextMenu) {
reloadContextMenuLayout()
}
}, [reloadContextMenuLayout, showFileContextMenu])
useEffect(() => {
window.addEventListener('resize', reloadContextMenuLayout)
return () => {
window.removeEventListener('resize', reloadContextMenuLayout)
}
}, [reloadContextMenuLayout])
return (
<div
ref={contextMenuRef}
className="max-h-120 fixed z-dropdown-menu flex min-w-60 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main"
style={{
...contextMenuStyle,
maxHeight: contextMenuMaxHeight,
}}
<Popover
open={showFileContextMenu}
anchorPoint={fileContextMenuLocation}
togglePopover={() => setShowFileContextMenu(!showFileContextMenu)}
side="right"
align="start"
className="py-2"
>
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
closeOnBlur={closeOnBlur}
closeMenu={() => setShowFileContextMenu(false)}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
/>
</div>
<div ref={contextMenuRef}>
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
closeMenu={() => setShowFileContextMenu(false)}
shouldShowRenameOption={false}
shouldShowAttachOption={false}
/>
</div>
</Popover>
)
})

View File

@@ -11,7 +11,6 @@ import { formatSizeToReadableString } from '@standardnotes/filepicker'
type Props = {
closeMenu: () => void
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
filesController: FilesController
selectionController: SelectedItemsController
isFileAttachedToNote?: boolean
@@ -22,7 +21,6 @@ type Props = {
const FileMenuOptions: FunctionComponent<Props> = ({
closeMenu,
closeOnBlur,
filesController,
selectionController,
isFileAttachedToNote,
@@ -73,7 +71,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
return (
<>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={onPreview}
>
@@ -84,7 +81,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
<>
{isFileAttachedToNote ? (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={onDetach}
>
@@ -93,7 +89,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
</button>
) : shouldShowAttachOption ? (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={onAttach}
>
@@ -109,7 +104,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
onClick={() => {
void filesController.setProtectionForFiles(!hasProtectedFiles, selectionController.selectedFiles)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" />
@@ -123,7 +117,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
</button>
<HorizontalSeparator classes="my-1" />
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
void filesController.downloadFiles(selectionController.selectedFiles)
@@ -134,7 +127,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
</button>
{shouldShowRenameOption && (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
renameToggleCallback?.(true)
@@ -145,7 +137,6 @@ const FileMenuOptions: FunctionComponent<Props> = ({
</button>
)}
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
void filesController.deleteFilesPermanently(selectionController.selectedFiles)

View File

@@ -1,13 +1,10 @@
import Icon from '@/Components/Icon/Icon'
import VisuallyHidden from '@reach/visually-hidden'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useCallback, useRef, useState } from 'react'
import { observer } from 'mobx-react-lite'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import FileMenuOptions from './FileMenuOptions'
import { FilesController } from '@/Controllers/FilesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import Popover from '../Popover/Popover'
type Props = {
filesController: FilesController
@@ -15,80 +12,34 @@ type Props = {
}
const FilesOptionsPanel = ({ filesController, selectionController }: Props) => {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
const onDisclosureChange = useCallback(async () => {
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) => !open)
}
}, [])
const toggleMenu = useCallback(() => setIsOpen((isOpen) => !isOpen), [])
return (
<Disclosure open={open} onChange={onDisclosureChange}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
<>
<button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
title="File options menu"
aria-label="File options menu"
onClick={toggleMenu}
ref={buttonRef}
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className={`${
open ? 'flex' : 'hidden'
} max-h-120 slide-down-animation fixed min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main transition-transform duration-150`}
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{open && (
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
closeOnBlur={closeOnBlur}
closeMenu={() => {
setOpen(false)
}}
shouldShowAttachOption={false}
shouldShowRenameOption={false}
/>
)}
</DisclosurePanel>
</Disclosure>
<Icon type="more" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="py-2">
<FileMenuOptions
filesController={filesController}
selectionController={selectionController}
closeMenu={() => {
setIsOpen(false)
}}
shouldShowAttachOption={false}
shouldShowRenameOption={false}
/>
</Popover>
</>
)
}

View File

@@ -0,0 +1,52 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { useRef } from 'react'
import AccountMenu, { AccountMenuProps } from '../AccountMenu/AccountMenu'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
type Props = AccountMenuProps & {
isOpen: boolean
hasError: boolean
toggleMenu: () => void
user: unknown
}
const AccountMenuButton = ({
application,
hasError,
isOpen,
mainApplicationGroup,
onClickOutside,
toggleMenu,
user,
viewControllerManager,
}: Props) => {
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<>
<button
ref={buttonRef}
onClick={toggleMenu}
className={classNames(
isOpen ? 'bg-border' : '',
'flex h-full w-8 cursor-pointer items-center justify-center rounded-full',
)}
>
<div className={hasError ? 'text-danger' : (user ? 'text-info' : 'text-neutral') + ' h-5 w-5'}>
<Icon type="account-circle" className="max-h-5 hover:text-info" />
</div>
</button>
<Popover anchorElement={buttonRef.current} open={isOpen} togglePopover={toggleMenu} side="top" className="py-2">
<AccountMenu
onClickOutside={onClickOutside}
viewControllerManager={viewControllerManager}
application={application}
mainApplicationGroup={mainApplicationGroup}
/>
</Popover>
</>
)
}
export default AccountMenuButton

View File

@@ -12,13 +12,13 @@ import {
STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON,
} from '@/Constants/Strings'
import { alertDialog, confirmDialog } from '@/Services/AlertService'
import AccountMenu from '@/Components/AccountMenu/AccountMenu'
import Icon from '@/Components/Icon/Icon'
import QuickSettingsMenu from '@/Components/QuickSettingsMenu/QuickSettingsMenu'
import SyncResolutionMenu from '@/Components/SyncResolutionMenu/SyncResolutionMenu'
import { Fragment } from 'react'
import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
import { EditorEventSource } from '@/Types/EditorEventSource'
import QuickSettingsButton from './QuickSettingsButton'
import AccountMenuButton from './AccountMenuButton'
type Props = {
application: WebApplication
@@ -287,12 +287,10 @@ class Footer extends PureComponent<Props, State> {
}
accountMenuClickHandler = () => {
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
this.viewControllerManager.accountMenuController.toggleShow()
}
quickSettingsClickHandler = () => {
this.viewControllerManager.accountMenuController.closeAccountMenu()
this.viewControllerManager.quickSettingsMenuController.toggle()
}
@@ -342,55 +340,31 @@ class Footer extends PureComponent<Props, State> {
override render() {
return (
<div className="sn-component">
<div
<footer
id="footer-bar"
className="z-footer-bar flex h-6 w-full select-none items-center justify-between border-t border-border bg-contrast px-3 text-text"
>
<div className="left flex h-full">
<div className="sk-app-bar-item relative z-footer-bar-item ml-0 select-none">
<div
onClick={this.accountMenuClickHandler}
className={
(this.state.showAccountMenu ? 'bg-border' : '') +
' flex h-full w-8 cursor-pointer items-center justify-center rounded-full'
}
>
<div
className={
this.state.hasError ? 'text-danger' : (this.user ? 'text-info' : 'text-neutral') + ' h-5 w-5'
}
>
<Icon type="account-circle" className="max-h-5 hover:text-info" />
</div>
</div>
{this.state.showAccountMenu && (
<AccountMenu
onClickOutside={this.clickOutsideAccountMenu}
viewControllerManager={this.viewControllerManager}
application={this.application}
mainApplicationGroup={this.props.applicationGroup}
/>
)}
<AccountMenuButton
application={this.application}
hasError={this.state.hasError}
isOpen={this.state.showAccountMenu}
mainApplicationGroup={this.props.applicationGroup}
onClickOutside={this.clickOutsideAccountMenu}
toggleMenu={this.accountMenuClickHandler}
user={this.user}
viewControllerManager={this.viewControllerManager}
/>
</div>
<div className="sk-app-bar-item ml-0-important relative z-footer-bar-item select-none">
<div
onClick={this.quickSettingsClickHandler}
className="flex h-full w-8 cursor-pointer items-center justify-center"
>
<div className="h-5">
<Icon
type="tune"
className={(this.state.showQuickSettingsMenu ? 'text-info' : '') + ' rounded hover:text-info'}
/>
</div>
</div>
{this.state.showQuickSettingsMenu && (
<QuickSettingsMenu
onClickOutside={this.clickOutsideQuickSettingsMenu}
viewControllerManager={this.viewControllerManager}
application={this.application}
/>
)}
<div className="relative z-footer-bar-item select-none">
<QuickSettingsButton
isOpen={this.state.showQuickSettingsMenu}
toggleMenu={this.quickSettingsClickHandler}
application={this.application}
preferencesController={this.viewControllerManager.preferencesController}
quickSettingsMenuController={this.viewControllerManager.quickSettingsMenuController}
/>
</div>
{this.state.showBetaWarning && (
<Fragment>
@@ -454,7 +428,7 @@ class Footer extends PureComponent<Props, State> {
</div>
)}
</div>
</div>
</footer>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { WebApplication } from '@/Application/Application'
import { PreferencesController } from '@/Controllers/PreferencesController'
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
import { useRef } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import QuickSettingsMenu from '../QuickSettingsMenu/QuickSettingsMenu'
type Props = {
isOpen: boolean
toggleMenu: () => void
application: WebApplication
preferencesController: PreferencesController
quickSettingsMenuController: QuickSettingsController
}
const QuickSettingsButton = ({
application,
isOpen,
toggleMenu,
preferencesController,
quickSettingsMenuController,
}: Props) => {
const buttonRef = useRef<HTMLButtonElement>(null)
return (
<>
<button
onClick={toggleMenu}
className="flex h-full w-8 cursor-pointer items-center justify-center"
ref={buttonRef}
>
<div className="h-5">
<Icon type="tune" className={(isOpen ? 'text-info' : '') + ' rounded hover:text-info'} />
</div>
</button>
<Popover
togglePopover={toggleMenu}
anchorElement={buttonRef.current}
open={isOpen}
side="top"
align="start"
className="py-2"
>
<QuickSettingsMenu
preferencesController={preferencesController}
quickSettingsMenuController={quickSettingsMenuController}
application={application}
/>
</Popover>
</>
)
}
export default QuickSettingsButton

View File

@@ -1,13 +1,12 @@
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { observer } from 'mobx-react-lite'
import NotesOptions from '@/Components/NotesOptions/NotesOptions'
import { useCallback, useEffect, useRef } from 'react'
import { useRef } from 'react'
import { WebApplication } from '@/Application/Application'
import { NotesController } from '@/Controllers/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
@@ -24,43 +23,33 @@ const NotesContextMenu = ({
noteTagsController,
historyModalController,
}: Props) => {
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = notesController
const { contextMenuOpen, contextMenuClickLocation } = notesController
const contextMenuRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => notesController.setContextMenuOpen(open))
useCloseOnClickOutside(contextMenuRef, () => notesController.setContextMenuOpen(false))
const reloadContextMenuLayout = useCallback(() => {
notesController.reloadContextMenuLayout()
}, [notesController])
useEffect(() => {
window.addEventListener('resize', reloadContextMenuLayout)
return () => {
window.removeEventListener('resize', reloadContextMenuLayout)
}
}, [reloadContextMenuLayout])
return contextMenuOpen ? (
<div
ref={contextMenuRef}
className="max-h-120 fixed z-dropdown-menu flex min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main"
style={{
...contextMenuPosition,
maxHeight: contextMenuMaxHeight,
return (
<Popover
align="start"
anchorPoint={{
x: contextMenuClickLocation.x,
y: contextMenuClickLocation.y,
}}
className="py-2"
open={contextMenuOpen}
side="right"
togglePopover={() => notesController.setContextMenuOpen(!contextMenuOpen)}
>
<NotesOptions
application={application}
closeOnBlur={closeOnBlur}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
historyModalController={historyModalController}
/>
</div>
) : null
<div ref={contextMenuRef}>
<NotesOptions
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
historyModalController={historyModalController}
/>
</div>
</Popover>
)
}
export default observer(NotesContextMenu)

View File

@@ -1,12 +1,11 @@
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { KeyboardKey } from '@/Services/IOService'
import Popover from '../Popover/Popover'
type Props = {
navigationController: NavigationController
@@ -16,101 +15,59 @@ type Props = {
const AddTagOption: FunctionComponent<Props> = ({ navigationController, notesController, noteTagsController }) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
})
const [isOpen, setIsOpen] = useState(false)
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const toggleTagsMenu = useCallback(() => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen)
}, [isMenuOpen])
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
const toggleMenu = useCallback(() => {
setIsOpen((isOpen) => !isOpen)
}, [])
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle()
})
}
}, [isMenuOpen, recalculateMenuStyle])
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleTagsMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsMenuOpen(false)
}
}}
onBlur={closeOnBlur}
ref={menuButtonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="hashtag" className="mr-2 text-neutral" />
Add tag
</div>
<Icon type="chevron-right" className="text-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setIsMenuOpen(false)
menuButtonRef.current?.focus()
}
}}
style={{
...menuStyle,
position: 'fixed',
}}
className={`${
isMenuOpen ? 'flex' : 'hidden'
} max-h-120 fixed min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main`}
>
{navigationController.tags.map((tag) => (
<button
key={tag.uuid}
className="max-w-80 flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-2 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onBlur={closeOnBlur}
onClick={() => {
notesController.isTagInSelectedNotes(tag)
? notesController.removeTagFromSelectedNotes(tag).catch(console.error)
: notesController.addTagToSelectedNotes(tag).catch(console.error)
}}
>
<span
className={`overflow-hidden overflow-ellipsis whitespace-nowrap
<button
onClick={toggleMenu}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false)
}
}}
ref={buttonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="hashtag" className="mr-2 text-neutral" />
Add tag
</div>
<Icon type="chevron-right" className="text-neutral" />
</button>
<Popover
togglePopover={toggleMenu}
anchorElement={buttonRef.current}
open={isOpen}
side="right"
align="start"
className="py-2"
>
{navigationController.tags.map((tag) => (
<button
key={tag.uuid}
className="max-w-80 flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-2 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
notesController.isTagInSelectedNotes(tag)
? notesController.removeTagFromSelectedNotes(tag).catch(console.error)
: notesController.addTagToSelectedNotes(tag).catch(console.error)
}}
>
<span
className={`overflow-hidden overflow-ellipsis whitespace-nowrap
${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
>
{noteTagsController.getLongTitle(tag)}
</span>
</button>
))}
</DisclosurePanel>
</Disclosure>
>
{noteTagsController.getLongTitle(tag)}
</span>
</button>
))}
</Popover>
</div>
)
}

View File

@@ -1,12 +1,10 @@
import { KeyboardKey } from '@/Services/IOService'
import { WebApplication } from '@/Application/Application'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import ChangeEditorMenu from '@/Components/ChangeEditor/ChangeEditorMenu'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import Popover from '../Popover/Popover'
type ChangeEditorOptionProps = {
application: WebApplication
@@ -15,91 +13,48 @@ type ChangeEditorOptionProps = {
const ChangeEditorOption: FunctionComponent<ChangeEditorOptionProps> = ({ application, note }) => {
const [isOpen, setIsOpen] = useState(false)
const [isVisible, setIsVisible] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
})
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, (open: boolean) => {
setIsOpen(open)
setIsVisible(open)
})
const toggleChangeEditorMenu = useCallback(() => {
if (!isOpen) {
const menuStyle = calculateSubmenuStyle(buttonRef.current)
if (menuStyle) {
setMenuStyle(menuStyle)
}
}
setIsOpen(!isOpen)
}, [isOpen])
useEffect(() => {
if (isOpen) {
setTimeout(() => {
const newMenuStyle = calculateSubmenuStyle(buttonRef.current, menuRef.current)
if (newMenuStyle) {
setMenuStyle(newMenuStyle)
setIsVisible(true)
}
}, 5)
}
}, [isOpen])
const toggleMenu = useCallback(async () => {
setIsOpen((isOpen) => !isOpen)
}, [])
return (
<div ref={menuContainerRef}>
<Disclosure open={isOpen} onChange={toggleChangeEditorMenu}>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false)
}
<button
onClick={toggleMenu}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false)
}
}}
ref={buttonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="dashboard" className="mr-2 text-neutral" />
Change note type
</div>
<Icon type="chevron-right" className="text-neutral" />
</button>
<Popover
align="start"
anchorElement={buttonRef.current}
className="pt-2 md:pt-0"
open={isOpen}
side="right"
togglePopover={toggleMenu}
>
<ChangeEditorMenu
application={application}
note={note}
isVisible={isOpen}
closeMenu={() => {
setIsOpen(false)
}}
onBlur={closeOnBlur}
ref={buttonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="dashboard" className="mr-2 text-neutral" />
Change note type
</div>
<Icon type="chevron-right" className="text-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false)
buttonRef.current?.focus()
}
}}
style={{
...menuStyle,
position: 'fixed',
}}
className="max-h-120 fixed flex min-w-68 flex-col overflow-y-auto rounded bg-default shadow-main"
>
{isOpen && (
<ChangeEditorMenu
application={application}
closeOnBlur={closeOnBlur}
note={note}
isVisible={isVisible}
closeMenu={() => {
setIsOpen(false)
}}
/>
)}
</DisclosurePanel>
</Disclosure>
/>
</Popover>
</div>
)
}

View File

@@ -9,10 +9,9 @@ import Spinner from '@/Components/Spinner/Spinner'
type ListedActionsMenuProps = {
application: WebApplication
note: SNNote
recalculateMenuStyle: () => void
}
const ListedActionsMenu = ({ application, note, recalculateMenuStyle }: ListedActionsMenuProps) => {
const ListedActionsMenu = ({ application, note }: ListedActionsMenuProps) => {
const [menuGroups, setMenuGroups] = useState<ListedMenuGroup[]>([])
const [isFetchingAccounts, setIsFetchingAccounts] = useState(true)
@@ -88,14 +87,11 @@ const ListedActionsMenu = ({ application, note, recalculateMenuStyle }: ListedAc
console.error(err)
} finally {
setIsFetchingAccounts(false)
setTimeout(() => {
recalculateMenuStyle()
})
}
}
void fetchListedAccounts()
}, [application, note.uuid, recalculateMenuStyle])
}, [application, note.uuid])
return (
<>

View File

@@ -1,11 +1,10 @@
import { WebApplication } from '@/Application/Application'
import { calculateSubmenuStyle, SubmenuStyle } from '@/Utils/CalculateSubmenuStyle'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import ListedActionsMenu from './ListedActionsMenu'
import { KeyboardKey } from '@/Services/IOService'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
@@ -14,74 +13,42 @@ type Props = {
const ListedActionsOption: FunctionComponent<Props> = ({ application, note }) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuButtonRef = useRef<HTMLButtonElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<SubmenuStyle>({
right: 0,
bottom: 0,
maxHeight: 'auto',
})
const [isOpen, setIsOpen] = useState(false)
const [closeOnBlur] = useCloseOnBlur(menuContainerRef, setIsMenuOpen)
const toggleListedMenu = useCallback(() => {
if (!isMenuOpen) {
const menuPosition = calculateSubmenuStyle(menuButtonRef.current)
if (menuPosition) {
setMenuStyle(menuPosition)
}
}
setIsMenuOpen(!isMenuOpen)
}, [isMenuOpen])
const recalculateMenuStyle = useCallback(() => {
const newMenuPosition = calculateSubmenuStyle(menuButtonRef.current, menuRef.current)
if (newMenuPosition) {
setMenuStyle(newMenuPosition)
}
const toggleMenu = useCallback(() => {
setIsOpen((isOpen) => !isOpen)
}, [])
useEffect(() => {
if (isMenuOpen) {
setTimeout(() => {
recalculateMenuStyle()
})
}
}, [isMenuOpen, recalculateMenuStyle])
return (
<div ref={menuContainerRef}>
<Disclosure open={isMenuOpen} onChange={toggleListedMenu}>
<DisclosureButton
ref={menuButtonRef}
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="listed" className="mr-2 text-neutral" />
Listed actions
</div>
<Icon type="chevron-right" className="text-neutral" />
</DisclosureButton>
<DisclosurePanel
ref={menuRef}
style={{
...menuStyle,
position: 'fixed',
}}
className={`${
isMenuOpen ? 'flex' : 'hidden'
} max-h-120 fixed min-w-68 flex-col overflow-y-auto rounded bg-default pb-1 shadow-main`}
>
{isMenuOpen && (
<ListedActionsMenu application={application} note={note} recalculateMenuStyle={recalculateMenuStyle} />
)}
</DisclosurePanel>
</Disclosure>
<button
onClick={toggleMenu}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsOpen(false)
}
}}
ref={buttonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="listed" className="mr-2 text-neutral" />
Listed actions
</div>
<Icon type="chevron-right" className="text-neutral" />
</button>
<Popover
togglePopover={toggleMenu}
anchorElement={buttonRef.current}
open={isOpen}
side="right"
align="end"
className="pt-2 md:pt-0"
>
<ListedActionsMenu application={application} note={note} />
</Popover>
</div>
)
}

View File

@@ -15,13 +15,11 @@ import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
type DeletePermanentlyButtonProps = {
closeOnBlur: NotesOptionsProps['closeOnBlur']
onClick: () => void
}
const DeletePermanentlyButton = ({ closeOnBlur, onClick }: DeletePermanentlyButtonProps) => (
const DeletePermanentlyButton = ({ onClick }: DeletePermanentlyButtonProps) => (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={onClick}
>
@@ -177,7 +175,6 @@ const NotesOptions = ({
notesController,
noteTagsController,
historyModalController,
closeOnBlur,
}: NotesOptionsProps) => {
const [altKeyDown, setAltKeyDown] = useState(false)
@@ -270,7 +267,6 @@ const NotesOptions = ({
{notes.length === 1 && (
<>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={openRevisionHistoryModal}
>
@@ -285,7 +281,6 @@ const NotesOptions = ({
onClick={() => {
notesController.setLockSelectedNotes(!locked)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="pencil-off" className={iconClass} />
@@ -298,7 +293,6 @@ const NotesOptions = ({
onClick={() => {
notesController.setHideSelectedNotePreviews(!hidePreviews)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="rich-text" className={iconClass} />
@@ -311,7 +305,6 @@ const NotesOptions = ({
onClick={() => {
notesController.setProtectSelectedNotes(!protect).catch(console.error)
}}
onBlur={closeOnBlur}
>
<span className="flex items-center">
<Icon type="password" className={iconClass} />
@@ -335,7 +328,6 @@ const NotesOptions = ({
)}
{unpinned && (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
notesController.setPinSelectedNotes(true)
@@ -347,7 +339,6 @@ const NotesOptions = ({
)}
{pinned && (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
notesController.setPinSelectedNotes(false)
@@ -358,7 +349,6 @@ const NotesOptions = ({
</button>
)}
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={downloadSelectedItems}
>
@@ -366,7 +356,6 @@ const NotesOptions = ({
Export
</button>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={duplicateSelectedItems}
>
@@ -375,7 +364,6 @@ const NotesOptions = ({
</button>
{unarchived && (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
notesController.setArchiveSelectedNotes(true).catch(console.error)
@@ -387,7 +375,6 @@ const NotesOptions = ({
)}
{archived && (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
notesController.setArchiveSelectedNotes(false).catch(console.error)
@@ -400,14 +387,12 @@ const NotesOptions = ({
{notTrashed &&
(altKeyDown ? (
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await notesController.deleteNotesPermanently()
}}
/>
) : (
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={async () => {
await notesController.setTrashSelectedNotes(true)
@@ -420,7 +405,6 @@ const NotesOptions = ({
{trashed && (
<>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={async () => {
await notesController.setTrashSelectedNotes(false)
@@ -430,13 +414,11 @@ const NotesOptions = ({
<span className="text-success">Restore</span>
</button>
<DeletePermanentlyButton
closeOnBlur={closeOnBlur}
onClick={async () => {
await notesController.deleteNotesPermanently()
}}
/>
<button
onBlur={closeOnBlur}
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={async () => {
await notesController.emptyTrash()

View File

@@ -1,16 +1,13 @@
import Icon from '@/Components/Icon/Icon'
import VisuallyHidden from '@reach/visually-hidden'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { observer } from 'mobx-react-lite'
import NotesOptions from './NotesOptions'
import { WebApplication } from '@/Application/Application'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { NotesController } from '@/Controllers/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import Popover from '../Popover/Popover'
type Props = {
application: WebApplication
@@ -29,83 +26,38 @@ const NotesOptionsPanel = ({
historyModalController,
onClickPreprocessing,
}: Props) => {
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({
top: 0,
right: 0,
})
const [maxHeight, setMaxHeight] = useState<number | 'auto'>('auto')
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const [closeOnBlur] = useCloseOnBlur(panelRef, setOpen)
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen])
return (
<Disclosure
open={open}
onChange={async () => {
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,
})
const newOpenState = !open
if (newOpenState && onClickPreprocessing) {
await onClickPreprocessing()
}
setOpen(newOpenState)
}
}}
>
<DisclosureButton
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
}
}}
onBlur={closeOnBlur}
ref={buttonRef}
<>
<button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
title="Note options menu"
aria-label="Note options menu"
onClick={toggleMenu}
ref={buttonRef}
>
<VisuallyHidden>Actions</VisuallyHidden>
<Icon type="more" className="block" />
</DisclosureButton>
<DisclosurePanel
onKeyDown={(event) => {
if (event.key === 'Escape') {
setOpen(false)
buttonRef.current?.focus()
}
}}
ref={panelRef}
style={{
...position,
maxHeight,
}}
className={`${
open ? 'flex' : 'hidden'
} max-h-120 slide-down-animation fixed min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main transition-transform duration-150`}
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
{open && (
<NotesOptions
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
historyModalController={historyModalController}
closeOnBlur={closeOnBlur}
/>
)}
</DisclosurePanel>
</Disclosure>
<Icon type="more" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="py-2">
<NotesOptions
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
historyModalController={historyModalController}
/>
</Popover>
</>
)
}

View File

@@ -10,5 +10,4 @@ export type NotesOptionsProps = {
notesController: NotesController
noteTagsController: NoteTagsController
historyModalController: HistoryModalController
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}

View File

@@ -0,0 +1,54 @@
import { CSSProperties } from 'react'
import { PopoverAlignment, PopoverSide } from './Types'
import { OppositeSide, checkCollisions, getNonCollidingSide, getNonCollidingAlignment } from './Utils/Collisions'
import { getPositionedPopoverRect } from './Utils/Rect'
const getStylesFromRect = (rect: DOMRect): CSSProperties => {
return {
willChange: 'transform',
transform: `translate(${rect.x}px, ${rect.y}px)`,
}
}
type Options = {
align: PopoverAlignment
anchorRect?: DOMRect
documentRect: DOMRect
popoverRect?: DOMRect
side: PopoverSide
}
export const getPositionedPopoverStyles = ({
align,
anchorRect,
documentRect,
popoverRect,
side,
}: Options): [CSSProperties | null, PopoverSide, PopoverAlignment] => {
if (!popoverRect || !anchorRect) {
return [null, side, align]
}
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
if (!matchesMediumBreakpoint) {
return [null, side, align]
}
const rectForPreferredSide = getPositionedPopoverRect(popoverRect, anchorRect, side, align)
const preferredSideRectCollisions = checkCollisions(rectForPreferredSide, documentRect)
const oppositeSide = OppositeSide[side]
const rectForOppositeSide = getPositionedPopoverRect(popoverRect, anchorRect, oppositeSide, align)
const oppositeSideRectCollisions = checkCollisions(rectForOppositeSide, documentRect)
const finalSide = getNonCollidingSide(side, preferredSideRectCollisions, oppositeSideRectCollisions)
const finalAlignment = getNonCollidingAlignment(finalSide, align, preferredSideRectCollisions, {
popoverRect,
buttonRect: anchorRect,
documentRect,
})
const finalPositionedRect = getPositionedPopoverRect(popoverRect, anchorRect, finalSide, finalAlignment)
return [getStylesFromRect(finalPositionedRect), finalSide, finalAlignment]
}

View File

@@ -0,0 +1,36 @@
import PositionedPopoverContent from './PositionedPopoverContent'
import { PopoverProps } from './Types'
type Props = PopoverProps & {
open: boolean
}
const Popover = ({
align,
anchorElement,
anchorPoint,
children,
className,
open,
overrideZIndex,
side,
togglePopover,
}: Props) => {
return open ? (
<>
<PositionedPopoverContent
align={align}
anchorElement={anchorElement}
anchorPoint={anchorPoint}
className={className}
overrideZIndex={overrideZIndex}
side={side}
togglePopover={togglePopover}
>
{children}
</PositionedPopoverContent>
</>
) : null
}
export default Popover

View File

@@ -0,0 +1,80 @@
import { useDocumentRect } from '@/Hooks/useDocumentRect'
import { useAutoElementRect } from '@/Hooks/useElementRect'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { useState } from 'react'
import Icon from '../Icon/Icon'
import Portal from '../Portal/Portal'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
import { PopoverContentProps } from './Types'
import { getPopoverMaxHeight, getAppRect } from './Utils/Rect'
import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside'
const PositionedPopoverContent = ({
align = 'end',
anchorElement,
anchorPoint,
children,
className,
overrideZIndex,
side = 'bottom',
togglePopover,
}: PopoverContentProps) => {
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
const popoverRect = useAutoElementRect(popoverElement)
const anchorElementRect = useAutoElementRect(anchorElement, {
updateOnWindowResize: true,
})
const anchorPointRect = DOMRect.fromRect({
x: anchorPoint?.x,
y: anchorPoint?.y,
})
const anchorRect = anchorPoint ? anchorPointRect : anchorElementRect
const documentRect = useDocumentRect()
const [styles, positionedSide, positionedAlignment] = getPositionedPopoverStyles({
align,
anchorRect,
documentRect,
popoverRect: popoverRect ?? popoverElement?.getBoundingClientRect(),
side,
})
usePopoverCloseOnClickOutside({
popoverElement,
anchorElement,
togglePopover,
})
return (
<Portal>
<div
className={classNames(
'absolute top-0 left-0 flex h-full w-full min-w-80 cursor-auto flex-col overflow-y-auto rounded bg-default shadow-main md:h-auto md:max-w-xs',
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
className,
)}
style={{
...styles,
maxHeight: getPopoverMaxHeight(getAppRect(documentRect), anchorRect, positionedSide, positionedAlignment),
}}
ref={(node) => {
setPopoverElement(node)
}}
data-popover
>
<div className="md:hidden">
<div className="flex items-center justify-end px-3">
<button className="rounded-full border border-border p-1" onClick={togglePopover}>
<Icon type="close" className="h-4 w-4" />
</button>
</div>
<HorizontalSeparator classes="my-2" />
</div>
{children}
</div>
</Portal>
)
}
export default PositionedPopoverContent

View File

@@ -0,0 +1,49 @@
import { ReactNode } from 'react'
export type PopoverState = 'closed' | 'positioning' | 'open'
export type PopoverElement = HTMLDivElement | HTMLMenuElement
export type PopoverSide = 'top' | 'left' | 'bottom' | 'right'
export type PopoverAlignment = 'start' | 'center' | 'end'
export type PopoverOptions = {
side: PopoverSide
align: PopoverAlignment
}
export type RectCollisions = Record<PopoverSide, boolean>
type Point = {
x: number
y: number
}
type PopoverAnchorElementProps = {
anchorElement: HTMLElement | null
anchorPoint?: never
}
type PopoverAnchorPointProps = {
anchorPoint: Point
anchorElement?: never
}
type CommonPopoverProps = {
align?: PopoverAlignment
children: ReactNode
side?: PopoverSide
overrideZIndex?: string
togglePopover: () => void
className?: string
}
export type PopoverContentProps = CommonPopoverProps & {
anchorElement?: HTMLElement | null
anchorPoint?: Point
}
export type PopoverProps =
| (CommonPopoverProps & PopoverAnchorElementProps)
| (CommonPopoverProps & PopoverAnchorPointProps)

View File

@@ -0,0 +1,86 @@
import { PopoverSide, PopoverAlignment, RectCollisions } from '../Types'
import { getAppRect, getPositionedPopoverRect } from './Rect'
export const OppositeSide: Record<PopoverSide, PopoverSide> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left',
}
export const checkCollisions = (popoverRect: DOMRect, containerRect: DOMRect): RectCollisions => {
const appRect = getAppRect(containerRect)
return {
top: popoverRect.top < appRect.top,
left: popoverRect.left < appRect.left,
bottom: popoverRect.bottom > appRect.bottom,
right: popoverRect.right > appRect.right,
}
}
export const getNonCollidingSide = (
preferredSide: PopoverSide,
preferredSideCollisions: RectCollisions,
oppositeSideCollisions: RectCollisions,
): PopoverSide => {
const oppositeSide = OppositeSide[preferredSide]
return preferredSideCollisions[preferredSide] && !oppositeSideCollisions[oppositeSide] ? oppositeSide : preferredSide
}
const OppositeAlignment: Record<Exclude<PopoverAlignment, 'center'>, PopoverAlignment> = {
start: 'end',
end: 'start',
}
export const getNonCollidingAlignment = (
finalSide: PopoverSide,
preferredAlignment: PopoverAlignment,
collisions: RectCollisions,
{
popoverRect,
buttonRect,
documentRect,
}: {
popoverRect: DOMRect
buttonRect: DOMRect
documentRect: DOMRect
},
): PopoverAlignment => {
const isHorizontalSide = finalSide === 'top' || finalSide === 'bottom'
const boundToCheckForStart = isHorizontalSide ? 'right' : 'bottom'
const boundToCheckForEnd = isHorizontalSide ? 'left' : 'top'
const prefersAligningAtStart = preferredAlignment === 'start'
const prefersAligningAtCenter = preferredAlignment === 'center'
const prefersAligningAtEnd = preferredAlignment === 'end'
if (prefersAligningAtCenter) {
if (collisions[boundToCheckForStart]) {
return 'end'
}
if (collisions[boundToCheckForEnd]) {
return 'start'
}
} else {
const oppositeAlignmentCollisions = checkCollisions(
getPositionedPopoverRect(popoverRect, buttonRect, finalSide, OppositeAlignment[preferredAlignment]),
documentRect,
)
if (
prefersAligningAtStart &&
collisions[boundToCheckForStart] &&
!oppositeAlignmentCollisions[boundToCheckForEnd]
) {
return 'end'
}
if (prefersAligningAtEnd && collisions[boundToCheckForEnd] && !oppositeAlignmentCollisions[boundToCheckForStart]) {
return 'start'
}
}
return preferredAlignment
}

View File

@@ -0,0 +1,120 @@
import { PopoverSide, PopoverAlignment } from '../Types'
export const getPopoverMaxHeight = (
appRect: DOMRect,
buttonRect: DOMRect | undefined,
side: PopoverSide,
alignment: PopoverAlignment,
): number | 'none' => {
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
if (!matchesMediumBreakpoint) {
return 'none'
}
const MarginFromAppBorderInPX = 10
let constraint = 0
if (buttonRect) {
switch (side) {
case 'top':
constraint = appRect.height - buttonRect.top
break
case 'bottom':
constraint = buttonRect.bottom
break
case 'left':
case 'right':
switch (alignment) {
case 'start':
constraint = buttonRect.top
break
case 'end':
constraint = appRect.height - buttonRect.bottom
break
}
break
}
}
return appRect.height - constraint - MarginFromAppBorderInPX
}
export const getMaxHeightAdjustedRect = (rect: DOMRect, maxHeight: number) => {
return DOMRect.fromRect({
width: rect.width,
height: rect.height < maxHeight ? rect.height : maxHeight,
x: rect.x,
y: rect.y,
})
}
export const getAppRect = (updatedDocumentRect?: DOMRect) => {
const footerRect = document.querySelector('footer')?.getBoundingClientRect()
const documentRect = updatedDocumentRect ? updatedDocumentRect : document.documentElement.getBoundingClientRect()
const appRect = footerRect
? DOMRect.fromRect({
width: documentRect.width,
height: documentRect.height - footerRect.height,
})
: documentRect
return appRect
}
export const getPositionedPopoverRect = (
popoverRect: DOMRect,
buttonRect: DOMRect,
side: PopoverSide,
align: PopoverAlignment,
): DOMRect => {
const { width, height } = popoverRect
const positionPopoverRect = DOMRect.fromRect(popoverRect)
switch (side) {
case 'top': {
positionPopoverRect.y = buttonRect.top - height
break
}
case 'bottom':
positionPopoverRect.y = buttonRect.bottom
break
case 'left':
positionPopoverRect.x = buttonRect.left - width
break
case 'right':
positionPopoverRect.x = buttonRect.right
break
}
if (side === 'top' || side === 'bottom') {
switch (align) {
case 'start':
positionPopoverRect.x = buttonRect.left
break
case 'center':
positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2
break
case 'end':
positionPopoverRect.x = buttonRect.right - width
break
}
} else {
switch (align) {
case 'start':
positionPopoverRect.y = buttonRect.top
break
case 'center':
positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2
break
case 'end':
positionPopoverRect.y = buttonRect.bottom - height
break
}
}
return positionPopoverRect
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from 'react'
type Options = {
popoverElement: HTMLElement | null
anchorElement: HTMLElement | null | undefined
togglePopover: () => void
}
export const usePopoverCloseOnClickOutside = ({ popoverElement, anchorElement, togglePopover }: Options) => {
useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => {
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
if (!matchesMediumBreakpoint) {
return
}
const target = event.target as Element
const isDescendantOfMenu = popoverElement?.contains(target)
const isAnchorElement = anchorElement ? anchorElement === event.target || anchorElement.contains(target) : false
const isDescendantOfPopover = target.closest('[data-popover]')
if (!isDescendantOfMenu && !isAnchorElement && !isDescendantOfPopover) {
togglePopover()
}
}
document.addEventListener('click', closeIfClickedOutside, { capture: true })
return () => {
document.removeEventListener('click', closeIfClickedOutside, {
capture: true,
})
}
}, [anchorElement, popoverElement, togglePopover])
}

View File

@@ -0,0 +1,24 @@
import { ReactNode, useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
type Props = {
children: ReactNode
}
const randomPortalId = () => Math.random()
const Portal = ({ children }: Props) => {
const [container, setContainer] = useState<HTMLElement>()
useEffect(() => {
const container = document.createElement('div')
container.id = `react-portal-${randomPortalId()}`
document.body.append(container)
setContainer(container)
return () => container.remove()
}, [])
return container ? createPortal(children, container) : null
}
export default Portal

View File

@@ -1,27 +1,26 @@
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Services/ViewControllerManager'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { ComponentArea, ContentType, FeatureIdentifier, GetFeatures, SNComponent } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { quickSettingsKeyDownHandler, themesMenuKeyDownHandler } from './EventHandlers'
import { quickSettingsKeyDownHandler } from './EventHandlers'
import FocusModeSwitch from './FocusModeSwitch'
import ThemesMenuButton from './ThemesMenuButton'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { ThemeItem } from './ThemeItem'
import { sortThemes } from '@/Utils/SortThemes'
import RadioIndicator from '../RadioIndicator/RadioIndicator'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import Popover from '../Popover/Popover'
import { PreferencesController } from '@/Controllers/PreferencesController'
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
const focusModeAnimationDuration = 1255
type MenuProps = {
viewControllerManager: ViewControllerManager
preferencesController: PreferencesController
quickSettingsMenuController: QuickSettingsController
application: WebApplication
onClickOutside: () => void
}
const toggleFocusMode = (enabled: boolean) => {
@@ -38,25 +37,23 @@ const toggleFocusMode = (enabled: boolean) => {
}
}
const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, viewControllerManager, onClickOutside }) => {
const { closeQuickSettingsMenu, shouldAnimateCloseMenu, focusModeEnabled, setFocusModeEnabled } =
viewControllerManager.quickSettingsMenuController
const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
application,
preferencesController,
quickSettingsMenuController,
}) => {
const { closeQuickSettingsMenu, focusModeEnabled, setFocusModeEnabled } = quickSettingsMenuController
const [themes, setThemes] = useState<ThemeItem[]>([])
const [toggleableComponents, setToggleableComponents] = useState<SNComponent[]>([])
const [themesMenuOpen, setThemesMenuOpen] = useState(false)
const [themesMenuPosition, setThemesMenuPosition] = useState({})
const [defaultThemeOn, setDefaultThemeOn] = useState(false)
const themesMenuRef = useRef<HTMLDivElement>(null)
const themesButtonRef = useRef<HTMLButtonElement>(null)
const prefsButtonRef = useRef<HTMLButtonElement>(null)
const quickSettingsMenuRef = useRef<HTMLDivElement>(null)
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null)
const mainRef = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(mainRef, () => {
onClickOutside()
})
useEffect(() => {
toggleFocusMode(focusModeEnabled)
@@ -139,25 +136,14 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, viewCont
prefsButtonRef.current?.focus()
}, [])
const [closeOnBlur] = useCloseOnBlur(themesMenuRef, setThemesMenuOpen)
const toggleThemesMenu = useCallback(() => {
if (!themesMenuOpen && themesButtonRef.current) {
const themesButtonRect = themesButtonRef.current.getBoundingClientRect()
setThemesMenuPosition({
left: themesButtonRect.right,
bottom: document.documentElement.clientHeight - themesButtonRect.bottom,
})
setThemesMenuOpen(true)
} else {
setThemesMenuOpen(false)
}
}, [themesMenuOpen])
setThemesMenuOpen((isOpen) => !isOpen)
}, [])
const openPreferences = useCallback(() => {
closeQuickSettingsMenu()
viewControllerManager.preferencesController.openPreferences()
}, [viewControllerManager, closeQuickSettingsMenu])
preferencesController.openPreferences()
}, [closeQuickSettingsMenu, preferencesController])
const toggleComponent = useCallback(
(component: SNComponent) => {
@@ -193,10 +179,6 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, viewCont
[closeQuickSettingsMenu, themesMenuOpen],
)
const handlePanelKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback((event) => {
themesMenuKeyDownHandler(event, themesMenuRef, setThemesMenuOpen, themesButtonRef)
}, [])
const toggleDefaultTheme = useCallback(() => {
const activeTheme = themes.map((item) => item.component).find((theme) => theme?.active && !theme.isLayerable())
if (activeTheme) {
@@ -205,90 +187,71 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, viewCont
}, [application, themes])
return (
<div ref={mainRef} className="sn-component">
<div
className={`max-h-120 absolute bottom-full left-0 z-footer-bar-item-panel flex min-w-80 max-w-xs cursor-auto flex-col overflow-y-auto rounded bg-default py-2 shadow-main ${
shouldAnimateCloseMenu ? 'slide-up-animation' : 'slide-down-animation transition-transform duration-150'
}`}
ref={quickSettingsMenuRef}
onKeyDown={handleQuickSettingsKeyDown}
<div ref={mainRef} onKeyDown={handleQuickSettingsKeyDown}>
<div className="mt-1 mb-2 px-3 text-sm font-semibold uppercase text-text">Quick Settings</div>
<button
onClick={toggleThemesMenu}
onKeyDown={handleBtnKeyDown}
ref={themesButtonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="mt-1 mb-2 px-3 text-sm font-semibold uppercase text-text">Quick Settings</div>
<Disclosure open={themesMenuOpen} onChange={toggleThemesMenu}>
<DisclosureButton
onKeyDown={handleBtnKeyDown}
onBlur={closeOnBlur}
ref={themesButtonRef}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
>
<div className="flex items-center">
<Icon type="themes" className="mr-2 text-neutral" />
Themes
</div>
<Icon type="chevron-right" className="text-neutral" />
</DisclosureButton>
<DisclosurePanel
onBlur={closeOnBlur}
ref={themesMenuRef}
onKeyDown={handlePanelKeyDown}
style={{
...themesMenuPosition,
}}
className={`${
themesMenuOpen ? 'flex' : 'hidden'
} max-h-120 slide-down-animation fixed min-w-80 max-w-xs flex-col overflow-y-auto rounded bg-default py-2 shadow-main transition-transform duration-150`}
>
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Themes</div>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={toggleDefaultTheme}
onBlur={closeOnBlur}
ref={defaultThemeButtonRef}
>
<RadioIndicator checked={defaultThemeOn} className="mr-2" />
Default
</button>
{themes.map((theme) => (
<ThemesMenuButton
item={theme}
application={application}
key={theme.component?.uuid ?? theme.identifier}
onBlur={closeOnBlur}
/>
))}
</DisclosurePanel>
</Disclosure>
{toggleableComponents.map((component) => (
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
toggleComponent(component)
}}
key={component.uuid}
>
<div className="flex items-center">
<Icon type="window" className="mr-2 text-neutral" />
{component.displayName}
</div>
<Switch checked={component.active} className="px-0" />
</button>
))}
<FocusModeSwitch
application={application}
onToggle={setFocusModeEnabled}
onClose={closeQuickSettingsMenu}
isEnabled={focusModeEnabled}
/>
<HorizontalSeparator classes="my-2" />
<div className="flex items-center">
<Icon type="themes" className="mr-2 text-neutral" />
Themes
</div>
<Icon type="chevron-right" className="text-neutral" />
</button>
<Popover
togglePopover={toggleThemesMenu}
anchorElement={themesButtonRef.current}
open={themesMenuOpen}
side="right"
align="end"
className="py-2"
>
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Themes</div>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={openPreferences}
ref={prefsButtonRef}
onClick={toggleDefaultTheme}
ref={defaultThemeButtonRef}
>
<Icon type="more" className="mr-2 text-neutral" />
Open Preferences
<RadioIndicator checked={defaultThemeOn} className="mr-2" />
Default
</button>
</div>
{themes.map((theme) => (
<ThemesMenuButton item={theme} application={application} key={theme.component?.uuid ?? theme.identifier} />
))}
</Popover>
{toggleableComponents.map((component) => (
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
toggleComponent(component)
}}
key={component.uuid}
>
<div className="flex items-center">
<Icon type="window" className="mr-2 text-neutral" />
{component.displayName}
</div>
<Switch checked={component.active} className="px-0" />
</button>
))}
<FocusModeSwitch
application={application}
onToggle={setFocusModeEnabled}
onClose={closeQuickSettingsMenu}
isEnabled={focusModeEnabled}
/>
<HorizontalSeparator classes="my-2" />
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={openPreferences}
ref={prefsButtonRef}
>
<Icon type="more" className="mr-2 text-neutral" />
Open Preferences
</button>
</div>
)
}

View File

@@ -11,10 +11,9 @@ import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/Premium
type Props = {
item: ThemeItem
application: WebApplication
onBlur: (event: { relatedTarget: EventTarget | null }) => void
}
const ThemesMenuButton: FunctionComponent<Props> = ({ application, item, onBlur }) => {
const ThemesMenuButton: FunctionComponent<Props> = ({ application, item }) => {
const premiumModal = usePremiumModal()
const isThirdPartyTheme = useMemo(
@@ -50,7 +49,6 @@ const ThemesMenuButton: FunctionComponent<Props> = ({ application, item, onBlur
'flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:bg-info-backdrop focus:shadow-none focus:shadow-none'
}
onClick={toggleTheme}
onBlur={onBlur}
>
{item.component?.isLayerable() ? (
<>

View File

@@ -1,5 +1,5 @@
import { observer } from 'mobx-react-lite'
import { useCallback, useEffect, useRef, useMemo } from 'react'
import { useCallback, useRef, useMemo } from 'react'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
@@ -11,6 +11,7 @@ import { NavigationController } from '@/Controllers/Navigation/NavigationControl
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import Popover from '../Popover/Popover'
type ContextMenuProps = {
navigationController: NavigationController
@@ -21,22 +22,11 @@ type ContextMenuProps = {
const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag }: ContextMenuProps) => {
const premiumModal = usePremiumModal()
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = navigationController
const { contextMenuOpen, contextMenuClickLocation } = navigationController
const contextMenuRef = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(contextMenuRef, () => navigationController.setContextMenuOpen(false))
const reloadContextMenuLayout = useCallback(() => {
navigationController.reloadContextMenuLayout()
}, [navigationController])
useEffect(() => {
window.addEventListener('resize', reloadContextMenuLayout)
return () => {
window.removeEventListener('resize', reloadContextMenuLayout)
}
}, [reloadContextMenuLayout])
const onClickAddSubtag = useCallback(() => {
if (!isEntitledToFolders) {
premiumModal.activate('Folders')
@@ -63,52 +53,46 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
const tagCreatedAt = useMemo(() => formatDateForContextMenu(selectedTag.created_at), [selectedTag.created_at])
return contextMenuOpen ? (
<div
ref={contextMenuRef}
className="max-h-120 fixed z-dropdown-menu flex min-w-60 flex-col overflow-y-auto rounded bg-default py-2 shadow-main"
style={{
...contextMenuPosition,
maxHeight: contextMenuMaxHeight,
}}
return (
<Popover
open={contextMenuOpen}
anchorPoint={contextMenuClickLocation}
togglePopover={() => navigationController.setContextMenuOpen(!contextMenuOpen)}
className="py-2"
>
<Menu
a11yLabel="Tag context menu"
isOpen={contextMenuOpen}
closeMenu={() => {
navigationController.setContextMenuOpen(false)
}}
>
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickAddSubtag}>
<div className="flex items-center">
<Icon type="add" className="mr-2 text-neutral" />
Add subtag
<div ref={contextMenuRef}>
<Menu a11yLabel="Tag context menu" isOpen={contextMenuOpen}>
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickAddSubtag}>
<div className="flex items-center">
<Icon type="add" className="mr-2 text-neutral" />
Add subtag
</div>
{!isEntitledToFolders && <Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />}
</MenuItem>
<MenuItem type={MenuItemType.IconButton} className={'py-1.5'} onClick={onClickRename}>
<Icon type="pencil-filled" className="mr-2 text-neutral" />
Rename
</MenuItem>
<MenuItem type={MenuItemType.IconButton} className={'py-1.5'} onClick={onClickDelete}>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete</span>
</MenuItem>
</Menu>
<HorizontalSeparator classes="my-2" />
<div className="px-3 pt-1 pb-1.5 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">Last modified:</span> {tagLastModified}
</div>
<div className="mb-1">
<span className="font-semibold">Created:</span> {tagCreatedAt}
</div>
<div>
<span className="font-semibold">Tag ID:</span> {selectedTag.uuid}
</div>
{!isEntitledToFolders && <Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />}
</MenuItem>
<MenuItem type={MenuItemType.IconButton} className={'py-1.5'} onClick={onClickRename}>
<Icon type="pencil-filled" className="mr-2 text-neutral" />
Rename
</MenuItem>
<MenuItem type={MenuItemType.IconButton} className={'py-1.5'} onClick={onClickDelete}>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete</span>
</MenuItem>
</Menu>
<HorizontalSeparator classes="my-2" />
<div className="px-3 pt-1 pb-1.5 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">Last modified:</span> {tagLastModified}
</div>
<div className="mb-1">
<span className="font-semibold">Created:</span> {tagCreatedAt}
</div>
<div>
<span className="font-semibold">Tag ID:</span> {selectedTag.uuid}
</div>
</div>
</div>
) : null
</Popover>
)
}
export default observer(TagContextMenu)

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
const DebounceTimeInMs = 100
export const useDocumentRect = (): DOMRect => {
const [documentRect, setDocumentRect] = useState<DOMRect>(document.documentElement.getBoundingClientRect())
useEffect(() => {
let debounceTimeout: number
const handleWindowResize = () => {
window.clearTimeout(debounceTimeout)
window.setTimeout(() => {
setDocumentRect(document.documentElement.getBoundingClientRect())
}, DebounceTimeInMs)
}
window.addEventListener('resize', handleWindowResize)
return () => window.removeEventListener('resize', handleWindowResize)
}, [])
return documentRect
}

View File

@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
const DebounceTimeInMs = 100
type Options = {
updateOnWindowResize: boolean
}
/**
* Returns the bounding rect of an element, auto-updated when the element resizes.
* Can optionally be auto-update on window resize.
*/
export const useAutoElementRect = (
element: HTMLElement | null | undefined,
{ updateOnWindowResize }: Options = { updateOnWindowResize: false },
) => {
const [rect, setRect] = useState<DOMRect>()
useEffect(() => {
let windowResizeDebounceTimeout: number
let windowResizeHandler: () => void
if (element) {
const resizeObserver = new ResizeObserver(() => {
setRect(element.getBoundingClientRect())
})
resizeObserver.observe(element)
if (updateOnWindowResize) {
windowResizeHandler = () => {
window.clearTimeout(windowResizeDebounceTimeout)
window.setTimeout(() => {
setRect(element.getBoundingClientRect())
}, DebounceTimeInMs)
}
window.addEventListener('resize', windowResizeHandler)
}
return () => {
resizeObserver.unobserve(element)
if (windowResizeHandler) {
window.removeEventListener('resize', windowResizeHandler)
}
}
} else {
setRect(undefined)
return
}
}, [element, updateOnWindowResize])
return rect
}

View File

@@ -1,64 +0,0 @@
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER } from '@/Constants/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 | HTMLMenuElement | 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

@@ -1,4 +1,3 @@
export * from './CalculateSubmenuStyle'
export * from './ConcatenateUint8Arrays'
export * from './IsMobile'
export * from './StringUtils'

View File

@@ -4,10 +4,10 @@
--z-index-resizer-overlay: 1000;
--z-index-component-view: 1000;
--z-index-panel-resizer: 1001;
--z-index-dropdown-menu: 1002;
--z-index-footer-bar: 2000;
--z-index-footer-bar-item: 2000;
--z-index-footer-bar-item-panel: 2000;
--z-index-dropdown-menu: 2500;
--z-index-preferences: 3000;
--z-index-purchase-flow: 4000;
--z-index-lock-screen: 10000;