feat: item linking (#1779)
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 & 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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user