From e3f28421ff042c635ad2ae645c102c27e3e3f9c7 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Tue, 11 Oct 2022 23:54:00 +0530 Subject: [PATCH] feat: item linking (#1779) --- packages/mobile/src/Hooks/useFiles.ts | 2 +- .../src/Screens/SideMenu/NoteSideMenu.tsx | 4 +- .../Abstract/Reference/AnonymousReference.ts | 4 +- .../Reference/ContenteReferenceType.ts | 6 +- .../Abstract/Reference/FileToFileReference.ts | 8 + .../Abstract/Reference/FileToNoteReference.ts | 4 +- .../Domain/Abstract/Reference/Functions.ts | 4 +- .../Abstract/Reference/NoteToNoteReference.ts | 8 + .../Abstract/Reference/TagToFileReference.ts | 4 +- .../Reference/TagToParentTagReference.ts | 4 +- ...tesIndex.spec.ts => TagItemsIndex.spec.ts} | 27 +- .../{TagNotesIndex.ts => TagItemsIndex.ts} | 66 ++-- .../src/Domain/Syncable/File/FileMutator.ts | 25 +- .../src/Domain/Syncable/Note/NoteMutator.ts | 22 ++ .../Domain/Syncable/Tag/TagMutator.spec.ts | 4 +- .../src/Domain/Syncable/Tag/TagMutator.ts | 6 +- packages/models/src/Domain/index.ts | 2 +- .../src/Domain/Item/ItemsClientInterface.ts | 35 +- .../lib/Services/Items/ItemManager.spec.ts | 303 +++++++++++---- .../snjs/lib/Services/Items/ItemManager.ts | 130 +++++-- packages/snjs/mocha/item_manager.test.js | 2 +- .../model_tests/notes_tags_folders.test.js | 4 +- .../ApplicationView/ApplicationView.tsx | 3 +- .../AttachedFilesButton.tsx | 2 +- .../AttachedFilesPopover.tsx | 43 +-- .../ClearInputButton/ClearInputButton.tsx | 15 + .../ContentListView/ContentListView.tsx | 11 +- .../ContentListView/NoteListItem.tsx | 2 +- .../FileView/FileViewWithoutProtection.tsx | 7 + .../LinkedItems/ItemLinkAutocompleteInput.tsx | 172 +++++++++ .../LinkedItems/LinkedFileMenuOptions.tsx | 98 +++++ .../LinkedItems/LinkedItemBubble.tsx | 118 ++++++ .../LinkedItemBubblesContainer.tsx | 87 +++++ .../Components/LinkedItems/LinkedItemMeta.tsx | 46 +++ .../LinkedItems/LinkedItemSearchResults.tsx | 80 ++++ .../LinkedItems/LinkedItemsButton.tsx | 51 +++ .../LinkedItems/LinkedItemsPanel.tsx | 352 ++++++++++++++++++ .../src/javascripts/Components/Menu/Menu.tsx | 102 ++--- .../MultipleSelectedNotes.tsx | 8 +- .../Components/Navigation/Navigation.tsx | 8 +- .../NoteGroupView/NoteGroupView.tsx | 2 +- .../Components/NoteTags/NoteTag.tsx | 156 -------- .../Components/NoteTags/NoteTagsContainer.tsx | 35 -- .../Components/NoteTags/NoteTagsPanel.tsx | 170 --------- .../Components/NoteView/NoteView.tsx | 16 +- .../NotesContextMenu/NotesContextMenu.tsx | 8 +- .../Components/NotesOptions/AddTagOption.tsx | 8 +- .../Components/NotesOptions/NotesOptions.tsx | 4 +- .../NotesOptions/NotesOptionsPanel.tsx | 8 +- .../NotesOptions/NotesOptionsProps.ts | 4 +- .../Popover/PositionedPopoverContent.tsx | 4 +- .../Utils/usePopoverCloseOnClickOutside.ts | 6 +- .../Components/SearchBar/SearchBar.tsx | 12 +- .../StyledTooltip/StyledTooltip.tsx | 1 + .../TagAutocomplete/AutocompleteTagHint.tsx | 81 ---- .../TagAutocomplete/AutocompleteTagInput.tsx | 160 -------- .../TagAutocomplete/AutocompleteTagResult.tsx | 102 ----- .../src/javascripts/Constants/ElementIDs.ts | 1 + .../Controllers/FilesController.ts | 2 +- .../ItemList/ItemListController.ts | 18 +- .../Controllers/LinkingController.tsx | 339 +++++++++++++++++ .../Navigation/NavigationController.ts | 8 + .../Controllers/NoteTagsController.ts | 244 ------------ .../Controllers/NotesController.ts | 3 - .../Controllers/SelectedItemsController.ts | 5 + .../Controllers/ViewControllerManager.ts | 27 +- .../web/src/javascripts/Hooks/mergeRefs.ts | 37 ++ packages/web/src/stylesheets/_main.scss | 1 + 68 files changed, 2064 insertions(+), 1277 deletions(-) create mode 100644 packages/models/src/Domain/Abstract/Reference/FileToFileReference.ts create mode 100644 packages/models/src/Domain/Abstract/Reference/NoteToNoteReference.ts rename packages/models/src/Domain/Runtime/Collection/Item/{TagNotesIndex.spec.ts => TagItemsIndex.spec.ts} (67%) rename packages/models/src/Domain/Runtime/Collection/Item/{TagNotesIndex.ts => TagItemsIndex.ts} (53%) create mode 100644 packages/web/src/javascripts/Components/ClearInputButton/ClearInputButton.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemMeta.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx create mode 100644 packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx delete mode 100644 packages/web/src/javascripts/Components/NoteTags/NoteTag.tsx delete mode 100644 packages/web/src/javascripts/Components/NoteTags/NoteTagsContainer.tsx delete mode 100644 packages/web/src/javascripts/Components/NoteTags/NoteTagsPanel.tsx delete mode 100644 packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagHint.tsx delete mode 100644 packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagInput.tsx delete mode 100644 packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagResult.tsx create mode 100644 packages/web/src/javascripts/Controllers/LinkingController.tsx delete mode 100644 packages/web/src/javascripts/Controllers/NoteTagsController.ts create mode 100644 packages/web/src/javascripts/Hooks/mergeRefs.ts diff --git a/packages/mobile/src/Hooks/useFiles.ts b/packages/mobile/src/Hooks/useFiles.ts index 208ce74c2..f57416833 100644 --- a/packages/mobile/src/Hooks/useFiles.ts +++ b/packages/mobile/src/Hooks/useFiles.ts @@ -68,7 +68,7 @@ export const useFiles = ({ note }: Props) => { const filesService = application.getFilesService() const reloadAttachedFiles = useCallback(() => { - setAttachedFiles(application.items.getFilesForNote(note).sort(filesService.sortByName)) + setAttachedFiles(application.items.getSortedFilesForItem(note).sort(filesService.sortByName)) }, [application.items, filesService.sortByName, note]) const reloadAllFiles = useCallback(() => { diff --git a/packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx b/packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx index 20715962b..55224c3e3 100644 --- a/packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx +++ b/packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx @@ -139,7 +139,7 @@ export const NoteSideMenu = React.memo((props: Props) => { setAttachedFilesLength(0) return } - setAttachedFilesLength(application.items.getFilesForNote(note).length) + setAttachedFilesLength(application.items.getSortedFilesForItem(note).length) }, [application, note]) useEffect(() => { @@ -147,7 +147,7 @@ export const NoteSideMenu = React.memo((props: Props) => { return } const removeFilesObserver = application.streamItems(ContentType.File, () => { - setAttachedFilesLength(application.items.getFilesForNote(note).length) + setAttachedFilesLength(application.items.getSortedFilesForItem(note).length) }) return () => { removeFilesObserver() diff --git a/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts b/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts index 962f4f52f..253929d40 100644 --- a/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts +++ b/packages/models/src/Domain/Abstract/Reference/AnonymousReference.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' -import { ContenteReferenceType } from './ContenteReferenceType' +import { ContentReferenceType } from './ContenteReferenceType' export interface AnonymousReference { uuid: string content_type: ContentType - reference_type: ContenteReferenceType + reference_type: ContentReferenceType } diff --git a/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts b/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts index 801a777bc..9913e3855 100644 --- a/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts +++ b/packages/models/src/Domain/Abstract/Reference/ContenteReferenceType.ts @@ -1,5 +1,7 @@ -export enum ContenteReferenceType { +export enum ContentReferenceType { TagToParentTag = 'TagToParentTag', - FileToNote = 'FileToNote', TagToFile = 'TagToFile', + FileToNote = 'FileToNote', + FileToFile = 'FileToFile', + NoteToNote = 'NoteToNote', } diff --git a/packages/models/src/Domain/Abstract/Reference/FileToFileReference.ts b/packages/models/src/Domain/Abstract/Reference/FileToFileReference.ts new file mode 100644 index 000000000..f6f7daf12 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/FileToFileReference.ts @@ -0,0 +1,8 @@ +import { ContentType } from '@standardnotes/common' +import { AnonymousReference } from './AnonymousReference' +import { ContentReferenceType } from './ContenteReferenceType' + +export interface FileToFileReference extends AnonymousReference { + content_type: ContentType.File + reference_type: ContentReferenceType.FileToFile +} diff --git a/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts b/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts index 34385f55f..5ae3e2ec1 100644 --- a/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts +++ b/packages/models/src/Domain/Abstract/Reference/FileToNoteReference.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' import { AnonymousReference } from './AnonymousReference' -import { ContenteReferenceType } from './ContenteReferenceType' +import { ContentReferenceType } from './ContenteReferenceType' export interface FileToNoteReference extends AnonymousReference { content_type: ContentType.Note - reference_type: ContenteReferenceType.FileToNote + reference_type: ContentReferenceType.FileToNote } diff --git a/packages/models/src/Domain/Abstract/Reference/Functions.ts b/packages/models/src/Domain/Abstract/Reference/Functions.ts index d5d8edd72..18c0a5434 100644 --- a/packages/models/src/Domain/Abstract/Reference/Functions.ts +++ b/packages/models/src/Domain/Abstract/Reference/Functions.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ContentType } from '@standardnotes/common' import { ItemInterface } from '../Item/Interfaces/ItemInterface' -import { ContenteReferenceType } from './ContenteReferenceType' +import { ContentReferenceType } from './ContenteReferenceType' import { ContentReference } from './ContentReference' import { LegacyAnonymousReference } from './LegacyAnonymousReference' import { LegacyTagToNoteReference } from './LegacyTagToNoteReference' @@ -26,5 +26,5 @@ export const isLegacyTagToNoteReference = ( } export const isTagToParentTagReference = (x: ContentReference): x is TagToParentTagReference => { - return isReference(x) && x.reference_type === ContenteReferenceType.TagToParentTag + return isReference(x) && x.reference_type === ContentReferenceType.TagToParentTag } diff --git a/packages/models/src/Domain/Abstract/Reference/NoteToNoteReference.ts b/packages/models/src/Domain/Abstract/Reference/NoteToNoteReference.ts new file mode 100644 index 000000000..5370304d9 --- /dev/null +++ b/packages/models/src/Domain/Abstract/Reference/NoteToNoteReference.ts @@ -0,0 +1,8 @@ +import { ContentType } from '@standardnotes/common' +import { AnonymousReference } from './AnonymousReference' +import { ContentReferenceType } from './ContenteReferenceType' + +export interface NoteToNoteReference extends AnonymousReference { + content_type: ContentType.Note + reference_type: ContentReferenceType.NoteToNote +} diff --git a/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts b/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts index 9662f4c6a..b9b34e073 100644 --- a/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts +++ b/packages/models/src/Domain/Abstract/Reference/TagToFileReference.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' import { AnonymousReference } from './AnonymousReference' -import { ContenteReferenceType } from './ContenteReferenceType' +import { ContentReferenceType } from './ContenteReferenceType' export interface TagToFileReference extends AnonymousReference { content_type: ContentType.File - reference_type: ContenteReferenceType.TagToFile + reference_type: ContentReferenceType.TagToFile } diff --git a/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts b/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts index 96be8f144..cdf74ec2c 100644 --- a/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts +++ b/packages/models/src/Domain/Abstract/Reference/TagToParentTagReference.ts @@ -1,8 +1,8 @@ import { ContentType } from '@standardnotes/common' import { AnonymousReference } from './AnonymousReference' -import { ContenteReferenceType } from './ContenteReferenceType' +import { ContentReferenceType } from './ContenteReferenceType' export interface TagToParentTagReference extends AnonymousReference { content_type: ContentType.Tag - reference_type: ContenteReferenceType.TagToParentTag + reference_type: ContentReferenceType.TagToParentTag } diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts similarity index 67% rename from packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts rename to packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts index 643b96135..0ffbfb733 100644 --- a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.spec.ts +++ b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.spec.ts @@ -1,10 +1,10 @@ -import { NoteContent } from './../../../Syncable/Note/NoteContent' +import { NoteContent } from '../../../Syncable/Note/NoteContent' import { ContentType } from '@standardnotes/common' import { DecryptedItem, EncryptedItem } from '../../../Abstract/Item' import { DecryptedPayload, EncryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload' import { ItemCollection } from './ItemCollection' import { FillItemContent } from '../../../Abstract/Content/ItemContent' -import { TagNotesIndex } from './TagNotesIndex' +import { TagItemsIndex } from './TagItemsIndex' import { ItemDelta } from '../../Index/ItemDelta' import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes' @@ -24,10 +24,10 @@ describe('tag notes index', () => { return new EncryptedItem(payload) } - const createDecryptedItem = (uuid?: string) => { + const createDecryptedItem = (uuid?: string, content_type = ContentType.Note) => { const payload = new DecryptedPayload({ uuid: uuid || String(Math.random()), - content_type: ContentType.Note, + content_type, content: FillItemContent({ title: 'foo', }), @@ -46,20 +46,33 @@ describe('tag notes index', () => { } } + it('should count both notes and files', () => { + const collection = new ItemCollection() + const index = new TagItemsIndex(collection) + + const decryptedNote = createDecryptedItem('note') + const decryptedFile = createDecryptedItem('file') + collection.set([decryptedNote, decryptedFile]) + index.onChange(createChangeDelta(decryptedNote)) + index.onChange(createChangeDelta(decryptedFile)) + + expect(index.allCountableItemsCount()).toEqual(2) + }) + it('should decrement count after decrypted note becomes errored', () => { const collection = new ItemCollection() - const index = new TagNotesIndex(collection) + const index = new TagItemsIndex(collection) const decryptedItem = createDecryptedItem() collection.set(decryptedItem) index.onChange(createChangeDelta(decryptedItem)) - expect(index.allCountableNotesCount()).toEqual(1) + expect(index.allCountableItemsCount()).toEqual(1) const encryptedItem = createEncryptedItem(decryptedItem.uuid) collection.set(encryptedItem) index.onChange(createChangeDelta(encryptedItem)) - expect(index.allCountableNotesCount()).toEqual(0) + expect(index.allCountableItemsCount()).toEqual(0) }) }) diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts similarity index 53% rename from packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts rename to packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts index af2007286..61e495479 100644 --- a/packages/models/src/Domain/Runtime/Collection/Item/TagNotesIndex.ts +++ b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts @@ -7,22 +7,22 @@ import { ItemDelta } from '../../Index/ItemDelta' import { isDecryptedItem, ItemInterface } from '../../../Abstract/Item' type AllNotesUuidSignifier = undefined -export type TagNoteCountChangeObserver = (tagUuid: Uuid | AllNotesUuidSignifier) => void +export type TagItemCountChangeObserver = (tagUuid: Uuid | AllNotesUuidSignifier) => void -export class TagNotesIndex implements SNIndex { - private tagToNotesMap: Partial>> = {} - private allCountableNotes = new Set() +export class TagItemsIndex implements SNIndex { + private tagToItemsMap: Partial>> = {} + private allCountableItems = new Set() - constructor(private collection: ItemCollection, public observers: TagNoteCountChangeObserver[] = []) {} + constructor(private collection: ItemCollection, public observers: TagItemCountChangeObserver[] = []) {} - private isNoteCountable = (note: ItemInterface) => { - if (isDecryptedItem(note)) { - return !note.archived && !note.trashed + private isItemCountable = (item: ItemInterface) => { + if (isDecryptedItem(item)) { + return !item.archived && !item.trashed } return false } - public addCountChangeObserver(observer: TagNoteCountChangeObserver): () => void { + public addCountChangeObserver(observer: TagItemCountChangeObserver): () => void { this.observers.push(observer) const thislessEventObservers = this.observers @@ -37,30 +37,32 @@ export class TagNotesIndex implements SNIndex { } } - public allCountableNotesCount(): number { - return this.allCountableNotes.size + public allCountableItemsCount(): number { + return this.allCountableItems.size } - public countableNotesForTag(tag: SNTag): number { - return this.tagToNotesMap[tag.uuid]?.size || 0 + public countableItemsForTag(tag: SNTag): number { + return this.tagToItemsMap[tag.uuid]?.size || 0 } public onChange(delta: ItemDelta): void { - const notes = [...delta.changed, ...delta.inserted, ...delta.discarded].filter( - (i) => i.content_type === ContentType.Note, + const items = [...delta.changed, ...delta.inserted, ...delta.discarded].filter( + (i) => i.content_type === ContentType.Note || i.content_type === ContentType.File, ) const tags = [...delta.changed, ...delta.inserted].filter(isDecryptedItem).filter(isTag) - this.receiveNoteChanges(notes) + this.receiveItemChanges(items) this.receiveTagChanges(tags) } private receiveTagChanges(tags: SNTag[]): void { for (const tag of tags) { - const uuids = tag.noteReferences.map((ref) => ref.uuid) - const countableUuids = uuids.filter((uuid) => this.allCountableNotes.has(uuid)) - const previousSet = this.tagToNotesMap[tag.uuid] - this.tagToNotesMap[tag.uuid] = new Set(countableUuids) + const uuids = tag.references + .filter((ref) => ref.content_type === ContentType.Note || ref.content_type === ContentType.File) + .map((ref) => ref.uuid) + const countableUuids = uuids.filter((uuid) => this.allCountableItems.has(uuid)) + const previousSet = this.tagToItemsMap[tag.uuid] + this.tagToItemsMap[tag.uuid] = new Set(countableUuids) if (previousSet?.size !== countableUuids.length) { this.notifyObservers(tag.uuid) @@ -68,26 +70,26 @@ export class TagNotesIndex implements SNIndex { } } - private receiveNoteChanges(notes: ItemInterface[]): void { - const previousAllCount = this.allCountableNotes.size + private receiveItemChanges(items: ItemInterface[]): void { + const previousAllCount = this.allCountableItems.size - for (const note of notes) { - const isCountable = this.isNoteCountable(note) + for (const item of items) { + const isCountable = this.isItemCountable(item) if (isCountable) { - this.allCountableNotes.add(note.uuid) + this.allCountableItems.add(item.uuid) } else { - this.allCountableNotes.delete(note.uuid) + this.allCountableItems.delete(item.uuid) } - const associatedTagUuids = this.collection.uuidsThatReferenceUuid(note.uuid) + const associatedTagUuids = this.collection.uuidsThatReferenceUuid(item.uuid) for (const tagUuid of associatedTagUuids) { const set = this.setForTag(tagUuid) const previousCount = set.size if (isCountable) { - set.add(note.uuid) + set.add(item.uuid) } else { - set.delete(note.uuid) + set.delete(item.uuid) } if (previousCount !== set.size) { this.notifyObservers(tagUuid) @@ -95,16 +97,16 @@ export class TagNotesIndex implements SNIndex { } } - if (previousAllCount !== this.allCountableNotes.size) { + if (previousAllCount !== this.allCountableItems.size) { this.notifyObservers(undefined) } } private setForTag(uuid: Uuid): Set { - let set = this.tagToNotesMap[uuid] + let set = this.tagToItemsMap[uuid] if (!set) { set = new Set() - this.tagToNotesMap[uuid] = set + this.tagToItemsMap[uuid] = set } return set } diff --git a/packages/models/src/Domain/Syncable/File/FileMutator.ts b/packages/models/src/Domain/Syncable/File/FileMutator.ts index 93b834845..ecf540916 100644 --- a/packages/models/src/Domain/Syncable/File/FileMutator.ts +++ b/packages/models/src/Domain/Syncable/File/FileMutator.ts @@ -1,9 +1,10 @@ import { ContentType } from '@standardnotes/common' import { SNNote } from '../Note/Note' -import { FileContent } from './File' +import { FileContent, FileItem } from './File' import { FileToNoteReference } from '../../Abstract/Reference/FileToNoteReference' -import { ContenteReferenceType } from '../../Abstract/Reference/ContenteReferenceType' +import { ContentReferenceType } from '../../Abstract/Reference/ContenteReferenceType' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { FileToFileReference } from '../../Abstract/Reference/FileToFileReference' export class FileMutator extends DecryptedItemMutator { set name(newName: string) { @@ -16,7 +17,7 @@ export class FileMutator extends DecryptedItemMutator { public addNote(note: SNNote): void { const reference: FileToNoteReference = { - reference_type: ContenteReferenceType.FileToNote, + reference_type: ContentReferenceType.FileToNote, content_type: ContentType.Note, uuid: note.uuid, } @@ -30,4 +31,22 @@ export class FileMutator extends DecryptedItemMutator { const references = this.immutableItem.references.filter((ref) => ref.uuid !== note.uuid) this.mutableContent.references = references } + + public addFile(file: FileItem): void { + if (this.immutableItem.isReferencingItem(file)) { + return + } + + const reference: FileToFileReference = { + uuid: file.uuid, + content_type: ContentType.File, + reference_type: ContentReferenceType.FileToFile, + } + + this.mutableContent.references.push(reference) + } + + public removeFile(file: FileItem): void { + this.mutableContent.references = this.mutableContent.references.filter((r) => r.uuid !== file.uuid) + } } diff --git a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts index 5337773e4..f41152ca0 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts @@ -1,6 +1,10 @@ import { AppDataField } from '../../Abstract/Item/Types/AppDataField' import { NoteContent } from './NoteContent' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' +import { SNNote } from './Note' +import { NoteToNoteReference } from '../../Abstract/Reference/NoteToNoteReference' +import { ContentType } from '@standardnotes/common' +import { ContentReferenceType } from '../../Abstract/Item' export class NoteMutator extends DecryptedItemMutator { set title(title: string) { @@ -38,4 +42,22 @@ export class NoteMutator extends DecryptedItemMutator { this.mutableContent.spellcheck = !this.mutableContent.spellcheck } } + + public addNote(note: SNNote): void { + if (this.immutableItem.isReferencingItem(note)) { + return + } + + const reference: NoteToNoteReference = { + uuid: note.uuid, + content_type: ContentType.Note, + reference_type: ContentReferenceType.NoteToNote, + } + + this.mutableContent.references.push(reference) + } + + public removeNote(note: SNNote): void { + this.mutableContent.references = this.mutableContent.references.filter((r) => r.uuid !== note.uuid) + } } diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts index 5141f8644..9dff7323c 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts @@ -1,5 +1,5 @@ import { ContentType } from '@standardnotes/common' -import { ContenteReferenceType, MutationType } from '../../Abstract/Item' +import { ContentReferenceType, MutationType } from '../../Abstract/Item' import { createFile, createTag } from '../../Utilities/Test/SpecUtils' import { SNTag } from './Tag' import { TagMutator } from './TagMutator' @@ -16,7 +16,7 @@ describe('tag mutator', () => { expect(result.content.references[0]).toEqual({ uuid: file.uuid, content_type: ContentType.File, - reference_type: ContenteReferenceType.TagToFile, + reference_type: ContentReferenceType.TagToFile, }) }) diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts index 91527163d..ae493461f 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagMutator.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts @@ -4,7 +4,7 @@ import { FileItem } from '../File' import { SNNote } from '../Note' import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' import { TagToParentTagReference } from '../../Abstract/Reference/TagToParentTagReference' -import { ContenteReferenceType } from '../../Abstract/Reference/ContenteReferenceType' +import { ContentReferenceType } from '../../Abstract/Reference/ContenteReferenceType' import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference' @@ -21,7 +21,7 @@ export class TagMutator extends DecryptedItemMutator { const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref)) const reference: TagToParentTagReference = { - reference_type: ContenteReferenceType.TagToParentTag, + reference_type: ContentReferenceType.TagToParentTag, content_type: ContentType.Tag, uuid: tag.uuid, } @@ -41,7 +41,7 @@ export class TagMutator extends DecryptedItemMutator { } const reference: TagToFileReference = { - reference_type: ContenteReferenceType.TagToFile, + reference_type: ContentReferenceType.TagToFile, content_type: ContentType.File, uuid: file.uuid, } diff --git a/packages/models/src/Domain/index.ts b/packages/models/src/Domain/index.ts index a93856bb6..eea35ae28 100644 --- a/packages/models/src/Domain/index.ts +++ b/packages/models/src/Domain/index.ts @@ -38,7 +38,7 @@ export * from './Local/RootKey/RootKeyContent' export * from './Local/RootKey/RootKeyInterface' export * from './Runtime/Collection/CollectionSort' export * from './Runtime/Collection/Item/ItemCollection' -export * from './Runtime/Collection/Item/TagNotesIndex' +export * from './Runtime/Collection/Item/TagItemsIndex' export * from './Runtime/Collection/Payload/ImmutablePayloadCollection' export * from './Runtime/Collection/Payload/PayloadCollection' export * from './Runtime/Deltas' diff --git a/packages/services/src/Domain/Item/ItemsClientInterface.ts b/packages/services/src/Domain/Item/ItemsClientInterface.ts index c137d6519..141b50e0b 100644 --- a/packages/services/src/Domain/Item/ItemsClientInterface.ts +++ b/packages/services/src/Domain/Item/ItemsClientInterface.ts @@ -4,7 +4,7 @@ import { FileItem, SNTag, SmartView, - TagNoteCountChangeObserver, + TagItemCountChangeObserver, DecryptedPayloadInterface, EncryptedItemInterface, DecryptedTransferPayload, @@ -14,6 +14,7 @@ import { SNTheme, DisplayOptions, ItemsKeyInterface, + ItemContent, } from '@standardnotes/models' export interface ItemsClientInterface { @@ -23,12 +24,12 @@ export interface ItemsClientInterface { disassociateFileWithNote(file: FileItem, note: SNNote): Promise - getFilesForNote(note: SNNote): FileItem[] - renameFile(file: FileItem, name: string): Promise addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise + addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise + /** Creates an unmanaged, un-inserted item from a payload. */ createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface @@ -54,7 +55,7 @@ export interface ItemsClientInterface { notesMatchingSmartView(view: SmartView): SNNote[] - addNoteCountChangeObserver(observer: TagNoteCountChangeObserver): () => void + addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void allCountableNotesCount(): number @@ -72,6 +73,14 @@ export interface ItemsClientInterface { itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[] + linkNoteToNote(note: SNNote, otherNote: SNNote): Promise + linkFileToFile(file: FileItem, otherFile: FileItem): Promise + + unlinkItem( + item: DecryptedItemInterface, + itemToUnlink: DecryptedItemInterface, + ): Promise> + /** * Finds tags with title or component starting with a search query and (optionally) not associated with a note * @param searchQuery - The query string to match @@ -101,10 +110,14 @@ export interface ItemsClientInterface { /** * Get tags for a note sorted in natural order - * @param note - The note whose tags will be returned - * @returns Array containing tags associated with a note + * @param item - The item whose tags will be returned + * @returns Array containing tags associated with an item */ - getSortedTagsForNote(note: SNNote): SNTag[] + getSortedTagsForItem(item: DecryptedItemInterface): SNTag[] + getSortedFilesForItem(item: DecryptedItemInterface): FileItem[] + + getSortedLinkedNotesForItem(item: DecryptedItemInterface): SNNote[] + getSortedNotesLinkingToItem(item: DecryptedItemInterface): SNNote[] isSmartViewTitle(title: string): boolean @@ -137,4 +150,12 @@ export interface ItemsClientInterface { * @returns Whether the item is a template (unmanaged) */ isTemplateItem(item: DecryptedItemInterface): boolean + + /** + * @returns `'direct'` if `itemOne` has the reference to `itemTwo`, `'indirect'` if `itemTwo` has the reference to `itemOne`, `'unlinked'` if neither reference each other + */ + relationshipTypeForItems( + itemOne: DecryptedItemInterface, + itemTwo: DecryptedItemInterface, + ): 'direct' | 'indirect' | 'unlinked' } diff --git a/packages/snjs/lib/Services/Items/ItemManager.spec.ts b/packages/snjs/lib/Services/Items/ItemManager.spec.ts index 1810842fc..2b2414d42 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.spec.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.spec.ts @@ -405,41 +405,6 @@ describe('itemManager', () => { const notes = itemManager.getDisplayableNotes() expect(notes).toHaveLength(1) }) - - it('adding a note to a tag hierarchy should add the note to its parent too', async () => { - itemManager = createService() - const parentTag = createTag('parent') - const childTag = createTag('child') - const note = createNote('note') - - await itemManager.insertItems([parentTag, childTag, note]) - await itemManager.setTagParent(parentTag, childTag) - - await itemManager.addTagToNote(note, childTag, true) - - const tags = itemManager.getSortedTagsForNote(note) - - expect(tags).toHaveLength(2) - expect(tags[0].uuid).toEqual(childTag.uuid) - expect(tags[1].uuid).toEqual(parentTag.uuid) - }) - - it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => { - itemManager = createService() - const parentTag = createTag('parent') - const childTag = createTag('child') - const note = createNote('note') - - await itemManager.insertItems([parentTag, childTag, note]) - await itemManager.setTagParent(parentTag, childTag) - - await itemManager.addTagToNote(note, childTag, false) - - const tags = itemManager.getSortedTagsForNote(note) - - expect(tags).toHaveLength(1) - expect(tags[0].uuid).toEqual(childTag.uuid) - }) }) describe('template items', () => { @@ -703,47 +668,6 @@ describe('itemManager', () => { }) describe('files', () => { - it('associates with note', async () => { - itemManager = createService() - const note = createNote('invoices') - const file = createFile('invoice_1.pdf') - await itemManager.insertItems([note, file]) - - const resultingFile = await itemManager.associateFileWithNote(file, note) - const references = resultingFile.references - - expect(references).toHaveLength(1) - expect(references[0].uuid).toEqual(note.uuid) - }) - - it('disassociates with note', async () => { - itemManager = createService() - const note = createNote('invoices') - const file = createFile('invoice_1.pdf') - await itemManager.insertItems([note, file]) - - const associatedFile = await itemManager.associateFileWithNote(file, note) - const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note) - const references = disassociatedFile.references - - expect(references).toHaveLength(0) - }) - - it('should get files associated with note', async () => { - itemManager = createService() - const note = createNote('invoices') - const file = createFile('invoice_1.pdf') - const secondFile = createFile('unrelated-file.xlsx') - await itemManager.insertItems([note, file, secondFile]) - - await itemManager.associateFileWithNote(file, note) - - const filesAssociatedWithNote = itemManager.getFilesForNote(note) - - expect(filesAssociatedWithNote).toHaveLength(1) - expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid) - }) - it('should correctly rename file to filename that has extension', async () => { itemManager = createService() const file = createFile('initialName.ext') @@ -774,4 +698,231 @@ describe('itemManager', () => { expect(renamedFile.name).toBe('anotherName') }) }) + + describe('linking', () => { + it('adding a note to a tag hierarchy should add the note to its parent too', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const note = createNote('note') + + await itemManager.insertItems([parentTag, childTag, note]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToNote(note, childTag, true) + + const tags = itemManager.getSortedTagsForItem(note) + + expect(tags).toHaveLength(2) + expect(tags[0].uuid).toEqual(childTag.uuid) + expect(tags[1].uuid).toEqual(parentTag.uuid) + }) + + it('adding a note to a tag hierarchy should not add the note to its parent if hierarchy option is disabled', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const note = createNote('note') + + await itemManager.insertItems([parentTag, childTag, note]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToNote(note, childTag, false) + + const tags = itemManager.getSortedTagsForItem(note) + + expect(tags).toHaveLength(1) + expect(tags[0].uuid).toEqual(childTag.uuid) + }) + + it('adding a file to a tag hierarchy should add the file to its parent too', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const file = createFile('file') + + await itemManager.insertItems([parentTag, childTag, file]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToFile(file, childTag, true) + + const tags = itemManager.getSortedTagsForItem(file) + + expect(tags).toHaveLength(2) + expect(tags[0].uuid).toEqual(childTag.uuid) + expect(tags[1].uuid).toEqual(parentTag.uuid) + }) + + it('adding a file to a tag hierarchy should not add the file to its parent if hierarchy option is disabled', async () => { + itemManager = createService() + const parentTag = createTag('parent') + const childTag = createTag('child') + const file = createFile('file') + + await itemManager.insertItems([parentTag, childTag, file]) + await itemManager.setTagParent(parentTag, childTag) + + await itemManager.addTagToFile(file, childTag, false) + + const tags = itemManager.getSortedTagsForItem(file) + + expect(tags).toHaveLength(1) + expect(tags[0].uuid).toEqual(childTag.uuid) + }) + + it('should link file with note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + await itemManager.insertItems([note, file]) + + const resultingFile = await itemManager.associateFileWithNote(file, note) + const references = resultingFile.references + + expect(references).toHaveLength(1) + expect(references[0].uuid).toEqual(note.uuid) + }) + + it('should unlink file from note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + await itemManager.insertItems([note, file]) + + const associatedFile = await itemManager.associateFileWithNote(file, note) + const disassociatedFile = await itemManager.disassociateFileWithNote(associatedFile, note) + const references = disassociatedFile.references + + expect(references).toHaveLength(0) + }) + + it('should get files linked with note', async () => { + itemManager = createService() + const note = createNote('invoices') + const file = createFile('invoice_1.pdf') + const secondFile = createFile('unrelated-file.xlsx') + await itemManager.insertItems([note, file, secondFile]) + + await itemManager.associateFileWithNote(file, note) + + const filesAssociatedWithNote = itemManager.getSortedFilesForItem(note) + + expect(filesAssociatedWithNote).toHaveLength(1) + expect(filesAssociatedWithNote[0].uuid).toBe(file.uuid) + }) + + it('should link note to note', async () => { + itemManager = createService() + const note = createNote('research') + const note2 = createNote('citation') + await itemManager.insertItems([note, note2]) + + const resultingNote = await itemManager.linkNoteToNote(note, note2) + const references = resultingNote.references + + expect(references).toHaveLength(1) + expect(references[0].uuid).toEqual(note2.uuid) + }) + + it('should link file to file', async () => { + itemManager = createService() + const file = createFile('research') + const file2 = createFile('citation') + await itemManager.insertItems([file, file2]) + + const resultingfile = await itemManager.linkFileToFile(file, file2) + const references = resultingfile.references + + expect(references).toHaveLength(1) + expect(references[0].uuid).toEqual(file2.uuid) + }) + + it('should get the relationship type for two items', async () => { + itemManager = createService() + const firstNote = createNote('First note') + const secondNote = createNote('Second note') + const unlinkedNote = createNote('Unlinked note') + await itemManager.insertItems([firstNote, secondNote, unlinkedNote]) + + const firstNoteLinkedToSecond = await itemManager.linkNoteToNote(firstNote, secondNote) + + const relationshipOfFirstNoteToSecond = itemManager.relationshipTypeForItems(firstNoteLinkedToSecond, secondNote) + const relationshipOfSecondNoteToFirst = itemManager.relationshipTypeForItems(secondNote, firstNoteLinkedToSecond) + const relationshipOfFirstNoteToUnlinked = itemManager.relationshipTypeForItems( + firstNoteLinkedToSecond, + unlinkedNote, + ) + + expect(relationshipOfFirstNoteToSecond).toBe('direct') + expect(relationshipOfSecondNoteToFirst).toBe('indirect') + expect(relationshipOfFirstNoteToUnlinked).toBe('unlinked') + }) + + it('should unlink itemToUnlink from item', async () => { + itemManager = createService() + const note = createNote('Note 1') + const note2 = createNote('Note 2') + await itemManager.insertItems([note, note2]) + + const linkedItem = await itemManager.linkNoteToNote(note, note2) + const unlinkedItem = await itemManager.unlinkItem(linkedItem, note2) + const references = unlinkedItem.references + + expect(references).toHaveLength(0) + }) + + it('should get all linked files for item', async () => { + itemManager = createService() + const note = createNote('note') + const file = createFile('A1') + const file2 = createFile('B2') + + await itemManager.insertItems([note, file, file2]) + + await itemManager.associateFileWithNote(file2, note) + await itemManager.associateFileWithNote(file, note) + + const sortedFilesForItem = itemManager.getSortedFilesForItem(note) + + expect(sortedFilesForItem).toHaveLength(2) + expect(sortedFilesForItem[0].uuid).toEqual(file.uuid) + expect(sortedFilesForItem[1].uuid).toEqual(file2.uuid) + }) + + it('should get all linked notes for item', async () => { + itemManager = createService() + const baseNote = createNote('note') + const noteToLink1 = createNote('A1') + const noteToLink2 = createNote('B2') + + await itemManager.insertItems([baseNote, noteToLink1, noteToLink2]) + + await itemManager.linkNoteToNote(baseNote, noteToLink2) + await itemManager.linkNoteToNote(baseNote, noteToLink1) + + const sortedFilesForItem = itemManager.getSortedLinkedNotesForItem(baseNote) + + expect(sortedFilesForItem).toHaveLength(2) + expect(sortedFilesForItem[0].uuid).toEqual(noteToLink1.uuid) + expect(sortedFilesForItem[1].uuid).toEqual(noteToLink2.uuid) + }) + + it('should get all notes linking to item', async () => { + itemManager = createService() + const baseNote = createNote('note') + const noteToLink1 = createNote('A1') + const noteToLink2 = createNote('B2') + + await itemManager.insertItems([baseNote, noteToLink1, noteToLink2]) + + await itemManager.linkNoteToNote(noteToLink2, baseNote) + await itemManager.linkNoteToNote(noteToLink1, baseNote) + + const sortedFilesForItem = itemManager.getSortedNotesLinkingToItem(baseNote) + + expect(sortedFilesForItem).toHaveLength(2) + expect(sortedFilesForItem[0].uuid).toEqual(noteToLink1.uuid) + expect(sortedFilesForItem[1].uuid).toEqual(noteToLink2.uuid) + }) + }) }) diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index 0c81fa5a8..495e90b64 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -9,7 +9,7 @@ import * as Services from '@standardnotes/services' import { PayloadManagerChangeData } from '../Payloads' import { DiagnosticInfo, ItemsClientInterface } from '@standardnotes/services' import { ApplicationDisplayOptions } from '@Lib/Application/Options/OptionalOptions' -import { CollectionSort } from '@standardnotes/models' +import { CollectionSort, DecryptedItemInterface, ItemContent } from '@standardnotes/models' type ItemsChangeObserver = { contentType: ContentType[] @@ -32,7 +32,7 @@ export class ItemManager private observers: ItemsChangeObserver[] = [] private collection!: Models.ItemCollection private systemSmartViews: Models.SmartView[] - private tagNotesIndex!: Models.TagNotesIndex + private tagItemsIndex!: Models.TagItemsIndex private navigationDisplayController!: Models.ItemDisplayController private tagDisplayController!: Models.ItemDisplayController @@ -96,7 +96,7 @@ export class ItemManager sortDirection: 'asc', }) - this.tagNotesIndex = new Models.TagNotesIndex(this.collection, this.tagNotesIndex?.observers) + this.tagItemsIndex = new Models.TagItemsIndex(this.collection, this.tagItemsIndex?.observers) } private get allDisplayControllers(): Models.ItemDisplayController[] { @@ -219,7 +219,7 @@ export class ItemManager ;(this.unsubChangeObserver as unknown) = undefined ;(this.payloadManager as unknown) = undefined ;(this.collection as unknown) = undefined - ;(this.tagNotesIndex as unknown) = undefined + ;(this.tagItemsIndex as unknown) = undefined ;(this.tagDisplayController as unknown) = undefined ;(this.navigationDisplayController as unknown) = undefined ;(this.itemsKeyDisplayController as unknown) = undefined @@ -284,23 +284,23 @@ export class ItemManager return TagsToFoldersMigrationApplicator.isApplicableToCurrentData(this) } - public addNoteCountChangeObserver(observer: Models.TagNoteCountChangeObserver): () => void { - return this.tagNotesIndex.addCountChangeObserver(observer) + public addNoteCountChangeObserver(observer: Models.TagItemCountChangeObserver): () => void { + return this.tagItemsIndex.addCountChangeObserver(observer) } public allCountableNotesCount(): number { - return this.tagNotesIndex.allCountableNotesCount() + return this.tagItemsIndex.allCountableItemsCount() } public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number { if (tag instanceof Models.SmartView) { if (tag.uuid === Models.SystemViewId.AllNotes) { - return this.tagNotesIndex.allCountableNotesCount() + return this.tagItemsIndex.allCountableItemsCount() } - throw Error('countableNotesForTag is not meant to be used for smart views.') + throw Error('countableItemsForTag is not meant to be used for smart views.') } - return this.tagNotesIndex.countableNotesForTag(tag) + return this.tagItemsIndex.countableItemsForTag(tag) } public getNoteCount(): number { @@ -406,7 +406,7 @@ export class ItemManager } this.collection.onChange(delta) - this.tagNotesIndex.onChange(delta) + this.tagItemsIndex.onChange(delta) const affectedContentTypesArray = Array.from(affectedContentTypes.values()) for (const controller of this.allDisplayControllers) { @@ -1140,20 +1140,97 @@ export class ItemManager ) } + public async addTagToFile(file: Models.FileItem, tag: Models.SNTag, addHierarchy: boolean): Promise { + let tagsToAdd = [tag] + + if (addHierarchy) { + const parentChainTags = this.getTagParentChain(tag) + tagsToAdd = [...parentChainTags, tag] + } + + return Promise.all( + tagsToAdd.map((tagToAdd) => { + return this.changeTag(tagToAdd, (mutator) => { + mutator.addFile(file) + }) as Promise + }), + ) + } + + public async linkNoteToNote(note: Models.SNNote, otherNote: Models.SNNote): Promise { + return this.changeItem(note, (mutator) => { + mutator.addNote(otherNote) + }) + } + + public async linkFileToFile(file: Models.FileItem, otherFile: Models.FileItem): Promise { + return this.changeItem(file, (mutator) => { + mutator.addFile(otherFile) + }) + } + + public async unlinkItem( + item: DecryptedItemInterface, + itemToUnlink: DecryptedItemInterface, + ) { + return this.changeItem(item, (mutator) => { + mutator.removeItemAsRelationship(itemToUnlink) + }) + } + /** * Get tags for a note sorted in natural order - * @param note - The note whose tags will be returned - * @returns Array containing tags associated with a note + * @param item - The item whose tags will be returned + * @returns Array containing tags associated with an item */ - public getSortedTagsForNote(note: Models.SNNote): Models.SNTag[] { + public getSortedTagsForItem(item: DecryptedItemInterface): Models.SNTag[] { return naturalSort( - this.itemsReferencingItem(note).filter((ref) => { + this.itemsReferencingItem(item).filter((ref) => { return ref?.content_type === ContentType.Tag }) as Models.SNTag[], 'title', ) } + public getSortedFilesForItem(item: DecryptedItemInterface): Models.FileItem[] { + if (this.isTemplateItem(item)) { + return [] + } + + const filesReferencingItem = this.itemsReferencingItem(item).filter( + (ref) => ref.content_type === ContentType.File, + ) as Models.FileItem[] + const filesReferencedByItem = this.referencesForItem(item).filter( + (ref) => ref.content_type === ContentType.File, + ) as Models.FileItem[] + + return naturalSort(filesReferencingItem.concat(filesReferencedByItem), 'title') + } + + public getSortedLinkedNotesForItem(item: DecryptedItemInterface): Models.SNNote[] { + if (this.isTemplateItem(item)) { + return [] + } + + const notesReferencedByItem = this.referencesForItem(item).filter( + (ref) => ref.content_type === ContentType.Note, + ) as Models.SNNote[] + + return naturalSort(notesReferencedByItem, 'title') + } + + public getSortedNotesLinkingToItem(item: Models.DecryptedItemInterface): Models.SNNote[] { + if (this.isTemplateItem(item)) { + return [] + } + + const notesReferencingItem = this.itemsReferencingItem(item).filter( + (ref) => ref.content_type === ContentType.Note, + ) as Models.SNNote[] + + return naturalSort(notesReferencingItem, 'title') + } + public async createTag(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise { const newTag = await this.createItem( ContentType.Tag, @@ -1312,12 +1389,6 @@ export class ItemManager } } - public getFilesForNote(note: Models.SNNote): Models.FileItem[] { - return ( - this.itemsReferencingItem(note).filter((ref) => ref.content_type === ContentType.File) as Models.FileItem[] - ).sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)) - } - public renameFile(file: Models.FileItem, name: string): Promise { return this.changeItem(file, (mutator) => { mutator.name = name @@ -1353,6 +1424,23 @@ export class ItemManager return this.findAnyItems(uuids) as (Models.DecryptedItemInterface | Models.DeletedItemInterface)[] } + /** + * @returns `'direct'` if `itemOne` has the reference to `itemTwo`, `'indirect'` if `itemTwo` has the reference to `itemOne`, `'unlinked'` if neither reference each other + */ + public relationshipTypeForItems( + itemOne: Models.DecryptedItemInterface, + itemTwo: Models.DecryptedItemInterface, + ): 'direct' | 'indirect' | 'unlinked' { + const itemOneReferencesItemTwo = !!this.referencesForItem(itemOne).find( + (reference) => reference.uuid === itemTwo.uuid, + ) + const itemTwoReferencesItemOne = !!this.referencesForItem(itemTwo).find( + (reference) => reference.uuid === itemOne.uuid, + ) + + return itemOneReferencesItemTwo ? 'direct' : itemTwoReferencesItemOne ? 'indirect' : 'unlinked' + } + override getDiagnostics(): Promise { return Promise.resolve({ items: { diff --git a/packages/snjs/mocha/item_manager.test.js b/packages/snjs/mocha/item_manager.test.js index 14fd8bf73..e19060926 100644 --- a/packages/snjs/mocha/item_manager.test.js +++ b/packages/snjs/mocha/item_manager.test.js @@ -570,7 +570,7 @@ describe('item manager', function () { }) }) - const results = this.itemManager.getSortedTagsForNote(note) + const results = this.itemManager.getSortedTagsForItem(note) expect(results).lengthOf(tags.length) expect(results[0].title).to.equal(tags[1].title) diff --git a/packages/snjs/mocha/model_tests/notes_tags_folders.test.js b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js index c450119e1..0e3cedef6 100644 --- a/packages/snjs/mocha/model_tests/notes_tags_folders.test.js +++ b/packages/snjs/mocha/model_tests/notes_tags_folders.test.js @@ -77,8 +77,8 @@ describe('tags as folders', () => { await this.application.items.addTagToNote(note2, tags.another, true) // ## The note has been added to other tags - const note1Tags = await this.application.items.getSortedTagsForNote(note1) - const note2Tags = await this.application.items.getSortedTagsForNote(note2) + const note1Tags = await this.application.items.getSortedTagsForItem(note1) + const note2Tags = await this.application.items.getSortedTagsForItem(note2) expect(note1Tags.length).to.equal(3) expect(note2Tags.length).to.equal(1) diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index d89f2b4c9..bfe7c5fda 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -209,7 +209,6 @@ const ApplicationView: FunctionComponent = ({ 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 = ({ application, mainApplicatio application={application} navigationController={viewControllerManager.navigationController} notesController={viewControllerManager.notesController} - noteTagsController={viewControllerManager.noteTagsController} + linkingController={viewControllerManager.linkingController} historyModalController={viewControllerManager.historyModalController} /> = ({ } }} > - + {attachedFilesCount > 0 && {attachedFilesCount}} = ({
{filteredList.length > 0 || searchQuery.length > 0 ? (
-
- { - setSearchQuery((e.target as HTMLInputElement).value) - }} - ref={searchInputRef} - /> - {searchQuery.length > 0 && ( - - )} -
+ 0 && ( + { + setSearchQuery('') + searchInputRef.current?.focus() + }} + /> + ), + ]} + />
) : null} {filteredList.length > 0 ? ( diff --git a/packages/web/src/javascripts/Components/ClearInputButton/ClearInputButton.tsx b/packages/web/src/javascripts/Components/ClearInputButton/ClearInputButton.tsx new file mode 100644 index 000000000..141d181e7 --- /dev/null +++ b/packages/web/src/javascripts/Components/ClearInputButton/ClearInputButton.tsx @@ -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 ( + + ) +} + +export default ClearInputButton diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 8869a5202..aa891f116 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -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 = ({ itemListController, navigationController, noAccountWarningController, - noteTagsController, notesController, selectionController, searchOptionsController, @@ -167,16 +164,11 @@ const ContentListView: FunctionComponent = ({ 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 = ({ side={PanelSide.Right} type={PanelResizeType.WidthOnly} resizeFinishCallback={panelResizeFinishCallback} - widthEventCallback={panelWidthEventCallback} width={panelWidth} left={0} /> diff --git a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx index a53f6dead..e4fa38211 100644 --- a/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/NoteListItem.tsx @@ -32,7 +32,7 @@ const NoteListItem: FunctionComponent = ({ 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) diff --git a/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx b/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx index 3c09a4e32..98245fae0 100644 --- a/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx +++ b/packages/web/src/javascripts/Components/FileView/FileViewWithoutProtection.tsx @@ -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 }:
+
+
diff --git a/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx new file mode 100644 index 000000000..ff7e3ac52 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/ItemLinkAutocompleteInput.tsx @@ -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('auto') + + const containerRef = useRef(null) + const inputRef = useRef(null) + const searchResultsMenuRef = useRef(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 = (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 = useCallback((event) => { + if (event.key === KeyboardKey.Escape) { + inputRef.current?.focus() + } + }, []) + + return ( +
+
+ + 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 && ( + 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} + > + + setSearchQuery('')} + isEntitledToNoteLinking={isEntitledToNoteLinking} + /> + + + )} + +
+
+ ) +} + +export default observer(ItemLinkAutocompleteInput) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx new file mode 100644 index 000000000..750acbcea --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedFileMenuOptions.tsx @@ -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 ( + <> + + + + + + + + + ) +} + +export default LinkedFileMenuOptions diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx new file mode 100644 index 000000000..0613d2922 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx @@ -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 + 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(null) + + const [showUnlinkButton, setShowUnlinkButton] = useState(false) + const unlinkButtonRef = useRef(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 ( + + ) +} + +export default observer(LinkedItemBubble) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx new file mode 100644 index 000000000..f7c97b51a --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubblesContainer.tsx @@ -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() + 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 ( +
+ {allLinkedItems.concat(notesLinkingToItem).map((item) => ( + + ))} + +
+ ) +} + +export default observer(LinkedItemBubblesContainer) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemMeta.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemMeta.tsx new file mode 100644 index 000000000..ed62f17f4 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemMeta.tsx @@ -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 ( + <> + +
+ {tagTitle && {tagTitle.titlePrefix}} + {searchQuery + ? splitQueryInString(title, searchQuery).map((substring, index) => ( + + {substring} + + )) + : title} +
+ + ) +} + +export default observer(LinkedItemMeta) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx new file mode 100644 index 000000000..2e2139528 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemSearchResults.tsx @@ -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 ( +
+ {results.map((result) => { + const cannotLinkItem = !isEntitledToNoteLinking && result instanceof SNNote + return ( + + ) + })} + {shouldShowCreateTag && ( + + )} +
+ ) +} + +export default observer(LinkedItemSearchResults) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx new file mode 100644 index 000000000..e742a88a4 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx @@ -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 + filesController: FilesController +} + +const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing }: Props) => { + const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController + const buttonRef = useRef(null) + + const toggleMenu = useCallback(async () => { + const willMenuOpen = !isLinkingPanelOpen + if (willMenuOpen && onClickPreprocessing) { + await onClickPreprocessing() + } + setIsLinkingPanelOpen(willMenuOpen) + }, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen]) + + return ( + <> + + + + + + + + ) +} + +export default observer(LinkedItemsButton) diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx new file mode 100644 index 000000000..f0049d6f4 --- /dev/null +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsPanel.tsx @@ -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(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 ( +
+ {isRenamingFile && item instanceof FileItem ? ( +
+ + { + 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() + } + }} + /> +
+ ) : ( + + )} + + + { + unlinkItem(item) + toggleMenu() + }} + > + + Unlink + + {item instanceof FileItem && ( + + )} + +
+
+ Created at: {formatDateForContextMenu(item.created_at)} +
+
+ Modified at: {formatDateForContextMenu(item.userModifiedDate)} +
+
+ ID: {item.uuid} +
+ {item instanceof FileItem && ( +
+ Size: {formatSizeToReadableString(item.decryptedSize)} +
+ )} +
+
+
+ ) +} + +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(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 ( +
+
+ { + setSearchQuery('') + searchInputRef.current?.focus() + }} + /> + ), + ]} + /> + +
+ {isSearching ? ( + <> + {(!!unlinkedResults.length || shouldShowCreateTag) && ( +
+
Unlinked
+ { + setSearchQuery('') + searchInputRef.current?.focus() + }} + /> +
+ )} + {!!linkedResults.length && ( +
+
Linked
+
+ {linkedResults.map((item) => ( + + ))} +
+
+ )} + + ) : ( + <> + {!!tags.length && ( +
+
Linked Tags
+
+ {tags.map((item) => ( + + ))} +
+
+ )} + {!!files.length && ( +
+
Linked Files
+
+ {files.map((item) => ( + + ))} +
+
+ )} + {!!notesLinkedToItem.length && ( +
+
Linked Notes
+
+ {notesLinkedToItem.map((item) => ( + + ))} +
+
+ )} + {!!notesLinkingToItem.length && ( +
+
+ Notes Linking To This Note +
+
+ {notesLinkingToItem.map((item) => ( + + ))} +
+
+ )} + + )} +
+
+ ) +} + +export default observer(LinkedItemsPanel) diff --git a/packages/web/src/javascripts/Components/Menu/Menu.tsx b/packages/web/src/javascripts/Components/Menu/Menu.tsx index cd9606ab6..1af298124 100644 --- a/packages/web/src/javascripts/Components/Menu/Menu.tsx +++ b/packages/web/src/javascripts/Components/Menu/Menu.tsx @@ -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 + shouldAutoFocus?: boolean } -const Menu: FunctionComponent = ({ - children, - className = '', - style, - a11yLabel, - closeMenu, - isOpen, - initialFocus, -}: MenuProps) => { - const menuElementRef = useRef(null) +const Menu = forwardRef( + ( + { + children, + className = '', + style, + a11yLabel, + closeMenu, + isOpen, + initialFocus, + onKeyDown, + shouldAutoFocus = true, + }: MenuProps, + forwardedRef: Ref, + ) => { + const menuElementRef = useRef(null) - const handleKeyDown: KeyboardEventHandler = useCallback( - (event) => { - if (event.key === KeyboardKey.Escape) { - closeMenu?.() - return + const handleKeyDown: KeyboardEventHandler = 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 ( - - {children} - - ) -} + return ( + + {children} + + ) + }, +) export default Menu diff --git a/packages/web/src/javascripts/Components/MultipleSelectedNotes/MultipleSelectedNotes.tsx b/packages/web/src/javascripts/Components/MultipleSelectedNotes/MultipleSelectedNotes.tsx index a6c136af2..890a3003e 100644 --- a/packages/web/src/javascripts/Components/MultipleSelectedNotes/MultipleSelectedNotes.tsx +++ b/packages/web/src/javascripts/Components/MultipleSelectedNotes/MultipleSelectedNotes.tsx @@ -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} />
diff --git a/packages/web/src/javascripts/Components/Navigation/Navigation.tsx b/packages/web/src/javascripts/Components/Navigation/Navigation.tsx index 34fc94aac..b276ddcdc 100644 --- a/packages/web/src/javascripts/Components/Navigation/Navigation.tsx +++ b/packages/web/src/javascripts/Components/Navigation/Navigation.tsx @@ -49,16 +49,11 @@ const Navigation: FunctionComponent = ({ 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 ( {!this.state.shouldStickyHeader && (
- { 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 { )}
{!this.state.shouldStickyHeader && ( - + )} )} diff --git a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx index 0f854e9b6..a7f41580f 100644 --- a/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx +++ b/packages/web/src/javascripts/Components/NotesContextMenu/NotesContextMenu.tsx @@ -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} /> diff --git a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx index d36e95e90..9294a2c52 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/AddTagOption.tsx @@ -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 = ({ navigationController, notesController, noteTagsController }) => { +const AddTagOption: FunctionComponent = ({ navigationController, notesController, linkingController }) => { const menuContainerRef = useRef(null) const buttonRef = useRef(null) @@ -63,7 +63,7 @@ const AddTagOption: FunctionComponent = ({ navigationController, notesCon className={`overflow-hidden overflow-ellipsis whitespace-nowrap ${notesController.isTagInSelectedNotes(tag) ? 'font-bold' : ''}`} > - {noteTagsController.getLongTitle(tag)} + {linkingController.getTitleForLinkedTag(tag)?.longTitle} ))} diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx index 2b5150d4f..6fa287cc1 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptions.tsx @@ -178,7 +178,7 @@ const NotesOptions = ({ application, navigationController, notesController, - noteTagsController, + linkingController, historyModalController, closeMenu, }: NotesOptionsProps) => { @@ -327,7 +327,7 @@ const NotesOptions = ({ )} {unpinned && ( diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsPanel.tsx b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsPanel.tsx index 7cbabba7a..1d8735775 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsPanel.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsPanel.tsx @@ -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 } @@ -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} /> diff --git a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts index 44d1a2d63..afd07acb8 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts +++ b/packages/web/src/javascripts/Components/NotesOptions/NotesOptionsProps.ts @@ -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 } diff --git a/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx b/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx index 78d91c959..31768f6ba 100644 --- a/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx +++ b/packages/web/src/javascripts/Components/Popover/PositionedPopoverContent.tsx @@ -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) => { diff --git a/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts b/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts index ace9c5c42..f4ca9b192 100644 --- a/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts +++ b/packages/web/src/javascripts/Components/Popover/Utils/usePopoverCloseOnClickOutside.ts @@ -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]) } diff --git a/packages/web/src/javascripts/Components/SearchBar/SearchBar.tsx b/packages/web/src/javascripts/Components/SearchBar/SearchBar.tsx index fec855215..2349a01f6 100644 --- a/packages/web/src/javascripts/Components/SearchBar/SearchBar.tsx +++ b/packages/web/src/javascripts/Components/SearchBar/SearchBar.tsx @@ -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={[]} - right={[ - noteFilterText && ( - - ), - ]} + right={[noteFilterText && ]} roundedFull /> diff --git a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx index 80c0c85bb..fc282bc96 100644 --- a/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx +++ b/packages/web/src/javascripts/Components/StyledTooltip/StyledTooltip.tsx @@ -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); } ` diff --git a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagHint.tsx b/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagHint.tsx deleted file mode 100644 index 8f7f95b10..000000000 --- a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagHint.tsx +++ /dev/null @@ -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(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 && } - - - ) -} - -export default observer(AutocompleteTagHint) diff --git a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagInput.tsx b/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagInput.tsx deleted file mode 100644 index 1d5321f6d..000000000 --- a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagInput.tsx +++ /dev/null @@ -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('auto') - - const containerRef = useRef(null) - const inputRef = useRef(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 = (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 ( -
-
0 ? 'mt-2' : ''}`}> - - 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) && ( - 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} - > -
- {autocompleteTagResults.map((tagResult: SNTag) => ( - - ))} -
- {autocompleteTagHintVisible && ( - - )} -
- )} -
-
-
- ) -} - -export default observer(AutocompleteTagInput) diff --git a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagResult.tsx b/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagResult.tsx deleted file mode 100644 index c97b37338..000000000 --- a/packages/web/src/javascripts/Components/TagAutocomplete/AutocompleteTagResult.tsx +++ /dev/null @@ -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(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 ( - - ) -} - -export default observer(AutocompleteTagResult) diff --git a/packages/web/src/javascripts/Constants/ElementIDs.ts b/packages/web/src/javascripts/Constants/ElementIDs.ts index 1b169c290..7cf8b7089 100644 --- a/packages/web/src/javascripts/Constants/ElementIDs.ts +++ b/packages/web/src/javascripts/Constants/ElementIDs.ts @@ -10,4 +10,5 @@ export const ElementIds = { NoteTitleEditor: 'note-title-editor', RootId: 'app-group-root', NoteStatusTooltip: 'note-status-tooltip', + ItemLinkAutocompleteInput: 'item-link-autocomplete-input', } as const diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index bdf733af5..5c5be6922 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -99,7 +99,7 @@ export class FilesController extends AbstractViewController { reloadAttachedFiles = () => { const note = this.notesController.firstSelectedNote if (note) { - this.attachedFiles = this.application.items.getFilesForNote(note) + this.attachedFiles = this.application.items.getSortedFilesForItem(note) } } diff --git a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts index 928a3d14b..9c73fd671 100644 --- a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts +++ b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts @@ -29,10 +29,10 @@ import { CrossControllerEvent } from '../CrossControllerEvent' import { SearchOptionsController } from '../SearchOptionsController' import { SelectedItemsController } from '../SelectedItemsController' import { NotesController } from '../NotesController' -import { NoteTagsController } from '../NoteTagsController' import { formatDateAndTimeForNote } from '@/Utils/DateUtils' import { PrefDefaults } from '@/Constants/PrefDefaults' import dayjs from 'dayjs' +import { LinkingController } from '../LinkingController' const MinNoteCellHeight = 51.0 const DefaultListNumNotes = 20 @@ -85,7 +85,6 @@ export class ItemListController extends AbstractViewController implements Intern ;(this.searchOptionsController as unknown) = undefined ;(this.selectionController as unknown) = undefined ;(this.notesController as unknown) = undefined - ;(this.noteTagsController as unknown) = undefined ;(window.onresize as unknown) = undefined destroyAllObjectProperties(this) @@ -97,7 +96,7 @@ export class ItemListController extends AbstractViewController implements Intern private searchOptionsController: SearchOptionsController, private selectionController: SelectedItemsController, private notesController: NotesController, - private noteTagsController: NoteTagsController, + private linkingController: LinkingController, eventBus: InternalEventBus, ) { super(application, eventBus) @@ -228,13 +227,12 @@ export class ItemListController extends AbstractViewController implements Intern return this.application.itemControllerGroup.activeItemViewController } - public get activeControllerNote(): SNNote | undefined { - const activeController = this.getActiveItemController() - return activeController instanceof NoteViewController ? activeController.item : undefined + public get activeControllerItem() { + return this.getActiveItemController()?.item } async openNote(uuid: string): Promise { - if (this.activeControllerNote?.uuid === uuid) { + if (this.activeControllerItem?.uuid === uuid) { return } @@ -246,7 +244,7 @@ export class ItemListController extends AbstractViewController implements Intern await this.application.itemControllerGroup.createItemController(note) - this.noteTagsController.reloadTagsForCurrentNote() + this.linkingController.reloadAllLinks() await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged) } @@ -263,6 +261,8 @@ export class ItemListController extends AbstractViewController implements Intern } await this.application.itemControllerGroup.createItemController(file) + + this.linkingController.reloadAllLinks() } setCompletedFullSync = (completed: boolean) => { @@ -545,7 +545,7 @@ export class ItemListController extends AbstractViewController implements Intern await this.createNewNoteController(title) - this.noteTagsController.reloadTagsForCurrentNote() + this.linkingController.reloadAllLinks() } createPlaceholderNote = () => { diff --git a/packages/web/src/javascripts/Controllers/LinkingController.tsx b/packages/web/src/javascripts/Controllers/LinkingController.tsx new file mode 100644 index 000000000..fabefb262 --- /dev/null +++ b/packages/web/src/javascripts/Controllers/LinkingController.tsx @@ -0,0 +1,339 @@ +import { WebApplication } from '@/Application/Application' +import { PopoverFileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction' +import { AppPaneId } from '@/Components/ResponsivePane/AppPaneMetadata' +import { PrefDefaults } from '@/Constants/PrefDefaults' +import { + ApplicationEvent, + ContentType, + DecryptedItemInterface, + FileItem, + IconType, + InternalEventBus, + ItemContent, + naturalSort, + PrefKey, + SNNote, + SNTag, +} from '@standardnotes/snjs' +import { action, computed, makeObservable, observable } from 'mobx' +import { AbstractViewController } from './Abstract/AbstractViewController' +import { FilesController } from './FilesController' +import { ItemListController } from './ItemList/ItemListController' +import { NavigationController } from './Navigation/NavigationController' +import { SelectedItemsController } from './SelectedItemsController' +import { SubscriptionController } from './Subscription/SubscriptionController' + +export type LinkableItem = DecryptedItemInterface + +export class LinkingController extends AbstractViewController { + tags: SNTag[] = [] + files: FileItem[] = [] + notesLinkedToItem: SNNote[] = [] + notesLinkingToItem: SNNote[] = [] + shouldLinkToParentFolders: boolean + isLinkingPanelOpen = false + private itemListController!: ItemListController + private filesController!: FilesController + private subscriptionController!: SubscriptionController + + constructor( + application: WebApplication, + private navigationController: NavigationController, + private selectionController: SelectedItemsController, + eventBus: InternalEventBus, + ) { + super(application, eventBus) + + makeObservable(this, { + tags: observable, + files: observable, + notesLinkedToItem: observable, + notesLinkingToItem: observable, + isLinkingPanelOpen: observable, + + allLinkedItems: computed, + isEntitledToNoteLinking: computed, + + setIsLinkingPanelOpen: action, + reloadLinkedFiles: action, + reloadLinkedTags: action, + reloadLinkedNotes: action, + reloadNotesLinkingToItem: action, + }) + + this.shouldLinkToParentFolders = application.getPreference( + PrefKey.NoteAddToParentFolders, + PrefDefaults[PrefKey.NoteAddToParentFolders], + ) + + this.disposers.push( + this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { + this.shouldLinkToParentFolders = this.application.getPreference( + PrefKey.NoteAddToParentFolders, + PrefDefaults[PrefKey.NoteAddToParentFolders], + ) + }), + ) + } + + public setServicesPostConstruction( + itemListController: ItemListController, + filesController: FilesController, + subscriptionController: SubscriptionController, + ) { + this.itemListController = itemListController + this.filesController = filesController + this.subscriptionController = subscriptionController + + this.disposers.push( + this.application.streamItems(ContentType.File, () => { + this.reloadLinkedFiles() + }), + this.application.streamItems(ContentType.Tag, () => { + this.reloadLinkedTags() + }), + this.application.streamItems(ContentType.Note, () => { + this.reloadLinkedNotes() + this.reloadNotesLinkingToItem() + }), + ) + } + + get isEntitledToNoteLinking() { + return !!this.subscriptionController.userSubscription + } + + setIsLinkingPanelOpen = (open: boolean) => { + this.isLinkingPanelOpen = open + } + + get allLinkedItems() { + return [...this.tags, ...this.files, ...this.notesLinkedToItem] + } + + get activeItem() { + return this.itemListController.activeControllerItem + } + + reloadAllLinks() { + this.reloadLinkedFiles() + this.reloadLinkedTags() + this.reloadLinkedNotes() + this.reloadNotesLinkingToItem() + } + + reloadLinkedFiles() { + if (this.activeItem) { + const files = this.application.items.getSortedFilesForItem(this.activeItem) + this.files = files + } + } + + reloadLinkedTags() { + if (this.activeItem) { + const tags = this.application.items.getSortedTagsForItem(this.activeItem) + this.tags = tags + } + } + + reloadLinkedNotes() { + if (this.activeItem) { + const notes = this.application.items.getSortedLinkedNotesForItem(this.activeItem) + this.notesLinkedToItem = notes + } + } + + reloadNotesLinkingToItem() { + if (this.activeItem) { + const notes = this.application.items.getSortedNotesLinkingToItem(this.activeItem) + this.notesLinkingToItem = notes + } + } + + getTitleForLinkedTag = (item: LinkableItem) => { + const isTag = item instanceof SNTag + + if (!isTag) { + return + } + + const titlePrefix = this.application.items.getTagPrefixTitle(item) + const longTitle = this.application.items.getTagLongTitle(item) + return { + titlePrefix, + longTitle, + } + } + + getLinkedItemIcon = (item: LinkableItem): [IconType, string] => { + if (item instanceof SNNote) { + const editorForNote = this.application.componentManager.editorForNote(item) + const [icon, tint] = this.application.iconsController.getIconAndTintForNoteType( + editorForNote?.package_info.note_type, + ) + const className = `text-accessory-tint-${tint}` + return [icon, className] + } else if (item instanceof FileItem) { + const icon = this.application.iconsController.getIconForFileType(item.mimeType) + return [icon, 'text-info'] + } + + return ['hashtag', 'text-info'] + } + + activateItem = async (item: LinkableItem): Promise => { + this.setIsLinkingPanelOpen(false) + + if (item instanceof SNTag) { + await this.navigationController.setSelectedTag(item) + return AppPaneId.Items + } else if (item instanceof SNNote) { + await this.navigationController.selectHomeNavigationView() + const { didSelect } = await this.selectionController.selectItem(item.uuid, true) + if (didSelect) { + return AppPaneId.Editor + } + } else if (item instanceof FileItem) { + await this.filesController.handleFileAction({ + type: PopoverFileItemActionType.PreviewFile, + payload: { + file: item, + otherFiles: [], + }, + }) + } + + return undefined + } + + unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => { + const selectedItem = this.selectionController.firstSelectedItem + + if (!selectedItem) { + return + } + + const selectedItemReferencesItemToUnlink = + this.application.items.relationshipTypeForItems(selectedItem, itemToUnlink) === 'direct' + + if (selectedItemReferencesItemToUnlink) { + await this.application.items.unlinkItem(selectedItem, itemToUnlink) + } else { + await this.application.items.unlinkItem(itemToUnlink, selectedItem) + } + + void this.application.sync.sync() + this.reloadAllLinks() + } + + linkItemToSelectedItem = async (itemToLink: LinkableItem) => { + const selectedItem = this.selectionController.firstSelectedItem + + if (itemToLink instanceof SNTag) { + await this.addTagToActiveItem(itemToLink) + } + + if (selectedItem instanceof SNNote) { + if (itemToLink instanceof FileItem) { + await this.application.items.associateFileWithNote(itemToLink, selectedItem) + } else if (itemToLink instanceof SNNote && this.isEntitledToNoteLinking) { + await this.application.items.linkNoteToNote(selectedItem, itemToLink) + } + } else if (selectedItem instanceof FileItem) { + if (itemToLink instanceof SNNote) { + await this.application.items.associateFileWithNote(selectedItem, itemToLink) + } else if (itemToLink instanceof FileItem) { + await this.application.items.linkFileToFile(itemToLink, selectedItem) + } + } + + void this.application.sync.sync() + this.reloadAllLinks() + } + + createAndAddNewTag = async (title: string) => { + const newTag = await this.application.mutator.findOrCreateTag(title) + await this.addTagToActiveItem(newTag) + } + + addTagToActiveItem = async (tag: SNTag) => { + const activeItem = this.itemListController.activeControllerItem + + if (!activeItem) { + return + } + + if (activeItem instanceof SNNote) { + await this.application.items.addTagToNote(activeItem, tag, this.shouldLinkToParentFolders) + } else if (activeItem instanceof FileItem) { + await this.application.items.addTagToFile(activeItem, tag, this.shouldLinkToParentFolders) + } + + this.reloadLinkedTags() + this.application.sync.sync().catch(console.error) + } + + getSearchResults = (searchQuery: string) => { + if (!searchQuery.length) { + return { + linkedResults: [], + unlinkedResults: [], + shouldShowCreateTag: false, + } + } + + const searchResults = naturalSort( + this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]).filter((item) => { + const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title + const matchesQuery = title?.toLowerCase().includes(searchQuery.toLowerCase()) + const isNotActiveItem = this.activeItem?.uuid !== item.uuid + const isArchivedOrTrashed = item.archived || item.trashed + return matchesQuery && isNotActiveItem && !isArchivedOrTrashed + }), + 'title', + ) + + const isAlreadyLinked = (item: LinkableItem) => { + if (!this.activeItem) { + return false + } + const isItemReferencedByActiveItem = this.application.items + .itemsReferencingItem(item) + .some((linkedItem) => linkedItem.uuid === this.activeItem?.uuid) + const isActiveItemReferencedByItem = this.application.items + .itemsReferencingItem(this.activeItem) + .some((linkedItem) => linkedItem.uuid === item.uuid) + const isAlreadyLinkedToItem = + isItemReferencedByActiveItem || (item.content_type !== ContentType.Note && isActiveItemReferencedByItem) + return isAlreadyLinkedToItem + } + + const prioritizeTagResult = ( + itemA: DecryptedItemInterface, + itemB: DecryptedItemInterface, + ) => { + if (itemA.content_type === ContentType.Tag && itemB.content_type !== ContentType.Tag) { + return -1 + } + if (itemB.content_type === ContentType.Tag && itemA.content_type !== ContentType.Tag) { + return 1 + } + return 0 + } + + const unlinkedResults = searchResults + .slice(0, 20) + .filter((item) => !isAlreadyLinked(item)) + .sort(prioritizeTagResult) + const linkedResults = searchResults.filter(isAlreadyLinked).slice(0, 20) + const isResultExistingTag = (result: LinkableItem) => + result.content_type === ContentType.Tag && result.title === searchQuery + const shouldShowCreateTag = !linkedResults.find(isResultExistingTag) && !unlinkedResults.find(isResultExistingTag) + + return { + unlinkedResults, + linkedResults, + shouldShowCreateTag, + } + } +} diff --git a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts index 793f5266e..13583889b 100644 --- a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts +++ b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts @@ -394,10 +394,18 @@ export class NavigationController extends AbstractViewController { await this.setSelectedTag(this.homeNavigationView) } + public async selectFilesView() { + await this.setSelectedTag(this.filesNavigationView) + } + get homeNavigationView(): SmartView { return this.smartViews[0] } + get filesNavigationView(): SmartView { + return this.smartViews.find((view) => view.uuid === SystemViewId.Files) as SmartView + } + private setSelectedTagInstance(tag: AnyTag | undefined): void { runInAction(() => (this.selected_ = tag)) } diff --git a/packages/web/src/javascripts/Controllers/NoteTagsController.ts b/packages/web/src/javascripts/Controllers/NoteTagsController.ts deleted file mode 100644 index 34a1a53e6..000000000 --- a/packages/web/src/javascripts/Controllers/NoteTagsController.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { ElementIds } from '@/Constants/ElementIDs' -import { PrefDefaults } from '@/Constants/PrefDefaults' -import { destroyAllObjectProperties } from '@/Utils' -import { - ApplicationEvent, - ContentType, - InternalEventBus, - PrefKey, - SNNote, - SNTag, - UuidString, -} from '@standardnotes/snjs' -import { action, computed, makeObservable, observable } from 'mobx' -import { WebApplication } from '../Application/Application' -import { AbstractViewController } from './Abstract/AbstractViewController' -import { ItemListController } from './ItemList/ItemListController' - -export class NoteTagsController extends AbstractViewController { - autocompleteInputFocused = false - autocompleteSearchQuery = '' - autocompleteTagHintFocused = false - autocompleteTagResults: SNTag[] = [] - focusedTagResultUuid: UuidString | undefined = undefined - focusedTagUuid: UuidString | undefined = undefined - tags: SNTag[] = [] - tagsContainerMaxWidth: number | 'auto' = 0 - addNoteToParentFolders: boolean - private itemListController!: ItemListController - - override deinit() { - super.deinit() - ;(this.tags as unknown) = undefined - ;(this.autocompleteTagResults as unknown) = undefined - ;(this.itemListController as unknown) = undefined - - destroyAllObjectProperties(this) - } - - constructor(application: WebApplication, eventBus: InternalEventBus) { - super(application, eventBus) - - makeObservable(this, { - autocompleteInputFocused: observable, - autocompleteSearchQuery: observable, - autocompleteTagHintFocused: observable, - autocompleteTagResults: observable, - focusedTagUuid: observable, - focusedTagResultUuid: observable, - tags: observable, - tagsContainerMaxWidth: observable, - - autocompleteTagHintVisible: computed, - - setAutocompleteInputFocused: action, - setAutocompleteSearchQuery: action, - setAutocompleteTagHintFocused: action, - setAutocompleteTagResults: action, - setFocusedTagResultUuid: action, - setFocusedTagUuid: action, - setTags: action, - setTagsContainerMaxWidth: action, - }) - - this.addNoteToParentFolders = application.getPreference( - PrefKey.NoteAddToParentFolders, - PrefDefaults[PrefKey.NoteAddToParentFolders], - ) - } - - public setServicesPostConstruction(itemListController: ItemListController) { - this.itemListController = itemListController - - this.disposers.push( - this.application.streamItems(ContentType.Tag, () => { - this.reloadTagsForCurrentNote() - }), - this.application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => { - this.addNoteToParentFolders = this.application.getPreference( - PrefKey.NoteAddToParentFolders, - PrefDefaults[PrefKey.NoteAddToParentFolders], - ) - }), - ) - } - - get autocompleteTagHintVisible(): boolean { - return ( - this.autocompleteSearchQuery !== '' && - !this.autocompleteTagResults.some((tagResult) => tagResult.title === this.autocompleteSearchQuery) - ) - } - - setAutocompleteInputFocused(focused: boolean): void { - this.autocompleteInputFocused = focused - } - - setAutocompleteSearchQuery(query: string): void { - this.autocompleteSearchQuery = query - } - - setAutocompleteTagHintFocused(focused: boolean): void { - this.autocompleteTagHintFocused = focused - } - - setAutocompleteTagResults(results: SNTag[]): void { - this.autocompleteTagResults = results - } - - setFocusedTagUuid(tagUuid: UuidString | undefined): void { - this.focusedTagUuid = tagUuid - } - - setFocusedTagResultUuid(tagUuid: UuidString | undefined): void { - this.focusedTagResultUuid = tagUuid - } - - setTags(tags: SNTag[]): void { - this.tags = tags - } - - setTagsContainerMaxWidth(width: number): void { - this.tagsContainerMaxWidth = width - } - - clearAutocompleteSearch(): void { - this.setAutocompleteSearchQuery('') - this.setAutocompleteTagResults([]) - } - - async createAndAddNewTag(): Promise { - const newTag = await this.application.mutator.findOrCreateTag(this.autocompleteSearchQuery) - await this.addTagToActiveNote(newTag) - this.clearAutocompleteSearch() - } - - focusNextTag(tag: SNTag): void { - const nextTagIndex = this.getTagIndex(tag, this.tags) + 1 - if (nextTagIndex > -1 && this.tags.length > nextTagIndex) { - const nextTag = this.tags[nextTagIndex] - this.setFocusedTagUuid(nextTag.uuid) - } - } - - focusNextTagResult(tagResult: SNTag): void { - const nextTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) + 1 - if (nextTagResultIndex > -1 && this.autocompleteTagResults.length > nextTagResultIndex) { - const nextTagResult = this.autocompleteTagResults[nextTagResultIndex] - this.setFocusedTagResultUuid(nextTagResult.uuid) - } - } - - focusPreviousTag(tag: SNTag): void { - const previousTagIndex = this.getTagIndex(tag, this.tags) - 1 - if (previousTagIndex > -1 && this.tags.length > previousTagIndex) { - const previousTag = this.tags[previousTagIndex] - this.setFocusedTagUuid(previousTag.uuid) - } - } - - focusPreviousTagResult(tagResult: SNTag): void { - const previousTagResultIndex = this.getTagIndex(tagResult, this.autocompleteTagResults) - 1 - if (previousTagResultIndex > -1 && this.autocompleteTagResults.length > previousTagResultIndex) { - const previousTagResult = this.autocompleteTagResults[previousTagResultIndex] - this.setFocusedTagResultUuid(previousTagResult.uuid) - } - } - - searchActiveNoteAutocompleteTags(): void { - const newResults = this.application.items.searchTags( - this.autocompleteSearchQuery, - this.itemListController.activeControllerNote, - ) - this.setAutocompleteTagResults(newResults) - } - - getTagIndex(tag: SNTag, tagsArr: SNTag[]): number { - return tagsArr.findIndex((t) => t.uuid === tag.uuid) - } - - reloadTagsForCurrentNote(): void { - const activeNote = this.itemListController.activeControllerNote - - if (activeNote) { - const tags = this.application.items.getSortedTagsForNote(activeNote) - this.setTags(tags) - } - } - - reloadTagsContainerMaxWidth(): void { - const editorWidth = document.getElementById(ElementIds.EditorColumn)?.clientWidth - if (editorWidth) { - this.setTagsContainerMaxWidth(editorWidth) - } - } - - async addTagToActiveNote(tag: SNTag): Promise { - const activeNote = this.itemListController.activeControllerNote - - if (activeNote) { - await this.application.items.addTagToNote(activeNote, tag, this.addNoteToParentFolders) - this.application.sync.sync().catch(console.error) - this.reloadTagsForCurrentNote() - } - } - - async removeTagFromActiveNote(tag: SNTag): Promise { - const activeNote = this.itemListController.activeControllerNote - - if (activeNote) { - await this.application.mutator.changeItem(tag, (mutator) => { - mutator.removeItemAsRelationship(activeNote) - }) - this.application.sync.sync().catch(console.error) - this.reloadTagsForCurrentNote() - } - } - - getSortedTagsForNote(note: SNNote): SNTag[] { - const tags = this.application.items.getSortedTagsForNote(note) - - const sortFunction = (tagA: SNTag, tagB: SNTag): number => { - const a = this.getLongTitle(tagA) - const b = this.getLongTitle(tagB) - - if (a < b) { - return -1 - } - if (b > a) { - return 1 - } - return 0 - } - - return tags.sort(sortFunction) - } - - getPrefixTitle(tag: SNTag): string | undefined { - return this.application.items.getTagPrefixTitle(tag) - } - - getLongTitle(tag: SNTag): string { - return this.application.items.getTagLongTitle(tag) - } -} diff --git a/packages/web/src/javascripts/Controllers/NotesController.ts b/packages/web/src/javascripts/Controllers/NotesController.ts index dc5cc21aa..7c0d3fe39 100644 --- a/packages/web/src/javascripts/Controllers/NotesController.ts +++ b/packages/web/src/javascripts/Controllers/NotesController.ts @@ -8,7 +8,6 @@ import { WebApplication } from '../Application/Application' import { AbstractViewController } from './Abstract/AbstractViewController' import { SelectedItemsController } from './SelectedItemsController' import { ItemListController } from './ItemList/ItemListController' -import { NoteTagsController } from './NoteTagsController' import { NavigationController } from './Navigation/NavigationController' export class NotesController extends AbstractViewController { @@ -27,7 +26,6 @@ export class NotesController extends AbstractViewController { super.deinit() ;(this.lastSelectedNote as unknown) = undefined ;(this.selectionController as unknown) = undefined - ;(this.noteTagsController as unknown) = undefined ;(this.navigationController as unknown) = undefined ;(this.itemListController as unknown) = undefined @@ -37,7 +35,6 @@ export class NotesController extends AbstractViewController { constructor( application: WebApplication, private selectionController: SelectedItemsController, - private noteTagsController: NoteTagsController, private navigationController: NavigationController, eventBus: InternalEventBus, ) { diff --git a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts index 01a449199..257ff08e1 100644 --- a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts +++ b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts @@ -34,6 +34,7 @@ export class SelectedItemsController extends AbstractViewController { selectedItemsCount: computed, selectedFiles: computed, selectedFilesCount: computed, + firstSelectedItem: computed, selectItem: action, setSelectedItems: action, @@ -79,6 +80,10 @@ export class SelectedItemsController extends AbstractViewController { return this.selectedFiles.length } + get firstSelectedItem() { + return this.getSelectedItems()[0] + } + getSelectedItems = (contentType?: ContentType): T[] => { return Object.values(this.selectedItems).filter((item) => { return !contentType ? true : item.content_type === contentType diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index 1be78ff49..081b606a7 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -17,7 +17,6 @@ import { FeaturesController } from './FeaturesController' import { FilesController } from './FilesController' import { NotesController } from './NotesController' import { ItemListController } from './ItemList/ItemListController' -import { NoteTagsController } from './NoteTagsController' import { NoAccountWarningController } from './NoAccountWarningController' import { PreferencesController } from './PreferencesController' import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController' @@ -31,6 +30,7 @@ import { SelectedItemsController } from './SelectedItemsController' import { HistoryModalController } from './NoteHistory/HistoryModalController' import { PreferenceId } from '@/Components/Preferences/PreferencesMenu' import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' +import { LinkingController } from './LinkingController' export class ViewControllerManager { readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures @@ -47,7 +47,6 @@ export class ViewControllerManager { readonly noAccountWarningController: NoAccountWarningController readonly notesController: NotesController readonly itemListController: ItemListController - readonly noteTagsController: NoteTagsController readonly preferencesController = new PreferencesController() readonly purchaseFlowController: PurchaseFlowController readonly quickSettingsMenuController = new QuickSettingsController() @@ -57,6 +56,7 @@ export class ViewControllerManager { readonly navigationController: NavigationController readonly selectionController: SelectedItemsController readonly historyModalController: HistoryModalController + readonly linkingController: LinkingController public isSessionsModalVisible = false @@ -74,8 +74,6 @@ export class ViewControllerManager { this.selectionController = new SelectedItemsController(application, this.eventBus) - this.noteTagsController = new NoteTagsController(application, this.eventBus) - this.featuresController = new FeaturesController(application, this.eventBus) this.navigationController = new NavigationController(application, this.featuresController, this.eventBus) @@ -83,25 +81,30 @@ export class ViewControllerManager { this.notesController = new NotesController( application, this.selectionController, - this.noteTagsController, this.navigationController, this.eventBus, ) this.searchOptionsController = new SearchOptionsController(application, this.eventBus) + this.linkingController = new LinkingController( + application, + this.navigationController, + this.selectionController, + this.eventBus, + ) + this.itemListController = new ItemListController( application, this.navigationController, this.searchOptionsController, this.selectionController, this.notesController, - this.noteTagsController, + this.linkingController, this.eventBus, ) this.notesController.setServicesPostConstruction(this.itemListController) - this.noteTagsController.setServicesPostConstruction(this.itemListController) this.selectionController.setServicesPostConstruction(this.itemListController) this.noAccountWarningController = new NoAccountWarningController(application, this.eventBus) @@ -119,6 +122,12 @@ export class ViewControllerManager { this.eventBus, ) + this.linkingController.setServicesPostConstruction( + this.itemListController, + this.filesController, + this.subscriptionController, + ) + this.historyModalController = new HistoryModalController(this.application, this.eventBus) this.addAppEventObserver() @@ -180,8 +189,8 @@ export class ViewControllerManager { this.itemListController.deinit() ;(this.itemListController as unknown) = undefined - this.noteTagsController.deinit() - ;(this.noteTagsController as unknown) = undefined + this.linkingController.deinit() + ;(this.linkingController as unknown) = undefined this.purchaseFlowController.deinit() ;(this.purchaseFlowController as unknown) = undefined diff --git a/packages/web/src/javascripts/Hooks/mergeRefs.ts b/packages/web/src/javascripts/Hooks/mergeRefs.ts new file mode 100644 index 000000000..d6b3b0c4a --- /dev/null +++ b/packages/web/src/javascripts/Hooks/mergeRefs.ts @@ -0,0 +1,37 @@ +/** +MIT License + +Copyright (c) 2020 Greg Bergé + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +import { MutableRefObject, LegacyRef, RefCallback } from 'react' + +export function mergeRefs(refs: Array | LegacyRef>): RefCallback { + return (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value) + } else if (ref != null) { + ;(ref as MutableRefObject).current = value + } + }) + } +} diff --git a/packages/web/src/stylesheets/_main.scss b/packages/web/src/stylesheets/_main.scss index eabe93e8e..631a93076 100644 --- a/packages/web/src/stylesheets/_main.scss +++ b/packages/web/src/stylesheets/_main.scss @@ -4,6 +4,7 @@ --z-index-resizer-overlay: 1000; --z-index-component-view: 1000; --z-index-panel-resizer: 1001; + --z-index-tooltip: 2000; --z-index-footer-bar: 2000; --z-index-footer-bar-item: 2000; --z-index-footer-bar-item-panel: 2000;