feat: item linking (#1779)

This commit is contained in:
Aman Harwara
2022-10-11 23:54:00 +05:30
committed by GitHub
parent d22c164e5d
commit e3f28421ff
68 changed files with 2064 additions and 1277 deletions

View File

@@ -209,7 +209,6 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
noteTagsController={viewControllerManager.noteTagsController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
@@ -238,7 +237,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
noteTagsController={viewControllerManager.noteTagsController}
linkingController={viewControllerManager.linkingController}
historyModalController={viewControllerManager.historyModalController}
/>
<TagContextMenuWrapper

View File

@@ -144,7 +144,7 @@ const AttachedFilesButton: FunctionComponent<Props> = ({
}
}}
>
<Icon type="attachment-file" />
<Icon type="folder" />
{attachedFilesCount > 0 && <span className="ml-2 text-sm">{attachedFilesCount}</span>}
</button>
<Popover

View File

@@ -11,6 +11,8 @@ import { PopoverFileItemActionType } from './PopoverFileItemAction'
import { PopoverTabs } from './PopoverTabs'
import { FilesController } from '@/Controllers/FilesController'
import { StreamingFileReader } from '@standardnotes/filepicker'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
import DecoratedInput from '../Input/DecoratedInput'
type Props = {
application: WebApplication
@@ -116,29 +118,24 @@ const AttachedFilesPopover: FunctionComponent<Props> = ({
<div className="max-h-110 min-h-0 overflow-y-auto">
{filteredList.length > 0 || searchQuery.length > 0 ? (
<div className="sticky top-0 left-0 border-b border-solid border-border bg-default p-3">
<div className="relative">
<input
type="text"
className="w-full rounded border border-solid border-border bg-default py-1.5 px-3 text-sm text-text"
placeholder="Search files..."
value={searchQuery}
onInput={(e) => {
setSearchQuery((e.target as HTMLInputElement).value)
}}
ref={searchInputRef}
/>
{searchQuery.length > 0 && (
<button
className="absolute right-2 top-1/2 flex -translate-y-1/2 cursor-pointer border-0 bg-transparent p-0"
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
>
<Icon type="clear-circle-filled" className="text-neutral" />
</button>
)}
</div>
<DecoratedInput
type="text"
className={{ container: searchQuery.length < 1 ? 'py-1.5 px-0.5' : 'py-0' }}
placeholder="Search items..."
value={searchQuery}
onChange={setSearchQuery}
ref={searchInputRef}
right={[
searchQuery.length > 0 && (
<ClearInputButton
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
/>
),
]}
/>
</div>
) : null}
{filteredList.length > 0 ? (

View File

@@ -0,0 +1,15 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { ComponentPropsWithoutRef } from 'react'
import Icon from '../Icon/Icon'
type Props = ComponentPropsWithoutRef<'button'>
const ClearInputButton = ({ className, ...props }: Props) => {
return (
<button className={classNames('flex cursor-pointer border-0 bg-transparent p-0', className)} {...props}>
<Icon type="clear-circle-filled" className="text-neutral" />
</button>
)
}
export default ClearInputButton

View File

@@ -11,7 +11,6 @@ import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { FilesController } from '@/Controllers/FilesController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { NoAccountWarningController } from '@/Controllers/NoAccountWarningController'
import { NotesController } from '@/Controllers/NotesController'
import { AccountMenuController } from '@/Controllers/AccountMenu/AccountMenuController'
@@ -33,7 +32,6 @@ type Props = {
itemListController: ItemListController
navigationController: NavigationController
noAccountWarningController: NoAccountWarningController
noteTagsController: NoteTagsController
notesController: NotesController
selectionController: SelectedItemsController
searchOptionsController: SearchOptionsController
@@ -46,7 +44,6 @@ const ContentListView: FunctionComponent<Props> = ({
itemListController,
navigationController,
noAccountWarningController,
noteTagsController,
notesController,
selectionController,
searchOptionsController,
@@ -167,16 +164,11 @@ const ContentListView: FunctionComponent<Props> = ({
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.NotesPanelWidth, width).catch(console.error)
noteTagsController.reloadTagsContainerMaxWidth()
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, isCollapsed)
},
[application, noteTagsController],
[application],
)
const panelWidthEventCallback = useCallback(() => {
noteTagsController.reloadTagsContainerMaxWidth()
}, [noteTagsController])
const addButtonLabel = useMemo(
() => (isFilesSmartView ? 'Upload file' : 'Create a new note in the selected tag'),
[isFilesSmartView],
@@ -259,7 +251,6 @@ const ContentListView: FunctionComponent<Props> = ({
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>

View File

@@ -32,7 +32,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps> = ({
const editorForNote = application.componentManager.editorForNote(item as SNNote)
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
const [icon, tint] = application.iconsController.getIconAndTintForNoteType(editorForNote?.package_info.note_type)
const hasFiles = application.items.getFilesForNote(item as SNNote).length > 0
const hasFiles = application.items.getSortedFilesForItem(item).length > 0
const openNoteContextMenu = (posX: number, posY: number) => {
notesController.setContextMenuOpen(false)

View File

@@ -5,6 +5,8 @@ import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileViewProps } from './FileViewProps'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
@@ -63,6 +65,10 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
</div>
</div>
<div className="flex items-center gap-3">
<LinkedItemsButton
filesController={viewControllerManager.filesController}
linkingController={viewControllerManager.linkingController}
/>
<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 information panel"
@@ -87,6 +93,7 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
/>
</div>
</div>
<LinkedItemBubblesContainer linkingController={viewControllerManager.linkingController} />
</div>
</div>
<div className="flex min-h-0 flex-grow flex-col">

View File

@@ -0,0 +1,172 @@
import {
ChangeEventHandler,
FocusEventHandler,
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { observer } from 'mobx-react-lite'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import LinkedItemSearchResults from './LinkedItemSearchResults'
import { LinkingController } from '@/Controllers/LinkingController'
import { KeyboardKey } from '@standardnotes/ui-services'
import { ElementIds } from '@/Constants/ElementIDs'
import Menu from '../Menu/Menu'
type Props = {
linkingController: LinkingController
focusPreviousItem: () => void
focusedId: string | undefined
setFocusedId: (id: string) => void
}
const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focusedId, setFocusedId }: Props) => {
const {
tags,
getTitleForLinkedTag,
getLinkedItemIcon,
getSearchResults,
linkItemToSelectedItem,
createAndAddNewTag,
isEntitledToNoteLinking,
} = linkingController
const [searchQuery, setSearchQuery] = useState('')
const { unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
const [dropdownVisible, setDropdownVisible] = useState(false)
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
setDropdownVisible(visible)
setSearchQuery('')
})
const showDropdown = () => {
const { clientHeight } = document.documentElement
const inputRect = inputRef.current?.getBoundingClientRect()
if (inputRect) {
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
setDropdownVisible(true)
}
}
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setSearchQuery(event.currentTarget.value)
}
const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
if (searchQuery !== '') {
await createAndAddNewTag(searchQuery)
}
}
const handleFocus = () => {
if (focusedId !== ElementIds.ItemLinkAutocompleteInput) {
setFocusedId(ElementIds.ItemLinkAutocompleteInput)
}
showDropdown()
}
const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
}
const onKeyDown: KeyboardEventHandler = (event) => {
switch (event.key) {
case KeyboardKey.Left:
if (searchQuery.length === 0) {
focusPreviousItem()
}
break
case KeyboardKey.Down:
if (searchQuery.length > 0) {
searchResultsMenuRef.current?.focus()
}
break
}
}
useEffect(() => {
if (focusedId === ElementIds.ItemLinkAutocompleteInput) {
inputRef.current?.focus()
}
}, [focusedId])
const areSearchResultsVisible = dropdownVisible && (unlinkedResults.length > 0 || shouldShowCreateTag)
const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => {
if (event.key === KeyboardKey.Escape) {
inputRef.current?.focus()
}
}, [])
return (
<div ref={containerRef}>
<form onSubmit={onFormSubmit}>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
className={`${tags.length > 0 ? 'w-80' : 'mr-10 w-70'} no-border h-7
bg-transparent text-xs text-text focus:border-b-2 focus:border-solid focus:border-info focus:shadow-none focus:outline-none`}
value={searchQuery}
onChange={onSearchQueryChange}
type="text"
placeholder="Link tags, notes, files..."
onBlur={onBlur}
onFocus={handleFocus}
onKeyDown={onKeyDown}
id={ElementIds.ItemLinkAutocompleteInput}
autoComplete="off"
/>
{areSearchResultsVisible && (
<DisclosurePanel
className={classNames(
tags.length > 0 ? 'w-80' : 'mr-10 w-70',
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
)}
style={{
maxHeight: dropdownMaxHeight,
}}
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<Menu
isOpen={areSearchResultsVisible}
a11yLabel="Unlinked items search results"
onKeyDown={handleMenuKeyDown}
ref={searchResultsMenuRef}
shouldAutoFocus={false}
>
<LinkedItemSearchResults
createAndAddNewTag={createAndAddNewTag}
getLinkedItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
linkItemToSelectedItem={linkItemToSelectedItem}
results={unlinkedResults}
searchQuery={searchQuery}
shouldShowCreateTag={shouldShowCreateTag}
onClickCallback={() => setSearchQuery('')}
isEntitledToNoteLinking={isEntitledToNoteLinking}
/>
</Menu>
</DisclosurePanel>
)}
</Disclosure>
</form>
</div>
)
}
export default observer(ItemLinkAutocompleteInput)

View File

@@ -0,0 +1,98 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FilesController } from '@/Controllers/FilesController'
import { FileItem } from '@standardnotes/snjs'
import { useState } from 'react'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '../Icon/Icon'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import Switch from '../Switch/Switch'
type Props = {
file: FileItem
closeMenu: () => void
handleFileAction: FilesController['handleFileAction']
setIsRenamingFile: (set: boolean) => void
}
const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamingFile }: Props) => {
const [isFileProtected, setIsFileProtected] = useState(file.protected)
return (
<>
<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={() => {
void handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
payload: {
file,
otherFiles: [],
},
})
closeMenu()
}}
>
<Icon type="file" className="mr-2 text-neutral" />
Preview file
</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)
closeMenu()
}}
>
<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>
</>
)
}
export default LinkedFileMenuOptions

View File

@@ -0,0 +1,118 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite'
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
import Icon from '../Icon/Icon'
type Props = {
item: LinkableItem
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
activateItem: (item: LinkableItem) => Promise<void>
unlinkItem: (item: LinkableItem) => void
focusPreviousItem: () => void
focusNextItem: () => void
focusedId: string | undefined
setFocusedId: (id: string) => void
}
const LinkedItemBubble = ({
item,
getItemIcon,
getTitleForLinkedTag,
activateItem,
unlinkItem,
focusPreviousItem,
focusNextItem,
focusedId,
setFocusedId,
}: Props) => {
const ref = useRef<HTMLButtonElement>(null)
const [showUnlinkButton, setShowUnlinkButton] = useState(false)
const unlinkButtonRef = useRef<HTMLAnchorElement | null>(null)
const [wasClicked, setWasClicked] = useState(false)
const handleFocus = () => {
if (focusedId !== item.uuid) {
setFocusedId(item.uuid)
}
setShowUnlinkButton(true)
}
const onBlur = () => {
setShowUnlinkButton(false)
setWasClicked(false)
}
const onClick: MouseEventHandler = (event) => {
if (wasClicked && event.target !== unlinkButtonRef.current) {
setWasClicked(false)
void activateItem(item)
} else {
setWasClicked(true)
}
}
const onUnlinkClick: MouseEventHandler = (event) => {
event.stopPropagation()
unlinkItem(item)
}
const onKeyDown: KeyboardEventHandler = (event) => {
switch (event.key) {
case KeyboardKey.Backspace: {
focusPreviousItem()
unlinkItem(item)
break
}
case KeyboardKey.Left:
focusPreviousItem()
break
case KeyboardKey.Right:
focusNextItem()
break
}
}
const [icon, iconClassName] = getItemIcon(item)
const tagTitle = getTitleForLinkedTag(item)
useEffect(() => {
if (item.uuid === focusedId) {
ref.current?.focus()
}
}, [focusedId, item.uuid])
return (
<button
ref={ref}
className="flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
onFocus={handleFocus}
onBlur={onBlur}
onClick={onClick}
title={tagTitle ? tagTitle.longTitle : item.title}
onKeyDown={onKeyDown}
>
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" />
<span className="max-w-290px overflow-hidden overflow-ellipsis whitespace-nowrap">
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
{item.title}
</span>
{showUnlinkButton && (
<a
ref={unlinkButtonRef}
role="button"
className="ml-2 -mr-1 flex cursor-pointer border-0 bg-transparent p-0"
onClick={onUnlinkClick}
>
<Icon type="close" className="text-neutral hover:text-info" size="small" />
</a>
)}
</button>
)
}
export default observer(LinkedItemBubble)

View File

@@ -0,0 +1,87 @@
import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useState } from 'react'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@/Utils/ConcatenateClassNames'
type Props = {
linkingController: LinkingController
}
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
const { toggleAppPane } = useResponsiveAppPane()
const {
allLinkedItems,
notesLinkingToItem,
unlinkItemFromSelectedItem: unlinkItem,
getTitleForLinkedTag,
getLinkedItemIcon: getItemIcon,
activateItem,
} = linkingController
const [focusedId, setFocusedId] = useState<string>()
const focusableIds = allLinkedItems.map((item) => item.uuid).concat([ElementIds.ItemLinkAutocompleteInput])
const focusPreviousItem = useCallback(() => {
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
const previousIndex = currentFocusedIndex - 1
if (previousIndex > -1) {
setFocusedId(focusableIds[previousIndex])
}
}, [focusableIds, focusedId])
const focusNextItem = useCallback(() => {
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
const nextIndex = currentFocusedIndex + 1
if (nextIndex < focusableIds.length) {
setFocusedId(focusableIds[nextIndex])
}
}, [focusableIds, focusedId])
const activateItemAndTogglePane = useCallback(
async (item: LinkableItem) => {
const paneId = await activateItem(item)
if (paneId) {
toggleAppPane(paneId)
}
},
[activateItem, toggleAppPane],
)
return (
<div
className={classNames(
'hidden min-w-80 max-w-full flex-wrap items-center gap-2 bg-transparent md:-mr-2 md:flex',
allLinkedItems.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
)}
>
{allLinkedItems.concat(notesLinkingToItem).map((item) => (
<LinkedItemBubble
item={item}
key={item.uuid}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
activateItem={activateItemAndTogglePane}
unlinkItem={unlinkItem}
focusPreviousItem={focusPreviousItem}
focusNextItem={focusNextItem}
focusedId={focusedId}
setFocusedId={setFocusedId}
/>
))}
<ItemLinkAutocompleteInput
focusedId={focusedId}
linkingController={linkingController}
focusPreviousItem={focusPreviousItem}
setFocusedId={setFocusedId}
/>
</div>
)
}
export default observer(LinkedItemBubblesContainer)

View File

@@ -0,0 +1,46 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { splitQueryInString } from '@/Utils'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { observer } from 'mobx-react-lite'
import Icon from '../Icon/Icon'
const LinkedItemMeta = ({
item,
getItemIcon,
getTitleForLinkedTag,
searchQuery,
}: {
item: LinkableItem
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
searchQuery?: string
}) => {
const [icon, className] = getItemIcon(item)
const tagTitle = getTitleForLinkedTag(item)
const title = item.title ?? ''
return (
<>
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
<div className="min-w-0 flex-grow break-words text-left text-sm">
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
{searchQuery
? splitQueryInString(title, searchQuery).map((substring, index) => (
<span
key={index}
className={`${
substring.toLowerCase() === searchQuery.toLowerCase()
? 'whitespace-pre-wrap font-bold'
: 'whitespace-pre-wrap '
}`}
>
{substring}
</span>
))
: title}
</div>
</>
)
}
export default observer(LinkedItemMeta)

View File

@@ -0,0 +1,80 @@
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { observer } from 'mobx-react-lite'
import { SNNote } from '@standardnotes/snjs'
import Icon from '../Icon/Icon'
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import LinkedItemMeta from './LinkedItemMeta'
type Props = {
createAndAddNewTag: LinkingController['createAndAddNewTag']
getLinkedItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
linkItemToSelectedItem: LinkingController['linkItemToSelectedItem']
results: LinkableItem[]
searchQuery: string
shouldShowCreateTag: boolean
onClickCallback?: () => void
isEntitledToNoteLinking: boolean
}
const LinkedItemSearchResults = ({
createAndAddNewTag,
getLinkedItemIcon,
getTitleForLinkedTag,
linkItemToSelectedItem,
results,
searchQuery,
shouldShowCreateTag,
onClickCallback,
isEntitledToNoteLinking,
}: Props) => {
const premiumModal = usePremiumModal()
return (
<div className="my-1">
{results.map((result) => {
const cannotLinkItem = !isEntitledToNoteLinking && result instanceof SNNote
return (
<button
key={result.uuid}
className="flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
onClick={() => {
if (cannotLinkItem) {
premiumModal.activate('Note linking')
} else {
linkItemToSelectedItem(result)
onClickCallback?.()
}
}}
>
<LinkedItemMeta
item={result}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
/>
{cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />}
</button>
)
})}
{shouldShowCreateTag && (
<button
className="group flex w-full items-center gap-2 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
onClick={() => {
createAndAddNewTag(searchQuery)
onClickCallback?.()
}}
>
<span className="flex-shrink-0 align-middle">Create &amp; add tag</span>{' '}
<span className="inline-flex min-w-0 items-center gap-1 rounded bg-contrast py-1 pl-1 pr-2 align-middle text-xs text-text group-hover:bg-info group-hover:text-info-contrast">
<Icon type="hashtag" className="flex-shrink-0 text-info group-hover:text-info-contrast" size="small" />
<span className="min-w-0 overflow-hidden text-ellipsis">{searchQuery}</span>
</span>
</button>
)}
</div>
)
}
export default observer(LinkedItemSearchResults)

View File

@@ -0,0 +1,51 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { observer } from 'mobx-react-lite'
import { useRef, useCallback } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import LinkedItemsPanel from './LinkedItemsPanel'
type Props = {
linkingController: LinkingController
onClickPreprocessing?: () => Promise<void>
filesController: FilesController
}
const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing }: Props) => {
const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isLinkingPanelOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsLinkingPanelOpen(willMenuOpen)
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen])
return (
<>
<StyledTooltip label="Linked items panel">
<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"
aria-label="Linked items panel"
onClick={toggleMenu}
ref={buttonRef}
>
<Icon type="link" />
</button>
</StyledTooltip>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isLinkingPanelOpen} className="pb-2">
<LinkedItemsPanel
isOpen={isLinkingPanelOpen}
linkingController={linkingController}
filesController={filesController}
/>
</Popover>
</>
)
}
export default observer(LinkedItemsButton)

View File

@@ -0,0 +1,352 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import MenuItem from '../Menu/MenuItem'
import { MenuItemType } from '../Menu/MenuItemType'
import Popover from '../Popover/Popover'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
import LinkedItemMeta from './LinkedItemMeta'
import LinkedItemSearchResults from './LinkedItemSearchResults'
const LinkedItemsSectionItem = ({
activateItem,
getItemIcon,
getTitleForLinkedTag,
item,
searchQuery,
unlinkItem,
handleFileAction,
}: {
activateItem: LinkingController['activateItem']
getItemIcon: LinkingController['getLinkedItemIcon']
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
item: LinkableItem
searchQuery?: string
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
handleFileAction: FilesController['handleFileAction']
}) => {
const menuButtonRef = useRef<HTMLButtonElement>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => setIsMenuOpen((open) => !open)
const [isRenamingFile, setIsRenamingFile] = useState(false)
const [icon, className] = getItemIcon(item)
const title = item.title ?? ''
const renameFile = async (name: string) => {
if (!(item instanceof FileItem)) {
return
}
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file: item,
name: name,
},
})
setIsRenamingFile(false)
}
return (
<div className="relative flex items-center justify-between">
{isRenamingFile && item instanceof FileItem ? (
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
<input
className="min-w-0 flex-grow text-sm"
defaultValue={title}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsRenamingFile(false)
} else if (event.key === KeyboardKey.Enter) {
const newTitle = event.currentTarget.value
void renameFile(newTitle)
}
}}
ref={(node) => {
if (node) {
node.focus()
}
}}
/>
</div>
) : (
<button
className="flex flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
onClick={() => activateItem(item)}
onContextMenu={(event) => {
event.preventDefault()
toggleMenu()
}}
>
<LinkedItemMeta
item={item}
getItemIcon={getItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
/>
</button>
)}
<button
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
onClick={toggleMenu}
ref={menuButtonRef}
>
<Icon type="more" className="text-neutral" />
</button>
<Popover
open={isMenuOpen}
togglePopover={toggleMenu}
anchorElement={menuButtonRef.current}
side="bottom"
align="center"
className="py-2"
>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
unlinkItem(item)
toggleMenu()
}}
>
<Icon type="link-off" className="mr-2 text-danger" />
Unlink
</MenuItem>
{item instanceof FileItem && (
<LinkedFileMenuOptions
file={item}
closeMenu={toggleMenu}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
/>
)}
<HorizontalSeparator classes="my-2" />
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
</div>
<div className="mb-1">
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
</div>
<div className="mb-1">
<span className="font-semibold">ID:</span> {item.uuid}
</div>
{item instanceof FileItem && (
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
</div>
)}
</div>
</Popover>
</div>
)
}
const LinkedItemsPanel = ({
linkingController,
filesController,
isOpen,
}: {
linkingController: LinkingController
filesController: FilesController
isOpen: boolean
}) => {
const {
tags,
files,
notesLinkedToItem,
notesLinkingToItem,
allLinkedItems,
getTitleForLinkedTag,
getLinkedItemIcon,
getSearchResults,
linkItemToSelectedItem,
unlinkItemFromSelectedItem,
activateItem,
createAndAddNewTag,
isEntitledToNoteLinking,
} = linkingController
const searchInputRef = useRef<HTMLInputElement | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const isSearching = !!searchQuery.length
const { linkedResults, unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus()
}
}, [isOpen])
return (
<div>
<form
className={classNames(
'sticky top-0 z-10 bg-default px-2.5 pt-2.5',
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToItem.length
? 'border-b border-border pb-2.5'
: 'pb-1',
)}
>
<DecoratedInput
type="text"
className={{ container: !isSearching ? 'py-1.5 px-0.5' : 'py-0', input: 'placeholder:text-passive-0' }}
placeholder="Search items to link..."
value={searchQuery}
onChange={setSearchQuery}
ref={searchInputRef}
right={[
isSearching && (
<ClearInputButton
onClick={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
/>
),
]}
/>
</form>
<div className="divide-y divide-border">
{isSearching ? (
<>
{(!!unlinkedResults.length || shouldShowCreateTag) && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Unlinked</div>
<LinkedItemSearchResults
createAndAddNewTag={createAndAddNewTag}
getLinkedItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
linkItemToSelectedItem={linkItemToSelectedItem}
results={unlinkedResults}
searchQuery={searchQuery}
shouldShowCreateTag={shouldShowCreateTag}
isEntitledToNoteLinking={isEntitledToNoteLinking}
onClickCallback={() => {
setSearchQuery('')
searchInputRef.current?.focus()
}}
/>
</div>
)}
{!!linkedResults.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked</div>
<div className="my-1">
{linkedResults.map((item) => (
<LinkedItemsSectionItem
key={item.uuid}
item={item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
</div>
)}
</>
) : (
<>
{!!tags.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Tags</div>
<div className="my-1">
{tags.map((item) => (
<LinkedItemsSectionItem
key={item.uuid}
item={item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
</div>
)}
{!!files.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
<div className="my-1">
{files.map((item) => (
<LinkedItemsSectionItem
key={item.uuid}
item={item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
</div>
)}
{!!notesLinkedToItem.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Notes</div>
<div className="my-1">
{notesLinkedToItem.map((item) => (
<LinkedItemsSectionItem
key={item.uuid}
item={item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
</div>
)}
{!!notesLinkingToItem.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
Notes Linking To This Note
</div>
<div className="my-1">
{notesLinkingToItem.map((item) => (
<LinkedItemsSectionItem
key={item.uuid}
item={item}
getItemIcon={getLinkedItemIcon}
getTitleForLinkedTag={getTitleForLinkedTag}
searchQuery={searchQuery}
unlinkItem={unlinkItemFromSelectedItem}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
export default observer(LinkedItemsPanel)

View File

@@ -1,14 +1,7 @@
import {
CSSProperties,
FunctionComponent,
KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
} from 'react'
import { CSSProperties, forwardRef, KeyboardEventHandler, ReactNode, Ref, useCallback, useEffect, useRef } from 'react'
import { KeyboardKey } from '@standardnotes/ui-services'
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
import { mergeRefs } from '@/Hooks/mergeRefs'
type MenuProps = {
className?: string
@@ -18,50 +11,61 @@ type MenuProps = {
closeMenu?: () => void
isOpen: boolean
initialFocus?: number
onKeyDown?: KeyboardEventHandler<HTMLMenuElement>
shouldAutoFocus?: boolean
}
const Menu: FunctionComponent<MenuProps> = ({
children,
className = '',
style,
a11yLabel,
closeMenu,
isOpen,
initialFocus,
}: MenuProps) => {
const menuElementRef = useRef<HTMLMenuElement>(null)
const Menu = forwardRef(
(
{
children,
className = '',
style,
a11yLabel,
closeMenu,
isOpen,
initialFocus,
onKeyDown,
shouldAutoFocus = true,
}: MenuProps,
forwardedRef: Ref<HTMLMenuElement>,
) => {
const menuElementRef = useRef<HTMLMenuElement>(null)
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => {
if (event.key === KeyboardKey.Escape) {
closeMenu?.()
return
const handleKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback(
(event) => {
onKeyDown?.(event)
if (event.key === KeyboardKey.Escape) {
closeMenu?.()
return
}
},
[closeMenu, onKeyDown],
)
useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => {
if (isOpen && shouldAutoFocus) {
setTimeout(() => {
menuElementRef.current?.focus()
})
}
},
[closeMenu],
)
}, [isOpen, shouldAutoFocus])
useListKeyboardNavigation(menuElementRef, initialFocus)
useEffect(() => {
if (isOpen) {
setTimeout(() => {
menuElementRef.current?.focus()
})
}
}, [isOpen])
return (
<menu
className={`m-0 list-none pl-0 focus:shadow-none ${className}`}
onKeyDown={handleKeyDown}
ref={menuElementRef}
style={style}
aria-label={a11yLabel}
>
{children}
</menu>
)
}
return (
<menu
className={`m-0 list-none pl-0 focus:shadow-none ${className}`}
onKeyDown={handleKeyDown}
ref={mergeRefs([menuElementRef, forwardedRef])}
style={style}
aria-label={a11yLabel}
>
{children}
</menu>
)
},
)
export default Menu

View File

@@ -12,8 +12,8 @@ import { FilesController } from '@/Controllers/FilesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController'
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
application: WebApplication
@@ -22,9 +22,9 @@ type Props = {
filesController: FilesController
navigationController: NavigationController
notesController: NotesController
noteTagsController: NoteTagsController
selectionController: SelectedItemsController
historyModalController: HistoryModalController
linkingController: LinkingController
}
const MultipleSelectedNotes = ({
@@ -34,7 +34,7 @@ const MultipleSelectedNotes = ({
filesController,
navigationController,
notesController,
noteTagsController,
linkingController,
selectionController,
historyModalController,
}: Props) => {
@@ -67,7 +67,7 @@ const MultipleSelectedNotes = ({
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
linkingController={linkingController}
historyModalController={historyModalController}
/>
</div>

View File

@@ -49,16 +49,11 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(width, _lastLeft, _isMaxWidth, isCollapsed) => {
application.setPreference(PrefKey.TagsPanelWidth, width).catch(console.error)
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, isCollapsed)
},
[application, viewControllerManager],
[application],
)
const panelWidthEventCallback = useCallback(() => {
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
}, [viewControllerManager])
return (
<div
id="navigation"
@@ -157,7 +152,6 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
side={PanelSide.Right}
type={PanelResizeType.WidthOnly}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
width={panelWidth}
left={0}
/>

View File

@@ -103,7 +103,7 @@ class NoteGroupView extends PureComponent<Props, State> {
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
noteTagsController={this.viewControllerManager.noteTagsController}
linkingController={this.viewControllerManager.linkingController}
historyModalController={this.viewControllerManager.historyModalController}
/>
)}

View File

@@ -1,156 +0,0 @@
import Icon from '@/Components/Icon/Icon'
import {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
type Props = {
noteTagsController: NoteTagsController
navigationController: NavigationController
tag: SNTag
}
const NoteTag = ({ noteTagsController, navigationController, tag }: Props) => {
const { toggleAppPane } = useResponsiveAppPane()
const noteTags = noteTagsController
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
const [showDeleteButton, setShowDeleteButton] = useState(false)
const [tagClicked, setTagClicked] = useState(false)
const deleteTagRef = useRef<HTMLAnchorElement>(null)
const tagRef = useRef<HTMLButtonElement>(null)
const title = tag.title
const prefixTitle = noteTags.getPrefixTitle(tag)
const longTitle = noteTags.getLongTitle(tag)
const deleteTag = useCallback(() => {
noteTagsController.focusPreviousTag(tag)
noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [noteTagsController, tag])
const onDeleteTagClick: MouseEventHandler = useCallback(
(event) => {
event.stopPropagation()
deleteTag()
},
[deleteTag],
)
const onTagClick: MouseEventHandler = useCallback(
async (event) => {
if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false)
await navigationController.setSelectedTag(tag)
toggleAppPane(AppPaneId.Items)
} else {
setTagClicked(true)
tagRef.current?.focus()
}
},
[tagClicked, navigationController, tag, toggleAppPane],
)
const onFocus = useCallback(() => {
noteTagsController.setFocusedTagUuid(tag.uuid)
setShowDeleteButton(true)
}, [noteTagsController, tag])
const onBlur: FocusEventHandler = useCallback(
(event) => {
const relatedTarget = event.relatedTarget as Node
if (relatedTarget !== deleteTagRef.current) {
noteTagsController.setFocusedTagUuid(undefined)
setShowDeleteButton(false)
}
},
[noteTagsController],
)
const getTabIndex = useCallback(() => {
if (focusedTagUuid) {
return focusedTagUuid === tag.uuid ? 0 : -1
}
if (autocompleteInputFocused) {
return -1
}
return tags[0]?.uuid === tag.uuid ? 0 : -1
}, [autocompleteInputFocused, tags, tag, focusedTagUuid])
const onKeyDown: KeyboardEventHandler = useCallback(
(event) => {
const tagIndex = noteTagsController.getTagIndex(tag, tags)
switch (event.key) {
case 'Backspace':
deleteTag()
break
case 'ArrowLeft':
noteTagsController.focusPreviousTag(tag)
break
case 'ArrowRight':
if (tagIndex === tags.length - 1) {
noteTagsController.setAutocompleteInputFocused(true)
} else {
noteTagsController.focusNextTag(tag)
}
break
default:
return
}
},
[noteTagsController, deleteTag, tag, tags],
)
useEffect(() => {
if (focusedTagUuid === tag.uuid) {
tagRef.current?.focus()
}
}, [noteTagsController, focusedTagUuid, tag])
return (
<button
ref={tagRef}
className="mt-2 mr-2 flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
onClick={onTagClick}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
tabIndex={getTabIndex()}
title={longTitle}
>
<Icon type="hashtag" className="mr-1 text-info" size="small" />
<span className="max-w-290px overflow-hidden overflow-ellipsis whitespace-nowrap">
{prefixTitle && <span className="text-passive-1">{prefixTitle}</span>}
{title}
</span>
{showDeleteButton && (
<a
ref={deleteTagRef}
role="button"
className="ml-2 -mr-1 flex cursor-pointer border-0 bg-transparent p-0"
onBlur={onBlur}
onClick={onDeleteTagClick}
tabIndex={-1}
>
<Icon type="close" className="text-neutral hover:text-info" size="small" />
</a>
)}
</button>
)
}
export default observer(NoteTag)

View File

@@ -1,35 +0,0 @@
import { observer } from 'mobx-react-lite'
import AutocompleteTagInput from '@/Components/TagAutocomplete/AutocompleteTagInput'
import NoteTag from './NoteTag'
import { useEffect } from 'react'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
type Props = {
noteTagsController: NoteTagsController
navigationController: NavigationController
}
const NoteTagsContainer = ({ noteTagsController, navigationController }: Props) => {
const { tags } = noteTagsController
useEffect(() => {
noteTagsController.reloadTagsContainerMaxWidth()
}, [noteTagsController])
return (
<div className="hidden min-w-80 max-w-full flex-wrap bg-transparent md:-mr-2 md:flex">
{tags.map((tag) => (
<NoteTag
key={tag.uuid}
noteTagsController={noteTagsController}
navigationController={navigationController}
tag={tag}
/>
))}
<AutocompleteTagInput noteTagsController={noteTagsController} />
</div>
)
}
export default observer(NoteTagsContainer)

View File

@@ -1,170 +0,0 @@
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { splitQueryInString } from '@/Utils'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FormEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
const ListItem = ({
tag,
isSearching,
noteTagsController,
autocompleteSearchQuery,
}: {
tag: SNTag
isSearching: boolean
noteTagsController: NoteTagsController
autocompleteSearchQuery: string
}) => {
const handleSearchResultClick = useCallback(async () => {
await noteTagsController.addTagToActiveNote(tag)
noteTagsController.clearAutocompleteSearch()
noteTagsController.setAutocompleteInputFocused(true)
}, [noteTagsController, tag])
const handleNoteTagRemove = useCallback(() => {
noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [noteTagsController, tag])
const longTitle = noteTagsController.getLongTitle(tag)
return isSearching ? (
<button
onClick={handleSearchResultClick}
className="max-w-80 flex w-full items-center border-0 bg-transparent px-3 py-2 text-left text-mobile-menu-item text-text hover:bg-info-backdrop focus:bg-info-backdrop md:text-menu-item"
>
{splitQueryInString(longTitle, autocompleteSearchQuery).map((substring, index) => (
<span
key={index}
className={
substring.toLowerCase() === autocompleteSearchQuery.toLowerCase()
? 'whitespace-pre-wrap font-bold'
: 'whitespace-pre-wrap'
}
>
{substring}
</span>
))}
</button>
) : (
<div className="max-w-80 flex w-full items-center justify-between border-0 bg-transparent px-3 py-2 text-left text-mobile-menu-item text-text md:text-menu-item">
<span className="overflow-hidden overflow-ellipsis whitespace-nowrap">{longTitle}</span>
<button
onClick={handleNoteTagRemove}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-danger hover:bg-info-backdrop focus:bg-info-backdrop"
>
<Icon type="trash" size="small" />
</button>
</div>
)
}
const NoteTagsPanel = ({
noteTagsController,
onClickPreprocessing,
}: {
noteTagsController: NoteTagsController
onClickPreprocessing?: () => Promise<void>
}) => {
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const { tags, autocompleteTagResults, autocompleteSearchQuery, autocompleteTagHintVisible } = noteTagsController
const isSearching = autocompleteSearchQuery.length > 0
const visibleTagsList = isSearching ? autocompleteTagResults : tags
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen])
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const query = event.target.value
if (query === '') {
noteTagsController.clearAutocompleteSearch()
} else {
noteTagsController.setAutocompleteSearchQuery(query)
noteTagsController.searchActiveNoteAutocompleteTags()
}
}
const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
if (autocompleteSearchQuery !== '') {
await noteTagsController.createAndAddNewTag()
}
}
useEffect(() => {
if (isDesktopScreen) {
setIsOpen(false)
}
}, [isDesktopScreen])
return (
<>
<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 md:hidden"
title="Note options menu"
aria-label="Note options menu"
onClick={toggleMenu}
ref={buttonRef}
>
<Icon type="hashtag" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="pb-2">
<form onSubmit={onFormSubmit} className="sticky top-0 border-b border-border bg-default px-2.5 py-2.5">
<input
type="text"
className="w-full rounded border border-solid border-border bg-default py-1.5 px-3 text-sm text-text"
placeholder="Create or search tag..."
value={autocompleteSearchQuery}
onChange={onSearchQueryChange}
ref={(node) => {
if (isOpen && node) {
node.focus()
}
}}
/>
</form>
<div className="pt-2.5">
{visibleTagsList.map((tag) => (
<ListItem
key={tag.uuid}
tag={tag}
isSearching={isSearching}
noteTagsController={noteTagsController}
autocompleteSearchQuery={autocompleteSearchQuery}
/>
))}
{autocompleteTagHintVisible && (
<button
onClick={async () => {
await noteTagsController.createAndAddNewTag()
}}
className="max-w-80 flex w-full items-center border-0 bg-transparent px-3 py-2 text-left text-mobile-menu-item text-text hover:bg-info-backdrop focus:bg-info-backdrop md:text-menu-item"
>
<span>Create new tag:</span>
<span className="ml-2 flex items-center rounded bg-contrast py-1 pl-1 pr-2 text-xs text-text">
<Icon type="hashtag" className="mr-1 text-neutral" size="small" />
<span className="max-w-40 overflow-hidden overflow-ellipsis whitespace-nowrap">
{autocompleteSearchQuery}
</span>
</span>
</button>
)}
</div>
</Popover>
</>
)
}
export default observer(NoteTagsPanel)

View File

@@ -41,10 +41,10 @@ import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import { classNames } from '@/Utils/ConcatenateClassNames'
import AutoresizingNoteViewTextarea from './AutoresizingTextarea'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import NoteTagsPanel from '../NoteTags/NoteTagsPanel'
import NoteTagsContainer from '../NoteTags/NoteTagsContainer'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
@@ -999,9 +999,10 @@ class NoteView extends PureComponent<NoteViewProps, State> {
</div>
{!this.state.shouldStickyHeader && (
<div className="flex items-center gap-3">
<NoteTagsPanel
<LinkedItemsButton
filesController={this.viewControllerManager.filesController}
linkingController={this.viewControllerManager.linkingController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
noteTagsController={this.viewControllerManager.noteTagsController}
/>
<AttachedFilesButton
application={this.application}
@@ -1026,7 +1027,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
application={this.application}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
noteTagsController={this.viewControllerManager.noteTagsController}
linkingController={this.viewControllerManager.linkingController}
historyModalController={this.viewControllerManager.historyModalController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
@@ -1034,10 +1035,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
)}
</div>
{!this.state.shouldStickyHeader && (
<NoteTagsContainer
noteTagsController={this.viewControllerManager.noteTagsController}
navigationController={this.viewControllerManager.navigationController}
/>
<LinkedItemBubblesContainer linkingController={this.viewControllerManager.linkingController} />
)}
</div>
)}

View File

@@ -4,15 +4,15 @@ 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'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
application: WebApplication
navigationController: NavigationController
notesController: NotesController
noteTagsController: NoteTagsController
linkingController: LinkingController
historyModalController: HistoryModalController
}
@@ -20,7 +20,7 @@ const NotesContextMenu = ({
application,
navigationController,
notesController,
noteTagsController,
linkingController,
historyModalController,
}: Props) => {
const { contextMenuOpen, contextMenuClickLocation, setContextMenuOpen } = notesController
@@ -46,7 +46,7 @@ const NotesContextMenu = ({
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
linkingController={linkingController}
historyModalController={historyModalController}
closeMenu={closeMenu}
/>

View File

@@ -3,17 +3,17 @@ import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { KeyboardKey } from '@standardnotes/ui-services'
import Popover from '../Popover/Popover'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
navigationController: NavigationController
notesController: NotesController
noteTagsController: NoteTagsController
linkingController: LinkingController
}
const AddTagOption: FunctionComponent<Props> = ({ navigationController, notesController, noteTagsController }) => {
const AddTagOption: FunctionComponent<Props> = ({ navigationController, notesController, linkingController }) => {
const menuContainerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
@@ -63,7 +63,7 @@ const AddTagOption: FunctionComponent<Props> = ({ navigationController, notesCon
className={`overflow-hidden overflow-ellipsis whitespace-nowrap
${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`}
>
{noteTagsController.getLongTitle(tag)}
{linkingController.getTitleForLinkedTag(tag)?.longTitle}
</span>
</button>
))}

View File

@@ -178,7 +178,7 @@ const NotesOptions = ({
application,
navigationController,
notesController,
noteTagsController,
linkingController,
historyModalController,
closeMenu,
}: NotesOptionsProps) => {
@@ -327,7 +327,7 @@ const NotesOptions = ({
<AddTagOption
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
linkingController={linkingController}
/>
)}
{unpinned && (

View File

@@ -5,15 +5,15 @@ import NotesOptions from './NotesOptions'
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'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = {
application: WebApplication
navigationController: NavigationController
notesController: NotesController
noteTagsController: NoteTagsController
linkingController: LinkingController
historyModalController: HistoryModalController
onClickPreprocessing?: () => Promise<void>
}
@@ -22,7 +22,7 @@ const NotesOptionsPanel = ({
application,
navigationController,
notesController,
noteTagsController,
linkingController,
historyModalController,
onClickPreprocessing,
}: Props) => {
@@ -53,7 +53,7 @@ const NotesOptionsPanel = ({
application={application}
navigationController={navigationController}
notesController={notesController}
noteTagsController={noteTagsController}
linkingController={linkingController}
historyModalController={historyModalController}
closeMenu={toggleMenu}
/>

View File

@@ -2,13 +2,13 @@ import { WebApplication } from '@/Application/Application'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NotesController } from '@/Controllers/NotesController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { LinkingController } from '@/Controllers/LinkingController'
export type NotesOptionsProps = {
application: WebApplication
navigationController: NavigationController
notesController: NotesController
noteTagsController: NoteTagsController
linkingController: LinkingController
historyModalController: HistoryModalController
closeMenu: () => void
}

View File

@@ -65,7 +65,9 @@ const PositionedPopoverContent = ({
)}
style={{
...styles,
maxHeight: getPopoverMaxHeight(getAppRect(documentRect), anchorRect, positionedSide, positionedAlignment),
maxHeight: styles
? getPopoverMaxHeight(getAppRect(documentRect), anchorRect, positionedSide, positionedAlignment)
: '',
top: !isDesktopScreen ? `${document.documentElement.scrollTop}px` : '',
}}
ref={(node) => {

View File

@@ -35,10 +35,10 @@ export const usePopoverCloseOnClickOutside = ({
}
document.addEventListener('click', closeIfClickedOutside, { capture: true })
document.addEventListener('contextmenu', closeIfClickedOutside, { capture: true })
return () => {
document.removeEventListener('click', closeIfClickedOutside, {
capture: true,
})
document.removeEventListener('click', closeIfClickedOutside, { capture: true })
document.removeEventListener('contextmenu', closeIfClickedOutside, { capture: true })
}
}, [anchorElement, childPopovers, popoverElement, togglePopover])
}

View File

@@ -6,6 +6,7 @@ import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import { observer } from 'mobx-react-lite'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
type Props = {
itemListController: ItemListController
@@ -59,16 +60,7 @@ const SearchBar = ({ itemListController, searchOptionsController }: Props) => {
onFocus={onSearchFocus}
onKeyUp={onNoteFilterKeyUp}
left={[<Icon type="search" className="mr-1 h-4.5 w-4.5 flex-shrink-0 text-passive-1" />]}
right={[
noteFilterText && (
<button
onClick={onClearSearch}
className="flex h-4.5 w-4.5 items-center justify-center rounded-full border-0 bg-neutral text-neutral-contrast"
>
<Icon type="close" className="h-3.5 w-3.5" />
</button>
),
]}
right={[noteFilterText && <ClearInputButton onClick={onClearSearch} />]}
roundedFull
/>

View File

@@ -9,5 +9,6 @@ export default styled(Tooltip)`
background-color: var(--sn-stylekit-contrast-background-color);
color: var(--sn-stylekit-foreground-color);
border-color: var(--sn-stylekit-border-color);
z-index: var(--z-index-tooltip);
}
`

View File

@@ -1,81 +0,0 @@
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { observer } from 'mobx-react-lite'
import { useRef, useEffect, useCallback, FocusEventHandler, KeyboardEventHandler } from 'react'
import Icon from '@/Components/Icon/Icon'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
type Props = {
noteTagsController: NoteTagsController
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
const AutocompleteTagHint = ({ noteTagsController, closeOnBlur }: Props) => {
const { autocompleteTagHintFocused } = noteTagsController
const hintRef = useRef<HTMLButtonElement>(null)
const { autocompleteSearchQuery, autocompleteTagResults } = noteTagsController
const onTagHintClick = useCallback(async () => {
await noteTagsController.createAndAddNewTag()
noteTagsController.setAutocompleteInputFocused(true)
}, [noteTagsController])
const onFocus = useCallback(() => {
noteTagsController.setAutocompleteTagHintFocused(true)
}, [noteTagsController])
const onBlur: FocusEventHandler = useCallback(
(event) => {
closeOnBlur(event)
noteTagsController.setAutocompleteTagHintFocused(false)
},
[noteTagsController, closeOnBlur],
)
const onKeyDown: KeyboardEventHandler = useCallback(
(event) => {
if (event.key === 'ArrowUp') {
if (autocompleteTagResults.length > 0) {
const lastTagResult = autocompleteTagResults[autocompleteTagResults.length - 1]
noteTagsController.setFocusedTagResultUuid(lastTagResult.uuid)
} else {
noteTagsController.setAutocompleteInputFocused(true)
}
}
},
[noteTagsController, autocompleteTagResults],
)
useEffect(() => {
if (autocompleteTagHintFocused) {
hintRef.current?.focus()
}
}, [noteTagsController, autocompleteTagHintFocused])
return (
<>
{autocompleteTagResults.length > 0 && <HorizontalSeparator classes="my-2" />}
<button
ref={hintRef}
type="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 focus:text-info-contrast focus:shadow-none"
onClick={onTagHintClick}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
tabIndex={-1}
>
<span>Create new tag:</span>
<span className="ml-2 flex items-center rounded bg-contrast py-1 pl-1 pr-2 text-xs text-text">
<Icon type="hashtag" className="mr-1 text-neutral" size="small" />
<span className="max-w-40 overflow-hidden overflow-ellipsis whitespace-nowrap">
{autocompleteSearchQuery}
</span>
</span>
</button>
</>
)
}
export default observer(AutocompleteTagHint)

View File

@@ -1,160 +0,0 @@
import {
ChangeEventHandler,
FocusEventHandler,
FormEventHandler,
KeyboardEventHandler,
useEffect,
useRef,
useState,
} from 'react'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import AutocompleteTagResult from './AutocompleteTagResult'
import AutocompleteTagHint from './AutocompleteTagHint'
import { observer } from 'mobx-react-lite'
import { SNTag } from '@standardnotes/snjs'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
type Props = {
noteTagsController: NoteTagsController
}
const AutocompleteTagInput = ({ noteTagsController }: Props) => {
const {
autocompleteInputFocused,
autocompleteSearchQuery,
autocompleteTagHintVisible,
autocompleteTagResults,
tags,
tagsContainerMaxWidth,
} = noteTagsController
const [dropdownVisible, setDropdownVisible] = useState(false)
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
setDropdownVisible(visible)
noteTagsController.clearAutocompleteSearch()
})
const showDropdown = () => {
const { clientHeight } = document.documentElement
const inputRect = inputRef.current?.getBoundingClientRect()
if (inputRect) {
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
setDropdownVisible(true)
}
}
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const query = event.target.value
if (query === '') {
noteTagsController.clearAutocompleteSearch()
} else {
noteTagsController.setAutocompleteSearchQuery(query)
noteTagsController.searchActiveNoteAutocompleteTags()
}
}
const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
if (autocompleteSearchQuery !== '') {
await noteTagsController.createAndAddNewTag()
}
}
const onKeyDown: KeyboardEventHandler = (event) => {
switch (event.key) {
case 'Backspace':
case 'ArrowLeft':
if (autocompleteSearchQuery === '' && tags.length > 0) {
noteTagsController.setFocusedTagUuid(tags[tags.length - 1].uuid)
}
break
case 'ArrowDown':
event.preventDefault()
if (autocompleteTagResults.length > 0) {
noteTagsController.setFocusedTagResultUuid(autocompleteTagResults[0].uuid)
} else if (autocompleteTagHintVisible) {
noteTagsController.setAutocompleteTagHintFocused(true)
}
break
default:
return
}
}
const onFocus = () => {
showDropdown()
noteTagsController.setAutocompleteInputFocused(true)
}
const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
noteTagsController.setAutocompleteInputFocused(false)
}
useEffect(() => {
if (autocompleteInputFocused) {
inputRef.current?.focus()
}
}, [autocompleteInputFocused])
return (
<div ref={containerRef}>
<form onSubmit={onFormSubmit} className={`${tags.length > 0 ? 'mt-2' : ''}`}>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
className={`${tags.length > 0 ? 'w-80' : 'mr-10 w-70'} no-border h-7
bg-transparent text-xs text-text focus:border-b-2 focus:border-solid focus:border-info focus:shadow-none focus:outline-none`}
value={autocompleteSearchQuery}
onChange={onSearchQueryChange}
type="text"
placeholder="Add tag"
onBlur={onBlur}
onFocus={onFocus}
onKeyDown={onKeyDown}
tabIndex={tags.length === 0 ? 0 : -1}
/>
{dropdownVisible && (autocompleteTagResults.length > 0 || autocompleteTagHintVisible) && (
<DisclosurePanel
className={classNames(
tags.length > 0 ? 'w-80' : 'mr-10 w-70',
'absolute z-dropdown-menu flex flex-col rounded bg-default py-2 shadow-main',
)}
style={{
maxHeight: dropdownMaxHeight,
maxWidth: tagsContainerMaxWidth,
}}
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<div className="md:overflow-y-auto">
{autocompleteTagResults.map((tagResult: SNTag) => (
<AutocompleteTagResult
key={tagResult.uuid}
noteTagsController={noteTagsController}
tagResult={tagResult}
closeOnBlur={closeOnBlur}
/>
))}
</div>
{autocompleteTagHintVisible && (
<AutocompleteTagHint noteTagsController={noteTagsController} closeOnBlur={closeOnBlur} />
)}
</DisclosurePanel>
)}
</Disclosure>
</form>
</div>
)
}
export default observer(AutocompleteTagInput)

View File

@@ -1,102 +0,0 @@
import { splitQueryInString } from '@/Utils/StringUtils'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FocusEventHandler, KeyboardEventHandler, useEffect, useRef } from 'react'
import Icon from '@/Components/Icon/Icon'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
type Props = {
noteTagsController: NoteTagsController
tagResult: SNTag
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
const AutocompleteTagResult = ({ noteTagsController, tagResult, closeOnBlur }: Props) => {
const { autocompleteSearchQuery, autocompleteTagHintVisible, autocompleteTagResults, focusedTagResultUuid } =
noteTagsController
const tagResultRef = useRef<HTMLButtonElement>(null)
const title = tagResult.title
const prefixTitle = noteTagsController.getPrefixTitle(tagResult)
const onTagOptionClick = async (tag: SNTag) => {
await noteTagsController.addTagToActiveNote(tag)
noteTagsController.clearAutocompleteSearch()
noteTagsController.setAutocompleteInputFocused(true)
}
const onKeyDown: KeyboardEventHandler = (event) => {
const tagResultIndex = noteTagsController.getTagIndex(tagResult, autocompleteTagResults)
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
if (tagResultIndex === 0) {
noteTagsController.setAutocompleteInputFocused(true)
} else {
noteTagsController.focusPreviousTagResult(tagResult)
}
break
case 'ArrowDown':
event.preventDefault()
if (tagResultIndex === autocompleteTagResults.length - 1 && autocompleteTagHintVisible) {
noteTagsController.setAutocompleteTagHintFocused(true)
} else {
noteTagsController.focusNextTagResult(tagResult)
}
break
default:
return
}
}
const onFocus = () => {
noteTagsController.setFocusedTagResultUuid(tagResult.uuid)
}
const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
noteTagsController.setFocusedTagResultUuid(undefined)
}
useEffect(() => {
if (focusedTagResultUuid === tagResult.uuid) {
tagResultRef.current?.focus()
noteTagsController.setFocusedTagResultUuid(undefined)
}
}, [noteTagsController, focusedTagResultUuid, tagResult])
return (
<button
ref={tagResultRef}
type="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 focus:text-info-contrast focus:shadow-none"
onClick={() => onTagOptionClick(tagResult)}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
tabIndex={-1}
>
<Icon type="hashtag" className="min-h-5 mr-2 min-w-5 text-neutral" />
<span className="overflow-hidden overflow-ellipsis whitespace-nowrap">
{prefixTitle && <span className="text-passive-2">{prefixTitle}</span>}
{autocompleteSearchQuery === ''
? title
: splitQueryInString(title, autocompleteSearchQuery).map((substring, index) => (
<span
key={index}
className={`${
substring.toLowerCase() === autocompleteSearchQuery.toLowerCase()
? 'whitespace-pre-wrap font-bold'
: 'whitespace-pre-wrap '
}`}
>
{substring}
</span>
))}
</span>
</button>
)
}
export default observer(AutocompleteTagResult)