refactor: item linking (#1781)
This commit is contained in:
@@ -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.getSortedFilesForItem(item).length > 0
|
||||
const hasFiles = application.items.getSortedFilesLinkingToItem(item).length > 0
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
notesController.setContextMenuOpen(false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as icons from '@standardnotes/icons'
|
||||
export const ICONS = {
|
||||
'account-circle': icons.AccountCircleIcon,
|
||||
'arrow-left': icons.ArrowLeftIcon,
|
||||
'arrow-right': icons.ArrowRightIcon,
|
||||
'arrows-sort-down': icons.ArrowsSortDownIcon,
|
||||
'arrows-sort-up': icons.ArrowsSortUpIcon,
|
||||
'attachment-file': icons.AttachmentFileIcon,
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { ItemLink, 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 { ContentType } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
type Props = {
|
||||
item: LinkableItem
|
||||
link: ItemLink
|
||||
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
activateItem: (item: LinkableItem) => Promise<void>
|
||||
unlinkItem: (item: LinkableItem) => void
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
focusPreviousItem: () => void
|
||||
focusNextItem: () => void
|
||||
focusedId: string | undefined
|
||||
setFocusedId: (id: string) => void
|
||||
isBidirectional: boolean
|
||||
}
|
||||
|
||||
const LinkedItemBubble = ({
|
||||
item,
|
||||
link,
|
||||
getItemIcon,
|
||||
getTitleForLinkedTag,
|
||||
activateItem,
|
||||
@@ -27,6 +29,7 @@ const LinkedItemBubble = ({
|
||||
focusNextItem,
|
||||
focusedId,
|
||||
setFocusedId,
|
||||
isBidirectional,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -36,8 +39,8 @@ const LinkedItemBubble = ({
|
||||
const [wasClicked, setWasClicked] = useState(false)
|
||||
|
||||
const handleFocus = () => {
|
||||
if (focusedId !== item.uuid) {
|
||||
setFocusedId(item.uuid)
|
||||
if (focusedId !== link.id) {
|
||||
setFocusedId(link.id)
|
||||
}
|
||||
setShowUnlinkButton(true)
|
||||
}
|
||||
@@ -50,7 +53,7 @@ const LinkedItemBubble = ({
|
||||
const onClick: MouseEventHandler = (event) => {
|
||||
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
||||
setWasClicked(false)
|
||||
void activateItem(item)
|
||||
void activateItem(link.item)
|
||||
} else {
|
||||
setWasClicked(true)
|
||||
}
|
||||
@@ -58,14 +61,14 @@ const LinkedItemBubble = ({
|
||||
|
||||
const onUnlinkClick: MouseEventHandler = (event) => {
|
||||
event.stopPropagation()
|
||||
unlinkItem(item)
|
||||
unlinkItem(link)
|
||||
}
|
||||
|
||||
const onKeyDown: KeyboardEventHandler = (event) => {
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Backspace: {
|
||||
focusPreviousItem()
|
||||
unlinkItem(item)
|
||||
unlinkItem(link)
|
||||
break
|
||||
}
|
||||
case KeyboardKey.Left:
|
||||
@@ -77,29 +80,34 @@ const LinkedItemBubble = ({
|
||||
}
|
||||
}
|
||||
|
||||
const [icon, iconClassName] = getItemIcon(item)
|
||||
const tagTitle = getTitleForLinkedTag(item)
|
||||
const [icon, iconClassName] = getItemIcon(link.item)
|
||||
const tagTitle = getTitleForLinkedTag(link.item)
|
||||
|
||||
useEffect(() => {
|
||||
if (item.uuid === focusedId) {
|
||||
if (link.id === focusedId) {
|
||||
ref.current?.focus()
|
||||
}
|
||||
}, [focusedId, item.uuid])
|
||||
}, [focusedId, link.id])
|
||||
|
||||
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"
|
||||
className="group 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}
|
||||
title={tagTitle ? tagTitle.longTitle : link.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">
|
||||
<span className="max-w-290px flex items-center overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
|
||||
{item.title}
|
||||
<span className="flex items-center gap-1">
|
||||
{link.relationWithSelectedItem === 'indirect' && link.item.content_type !== ContentType.Tag && (
|
||||
<span className={!isBidirectional ? 'hidden group-focus:block' : ''}>Linked By:</span>
|
||||
)}
|
||||
{link.item.title}
|
||||
</span>
|
||||
</span>
|
||||
{showUnlinkButton && (
|
||||
<a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
|
||||
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import { ItemLink, LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||
import LinkedItemBubble from './LinkedItemBubble'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||
@@ -14,8 +14,9 @@ type Props = {
|
||||
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
const { toggleAppPane } = useResponsiveAppPane()
|
||||
const {
|
||||
allLinkedItems,
|
||||
notesLinkingToItem,
|
||||
allItemLinks,
|
||||
notesLinkingToActiveItem,
|
||||
filesLinkingToActiveItem,
|
||||
unlinkItemFromSelectedItem: unlinkItem,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon: getItemIcon,
|
||||
@@ -23,7 +24,13 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
} = linkingController
|
||||
|
||||
const [focusedId, setFocusedId] = useState<string>()
|
||||
const focusableIds = allLinkedItems.map((item) => item.uuid).concat([ElementIds.ItemLinkAutocompleteInput])
|
||||
const focusableIds = allItemLinks
|
||||
.map((link) => link.id)
|
||||
.concat(
|
||||
notesLinkingToActiveItem.map((link) => link.id),
|
||||
filesLinkingToActiveItem.map((link) => link.id),
|
||||
[ElementIds.ItemLinkAutocompleteInput],
|
||||
)
|
||||
|
||||
const focusPreviousItem = useCallback(() => {
|
||||
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
|
||||
@@ -53,27 +60,38 @@ const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||
[activateItem, toggleAppPane],
|
||||
)
|
||||
|
||||
const isItemBidirectionallyLinked = (link: ItemLink) => {
|
||||
const existsInAllItemLinks = !!allItemLinks.find((item) => link.item.uuid === item.item.uuid)
|
||||
const existsInNotesLinkingToItem = !!notesLinkingToActiveItem.find((item) => link.item.uuid === item.item.uuid)
|
||||
|
||||
return existsInAllItemLinks && existsInNotesLinkingToItem
|
||||
}
|
||||
|
||||
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',
|
||||
allItemLinks.length || notesLinkingToActiveItem.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}
|
||||
/>
|
||||
))}
|
||||
{allItemLinks
|
||||
.concat(notesLinkingToActiveItem)
|
||||
.concat(filesLinkingToActiveItem)
|
||||
.map((link) => (
|
||||
<LinkedItemBubble
|
||||
link={link}
|
||||
key={link.id}
|
||||
getItemIcon={getItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
activateItem={activateItemAndTogglePane}
|
||||
unlinkItem={unlinkItem}
|
||||
focusPreviousItem={focusPreviousItem}
|
||||
focusNextItem={focusNextItem}
|
||||
focusedId={focusedId}
|
||||
setFocusedId={setFocusedId}
|
||||
isBidirectional={isItemBidirectionallyLinked(link)}
|
||||
/>
|
||||
))}
|
||||
<ItemLinkAutocompleteInput
|
||||
focusedId={focusedId}
|
||||
linkingController={linkingController}
|
||||
|
||||
@@ -33,7 +33,7 @@ const LinkedItemsSectionItem = ({
|
||||
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||
item: LinkableItem
|
||||
searchQuery?: string
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
unlinkItem: () => void
|
||||
handleFileAction: FilesController['handleFileAction']
|
||||
}) => {
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||
@@ -85,7 +85,7 @@ const LinkedItemsSectionItem = ({
|
||||
</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"
|
||||
className="flex max-w-full 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()
|
||||
@@ -118,7 +118,7 @@ const LinkedItemsSectionItem = ({
|
||||
<MenuItem
|
||||
type={MenuItemType.IconButton}
|
||||
onClick={() => {
|
||||
unlinkItem(item)
|
||||
unlinkItem()
|
||||
toggleMenu()
|
||||
}}
|
||||
>
|
||||
@@ -166,10 +166,11 @@ const LinkedItemsPanel = ({
|
||||
}) => {
|
||||
const {
|
||||
tags,
|
||||
files,
|
||||
linkedFiles,
|
||||
filesLinkingToActiveItem,
|
||||
notesLinkedToItem,
|
||||
notesLinkingToItem,
|
||||
allLinkedItems,
|
||||
notesLinkingToActiveItem,
|
||||
allItemLinks: allLinkedItems,
|
||||
getTitleForLinkedTag,
|
||||
getLinkedItemIcon,
|
||||
getSearchResults,
|
||||
@@ -196,7 +197,7 @@ const LinkedItemsPanel = ({
|
||||
<form
|
||||
className={classNames(
|
||||
'sticky top-0 z-10 bg-default px-2.5 pt-2.5',
|
||||
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToItem.length
|
||||
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToActiveItem.length
|
||||
? 'border-b border-border pb-2.5'
|
||||
: 'pb-1',
|
||||
)}
|
||||
@@ -246,14 +247,14 @@ const LinkedItemsPanel = ({
|
||||
<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) => (
|
||||
{linkedResults.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -268,14 +269,14 @@ const LinkedItemsPanel = ({
|
||||
<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) => (
|
||||
{tags.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -283,18 +284,39 @@ const LinkedItemsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!files.length && (
|
||||
{!!linkedFiles.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) => (
|
||||
{linkedFiles.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!filesLinkingToActiveItem.length && (
|
||||
<div>
|
||||
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
|
||||
Files Linking To Current File
|
||||
</div>
|
||||
<div className="my-1">
|
||||
{filesLinkingToActiveItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -306,14 +328,14 @@ const LinkedItemsPanel = ({
|
||||
<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) => (
|
||||
{notesLinkedToItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -321,20 +343,20 @@ const LinkedItemsPanel = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!notesLinkingToItem.length && (
|
||||
{!!notesLinkingToActiveItem.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) => (
|
||||
{notesLinkingToActiveItem.map((link) => (
|
||||
<LinkedItemsSectionItem
|
||||
key={item.uuid}
|
||||
item={item}
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
getItemIcon={getLinkedItemIcon}
|
||||
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={unlinkItemFromSelectedItem}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user