feat: item linking (#1779)

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

View File

@@ -68,7 +68,7 @@ export const useFiles = ({ note }: Props) => {
const filesService = application.getFilesService() const filesService = application.getFilesService()
const reloadAttachedFiles = useCallback(() => { const reloadAttachedFiles = useCallback(() => {
setAttachedFiles(application.items.getFilesForNote(note).sort(filesService.sortByName)) setAttachedFiles(application.items.getSortedFilesForItem(note).sort(filesService.sortByName))
}, [application.items, filesService.sortByName, note]) }, [application.items, filesService.sortByName, note])
const reloadAllFiles = useCallback(() => { const reloadAllFiles = useCallback(() => {

View File

@@ -139,7 +139,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
setAttachedFilesLength(0) setAttachedFilesLength(0)
return return
} }
setAttachedFilesLength(application.items.getFilesForNote(note).length) setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
}, [application, note]) }, [application, note])
useEffect(() => { useEffect(() => {
@@ -147,7 +147,7 @@ export const NoteSideMenu = React.memo((props: Props) => {
return return
} }
const removeFilesObserver = application.streamItems(ContentType.File, () => { const removeFilesObserver = application.streamItems(ContentType.File, () => {
setAttachedFilesLength(application.items.getFilesForNote(note).length) setAttachedFilesLength(application.items.getSortedFilesForItem(note).length)
}) })
return () => { return () => {
removeFilesObserver() removeFilesObserver()

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { ContenteReferenceType } from './ContenteReferenceType' import { ContentReferenceType } from './ContenteReferenceType'
export interface AnonymousReference { export interface AnonymousReference {
uuid: string uuid: string
content_type: ContentType content_type: ContentType
reference_type: ContenteReferenceType reference_type: ContentReferenceType
} }

View File

@@ -1,5 +1,7 @@
export enum ContenteReferenceType { export enum ContentReferenceType {
TagToParentTag = 'TagToParentTag', TagToParentTag = 'TagToParentTag',
FileToNote = 'FileToNote',
TagToFile = 'TagToFile', TagToFile = 'TagToFile',
FileToNote = 'FileToNote',
FileToFile = 'FileToFile',
NoteToNote = 'NoteToNote',
} }

View File

@@ -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
}

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { AnonymousReference } from './AnonymousReference' import { AnonymousReference } from './AnonymousReference'
import { ContenteReferenceType } from './ContenteReferenceType' import { ContentReferenceType } from './ContenteReferenceType'
export interface FileToNoteReference extends AnonymousReference { export interface FileToNoteReference extends AnonymousReference {
content_type: ContentType.Note content_type: ContentType.Note
reference_type: ContenteReferenceType.FileToNote reference_type: ContentReferenceType.FileToNote
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { ItemInterface } from '../Item/Interfaces/ItemInterface' import { ItemInterface } from '../Item/Interfaces/ItemInterface'
import { ContenteReferenceType } from './ContenteReferenceType' import { ContentReferenceType } from './ContenteReferenceType'
import { ContentReference } from './ContentReference' import { ContentReference } from './ContentReference'
import { LegacyAnonymousReference } from './LegacyAnonymousReference' import { LegacyAnonymousReference } from './LegacyAnonymousReference'
import { LegacyTagToNoteReference } from './LegacyTagToNoteReference' import { LegacyTagToNoteReference } from './LegacyTagToNoteReference'
@@ -26,5 +26,5 @@ export const isLegacyTagToNoteReference = (
} }
export const isTagToParentTagReference = (x: ContentReference): x is TagToParentTagReference => { export const isTagToParentTagReference = (x: ContentReference): x is TagToParentTagReference => {
return isReference(x) && x.reference_type === ContenteReferenceType.TagToParentTag return isReference(x) && x.reference_type === ContentReferenceType.TagToParentTag
} }

View File

@@ -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
}

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { AnonymousReference } from './AnonymousReference' import { AnonymousReference } from './AnonymousReference'
import { ContenteReferenceType } from './ContenteReferenceType' import { ContentReferenceType } from './ContenteReferenceType'
export interface TagToFileReference extends AnonymousReference { export interface TagToFileReference extends AnonymousReference {
content_type: ContentType.File content_type: ContentType.File
reference_type: ContenteReferenceType.TagToFile reference_type: ContentReferenceType.TagToFile
} }

View File

@@ -1,8 +1,8 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { AnonymousReference } from './AnonymousReference' import { AnonymousReference } from './AnonymousReference'
import { ContenteReferenceType } from './ContenteReferenceType' import { ContentReferenceType } from './ContenteReferenceType'
export interface TagToParentTagReference extends AnonymousReference { export interface TagToParentTagReference extends AnonymousReference {
content_type: ContentType.Tag content_type: ContentType.Tag
reference_type: ContenteReferenceType.TagToParentTag reference_type: ContentReferenceType.TagToParentTag
} }

View File

@@ -1,10 +1,10 @@
import { NoteContent } from './../../../Syncable/Note/NoteContent' import { NoteContent } from '../../../Syncable/Note/NoteContent'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { DecryptedItem, EncryptedItem } from '../../../Abstract/Item' import { DecryptedItem, EncryptedItem } from '../../../Abstract/Item'
import { DecryptedPayload, EncryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload' import { DecryptedPayload, EncryptedPayload, PayloadTimestampDefaults } from '../../../Abstract/Payload'
import { ItemCollection } from './ItemCollection' import { ItemCollection } from './ItemCollection'
import { FillItemContent } from '../../../Abstract/Content/ItemContent' import { FillItemContent } from '../../../Abstract/Content/ItemContent'
import { TagNotesIndex } from './TagNotesIndex' import { TagItemsIndex } from './TagItemsIndex'
import { ItemDelta } from '../../Index/ItemDelta' import { ItemDelta } from '../../Index/ItemDelta'
import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes' import { AnyItemInterface } from '../../../Abstract/Item/Interfaces/UnionTypes'
@@ -24,10 +24,10 @@ describe('tag notes index', () => {
return new EncryptedItem(payload) return new EncryptedItem(payload)
} }
const createDecryptedItem = (uuid?: string) => { const createDecryptedItem = (uuid?: string, content_type = ContentType.Note) => {
const payload = new DecryptedPayload({ const payload = new DecryptedPayload({
uuid: uuid || String(Math.random()), uuid: uuid || String(Math.random()),
content_type: ContentType.Note, content_type,
content: FillItemContent<NoteContent>({ content: FillItemContent<NoteContent>({
title: 'foo', 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', () => { it('should decrement count after decrypted note becomes errored', () => {
const collection = new ItemCollection() const collection = new ItemCollection()
const index = new TagNotesIndex(collection) const index = new TagItemsIndex(collection)
const decryptedItem = createDecryptedItem() const decryptedItem = createDecryptedItem()
collection.set(decryptedItem) collection.set(decryptedItem)
index.onChange(createChangeDelta(decryptedItem)) index.onChange(createChangeDelta(decryptedItem))
expect(index.allCountableNotesCount()).toEqual(1) expect(index.allCountableItemsCount()).toEqual(1)
const encryptedItem = createEncryptedItem(decryptedItem.uuid) const encryptedItem = createEncryptedItem(decryptedItem.uuid)
collection.set(encryptedItem) collection.set(encryptedItem)
index.onChange(createChangeDelta(encryptedItem)) index.onChange(createChangeDelta(encryptedItem))
expect(index.allCountableNotesCount()).toEqual(0) expect(index.allCountableItemsCount()).toEqual(0)
}) })
}) })

View File

@@ -7,22 +7,22 @@ import { ItemDelta } from '../../Index/ItemDelta'
import { isDecryptedItem, ItemInterface } from '../../../Abstract/Item' import { isDecryptedItem, ItemInterface } from '../../../Abstract/Item'
type AllNotesUuidSignifier = undefined type AllNotesUuidSignifier = undefined
export type TagNoteCountChangeObserver = (tagUuid: Uuid | AllNotesUuidSignifier) => void export type TagItemCountChangeObserver = (tagUuid: Uuid | AllNotesUuidSignifier) => void
export class TagNotesIndex implements SNIndex { export class TagItemsIndex implements SNIndex {
private tagToNotesMap: Partial<Record<Uuid, Set<Uuid>>> = {} private tagToItemsMap: Partial<Record<Uuid, Set<Uuid>>> = {}
private allCountableNotes = new Set<Uuid>() private allCountableItems = new Set<Uuid>()
constructor(private collection: ItemCollection, public observers: TagNoteCountChangeObserver[] = []) {} constructor(private collection: ItemCollection, public observers: TagItemCountChangeObserver[] = []) {}
private isNoteCountable = (note: ItemInterface) => { private isItemCountable = (item: ItemInterface) => {
if (isDecryptedItem(note)) { if (isDecryptedItem(item)) {
return !note.archived && !note.trashed return !item.archived && !item.trashed
} }
return false return false
} }
public addCountChangeObserver(observer: TagNoteCountChangeObserver): () => void { public addCountChangeObserver(observer: TagItemCountChangeObserver): () => void {
this.observers.push(observer) this.observers.push(observer)
const thislessEventObservers = this.observers const thislessEventObservers = this.observers
@@ -37,30 +37,32 @@ export class TagNotesIndex implements SNIndex {
} }
} }
public allCountableNotesCount(): number { public allCountableItemsCount(): number {
return this.allCountableNotes.size return this.allCountableItems.size
} }
public countableNotesForTag(tag: SNTag): number { public countableItemsForTag(tag: SNTag): number {
return this.tagToNotesMap[tag.uuid]?.size || 0 return this.tagToItemsMap[tag.uuid]?.size || 0
} }
public onChange(delta: ItemDelta): void { public onChange(delta: ItemDelta): void {
const notes = [...delta.changed, ...delta.inserted, ...delta.discarded].filter( const items = [...delta.changed, ...delta.inserted, ...delta.discarded].filter(
(i) => i.content_type === ContentType.Note, (i) => i.content_type === ContentType.Note || i.content_type === ContentType.File,
) )
const tags = [...delta.changed, ...delta.inserted].filter(isDecryptedItem).filter(isTag) const tags = [...delta.changed, ...delta.inserted].filter(isDecryptedItem).filter(isTag)
this.receiveNoteChanges(notes) this.receiveItemChanges(items)
this.receiveTagChanges(tags) this.receiveTagChanges(tags)
} }
private receiveTagChanges(tags: SNTag[]): void { private receiveTagChanges(tags: SNTag[]): void {
for (const tag of tags) { for (const tag of tags) {
const uuids = tag.noteReferences.map((ref) => ref.uuid) const uuids = tag.references
const countableUuids = uuids.filter((uuid) => this.allCountableNotes.has(uuid)) .filter((ref) => ref.content_type === ContentType.Note || ref.content_type === ContentType.File)
const previousSet = this.tagToNotesMap[tag.uuid] .map((ref) => ref.uuid)
this.tagToNotesMap[tag.uuid] = new Set(countableUuids) 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) { if (previousSet?.size !== countableUuids.length) {
this.notifyObservers(tag.uuid) this.notifyObservers(tag.uuid)
@@ -68,26 +70,26 @@ export class TagNotesIndex implements SNIndex {
} }
} }
private receiveNoteChanges(notes: ItemInterface[]): void { private receiveItemChanges(items: ItemInterface[]): void {
const previousAllCount = this.allCountableNotes.size const previousAllCount = this.allCountableItems.size
for (const note of notes) { for (const item of items) {
const isCountable = this.isNoteCountable(note) const isCountable = this.isItemCountable(item)
if (isCountable) { if (isCountable) {
this.allCountableNotes.add(note.uuid) this.allCountableItems.add(item.uuid)
} else { } 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) { for (const tagUuid of associatedTagUuids) {
const set = this.setForTag(tagUuid) const set = this.setForTag(tagUuid)
const previousCount = set.size const previousCount = set.size
if (isCountable) { if (isCountable) {
set.add(note.uuid) set.add(item.uuid)
} else { } else {
set.delete(note.uuid) set.delete(item.uuid)
} }
if (previousCount !== set.size) { if (previousCount !== set.size) {
this.notifyObservers(tagUuid) 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) this.notifyObservers(undefined)
} }
} }
private setForTag(uuid: Uuid): Set<Uuid> { private setForTag(uuid: Uuid): Set<Uuid> {
let set = this.tagToNotesMap[uuid] let set = this.tagToItemsMap[uuid]
if (!set) { if (!set) {
set = new Set() set = new Set()
this.tagToNotesMap[uuid] = set this.tagToItemsMap[uuid] = set
} }
return set return set
} }

View File

@@ -1,9 +1,10 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { SNNote } from '../Note/Note' import { SNNote } from '../Note/Note'
import { FileContent } from './File' import { FileContent, FileItem } from './File'
import { FileToNoteReference } from '../../Abstract/Reference/FileToNoteReference' 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 { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator'
import { FileToFileReference } from '../../Abstract/Reference/FileToFileReference'
export class FileMutator extends DecryptedItemMutator<FileContent> { export class FileMutator extends DecryptedItemMutator<FileContent> {
set name(newName: string) { set name(newName: string) {
@@ -16,7 +17,7 @@ export class FileMutator extends DecryptedItemMutator<FileContent> {
public addNote(note: SNNote): void { public addNote(note: SNNote): void {
const reference: FileToNoteReference = { const reference: FileToNoteReference = {
reference_type: ContenteReferenceType.FileToNote, reference_type: ContentReferenceType.FileToNote,
content_type: ContentType.Note, content_type: ContentType.Note,
uuid: note.uuid, uuid: note.uuid,
} }
@@ -30,4 +31,22 @@ export class FileMutator extends DecryptedItemMutator<FileContent> {
const references = this.immutableItem.references.filter((ref) => ref.uuid !== note.uuid) const references = this.immutableItem.references.filter((ref) => ref.uuid !== note.uuid)
this.mutableContent.references = references 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)
}
} }

View File

@@ -1,6 +1,10 @@
import { AppDataField } from '../../Abstract/Item/Types/AppDataField' import { AppDataField } from '../../Abstract/Item/Types/AppDataField'
import { NoteContent } from './NoteContent' import { NoteContent } from './NoteContent'
import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator' 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<NoteContent> { export class NoteMutator extends DecryptedItemMutator<NoteContent> {
set title(title: string) { set title(title: string) {
@@ -38,4 +42,22 @@ export class NoteMutator extends DecryptedItemMutator<NoteContent> {
this.mutableContent.spellcheck = !this.mutableContent.spellcheck 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)
}
} }

View File

@@ -1,5 +1,5 @@
import { ContentType } from '@standardnotes/common' 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 { createFile, createTag } from '../../Utilities/Test/SpecUtils'
import { SNTag } from './Tag' import { SNTag } from './Tag'
import { TagMutator } from './TagMutator' import { TagMutator } from './TagMutator'
@@ -16,7 +16,7 @@ describe('tag mutator', () => {
expect(result.content.references[0]).toEqual({ expect(result.content.references[0]).toEqual({
uuid: file.uuid, uuid: file.uuid,
content_type: ContentType.File, content_type: ContentType.File,
reference_type: ContenteReferenceType.TagToFile, reference_type: ContentReferenceType.TagToFile,
}) })
}) })

View File

@@ -4,7 +4,7 @@ import { FileItem } from '../File'
import { SNNote } from '../Note' import { SNNote } from '../Note'
import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' import { isTagToParentTagReference } from '../../Abstract/Reference/Functions'
import { TagToParentTagReference } from '../../Abstract/Reference/TagToParentTagReference' 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 { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator'
import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference' import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference'
@@ -21,7 +21,7 @@ export class TagMutator extends DecryptedItemMutator<TagContent> {
const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref)) const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref))
const reference: TagToParentTagReference = { const reference: TagToParentTagReference = {
reference_type: ContenteReferenceType.TagToParentTag, reference_type: ContentReferenceType.TagToParentTag,
content_type: ContentType.Tag, content_type: ContentType.Tag,
uuid: tag.uuid, uuid: tag.uuid,
} }
@@ -41,7 +41,7 @@ export class TagMutator extends DecryptedItemMutator<TagContent> {
} }
const reference: TagToFileReference = { const reference: TagToFileReference = {
reference_type: ContenteReferenceType.TagToFile, reference_type: ContentReferenceType.TagToFile,
content_type: ContentType.File, content_type: ContentType.File,
uuid: file.uuid, uuid: file.uuid,
} }

View File

@@ -38,7 +38,7 @@ export * from './Local/RootKey/RootKeyContent'
export * from './Local/RootKey/RootKeyInterface' export * from './Local/RootKey/RootKeyInterface'
export * from './Runtime/Collection/CollectionSort' export * from './Runtime/Collection/CollectionSort'
export * from './Runtime/Collection/Item/ItemCollection' 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/ImmutablePayloadCollection'
export * from './Runtime/Collection/Payload/PayloadCollection' export * from './Runtime/Collection/Payload/PayloadCollection'
export * from './Runtime/Deltas' export * from './Runtime/Deltas'

View File

@@ -4,7 +4,7 @@ import {
FileItem, FileItem,
SNTag, SNTag,
SmartView, SmartView,
TagNoteCountChangeObserver, TagItemCountChangeObserver,
DecryptedPayloadInterface, DecryptedPayloadInterface,
EncryptedItemInterface, EncryptedItemInterface,
DecryptedTransferPayload, DecryptedTransferPayload,
@@ -14,6 +14,7 @@ import {
SNTheme, SNTheme,
DisplayOptions, DisplayOptions,
ItemsKeyInterface, ItemsKeyInterface,
ItemContent,
} from '@standardnotes/models' } from '@standardnotes/models'
export interface ItemsClientInterface { export interface ItemsClientInterface {
@@ -23,12 +24,12 @@ export interface ItemsClientInterface {
disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem> disassociateFileWithNote(file: FileItem, note: SNNote): Promise<FileItem>
getFilesForNote(note: SNNote): FileItem[]
renameFile(file: FileItem, name: string): Promise<FileItem> renameFile(file: FileItem, name: string): Promise<FileItem>
addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]> addTagToNote(note: SNNote, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]>
addTagToFile(file: FileItem, tag: SNTag, addHierarchy: boolean): Promise<SNTag[]>
/** Creates an unmanaged, un-inserted item from a payload. */ /** Creates an unmanaged, un-inserted item from a payload. */
createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface createItemFromPayload(payload: DecryptedPayloadInterface): DecryptedItemInterface
@@ -54,7 +55,7 @@ export interface ItemsClientInterface {
notesMatchingSmartView(view: SmartView): SNNote[] notesMatchingSmartView(view: SmartView): SNNote[]
addNoteCountChangeObserver(observer: TagNoteCountChangeObserver): () => void addNoteCountChangeObserver(observer: TagItemCountChangeObserver): () => void
allCountableNotesCount(): number allCountableNotesCount(): number
@@ -72,6 +73,14 @@ export interface ItemsClientInterface {
itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[] itemsReferencingItem(itemToLookupUuidFor: DecryptedItemInterface, contentType?: ContentType): DecryptedItemInterface[]
linkNoteToNote(note: SNNote, otherNote: SNNote): Promise<SNNote>
linkFileToFile(file: FileItem, otherFile: FileItem): Promise<FileItem>
unlinkItem(
item: DecryptedItemInterface<ItemContent>,
itemToUnlink: DecryptedItemInterface<ItemContent>,
): Promise<DecryptedItemInterface<ItemContent>>
/** /**
* Finds tags with title or component starting with a search query and (optionally) not associated with a note * 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 * @param searchQuery - The query string to match
@@ -101,10 +110,14 @@ export interface ItemsClientInterface {
/** /**
* Get tags for a note sorted in natural order * Get tags for a note sorted in natural order
* @param note - The note whose tags will be returned * @param item - The item whose tags will be returned
* @returns Array containing tags associated with a note * @returns Array containing tags associated with an item
*/ */
getSortedTagsForNote(note: SNNote): SNTag[] getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): SNTag[]
getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): FileItem[]
getSortedLinkedNotesForItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
getSortedNotesLinkingToItem(item: DecryptedItemInterface<ItemContent>): SNNote[]
isSmartViewTitle(title: string): boolean isSmartViewTitle(title: string): boolean
@@ -137,4 +150,12 @@ export interface ItemsClientInterface {
* @returns Whether the item is a template (unmanaged) * @returns Whether the item is a template (unmanaged)
*/ */
isTemplateItem(item: DecryptedItemInterface): boolean 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'
} }

View File

@@ -405,41 +405,6 @@ describe('itemManager', () => {
const notes = itemManager.getDisplayableNotes() const notes = itemManager.getDisplayableNotes()
expect(notes).toHaveLength(1) 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', () => { describe('template items', () => {
@@ -703,47 +668,6 @@ describe('itemManager', () => {
}) })
describe('files', () => { 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 () => { it('should correctly rename file to filename that has extension', async () => {
itemManager = createService() itemManager = createService()
const file = createFile('initialName.ext') const file = createFile('initialName.ext')
@@ -774,4 +698,231 @@ describe('itemManager', () => {
expect(renamedFile.name).toBe('anotherName') 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)
})
})
}) })

View File

@@ -9,7 +9,7 @@ import * as Services from '@standardnotes/services'
import { PayloadManagerChangeData } from '../Payloads' import { PayloadManagerChangeData } from '../Payloads'
import { DiagnosticInfo, ItemsClientInterface } from '@standardnotes/services' import { DiagnosticInfo, ItemsClientInterface } from '@standardnotes/services'
import { ApplicationDisplayOptions } from '@Lib/Application/Options/OptionalOptions' import { ApplicationDisplayOptions } from '@Lib/Application/Options/OptionalOptions'
import { CollectionSort } from '@standardnotes/models' import { CollectionSort, DecryptedItemInterface, ItemContent } from '@standardnotes/models'
type ItemsChangeObserver<I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface> = { type ItemsChangeObserver<I extends Models.DecryptedItemInterface = Models.DecryptedItemInterface> = {
contentType: ContentType[] contentType: ContentType[]
@@ -32,7 +32,7 @@ export class ItemManager
private observers: ItemsChangeObserver[] = [] private observers: ItemsChangeObserver[] = []
private collection!: Models.ItemCollection private collection!: Models.ItemCollection
private systemSmartViews: Models.SmartView[] private systemSmartViews: Models.SmartView[]
private tagNotesIndex!: Models.TagNotesIndex private tagItemsIndex!: Models.TagItemsIndex
private navigationDisplayController!: Models.ItemDisplayController<Models.SNNote | Models.FileItem> private navigationDisplayController!: Models.ItemDisplayController<Models.SNNote | Models.FileItem>
private tagDisplayController!: Models.ItemDisplayController<Models.SNTag> private tagDisplayController!: Models.ItemDisplayController<Models.SNTag>
@@ -96,7 +96,7 @@ export class ItemManager
sortDirection: 'asc', 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<Models.DisplayItem>[] { private get allDisplayControllers(): Models.ItemDisplayController<Models.DisplayItem>[] {
@@ -219,7 +219,7 @@ export class ItemManager
;(this.unsubChangeObserver as unknown) = undefined ;(this.unsubChangeObserver as unknown) = undefined
;(this.payloadManager as unknown) = undefined ;(this.payloadManager as unknown) = undefined
;(this.collection as unknown) = undefined ;(this.collection as unknown) = undefined
;(this.tagNotesIndex as unknown) = undefined ;(this.tagItemsIndex as unknown) = undefined
;(this.tagDisplayController as unknown) = undefined ;(this.tagDisplayController as unknown) = undefined
;(this.navigationDisplayController as unknown) = undefined ;(this.navigationDisplayController as unknown) = undefined
;(this.itemsKeyDisplayController as unknown) = undefined ;(this.itemsKeyDisplayController as unknown) = undefined
@@ -284,23 +284,23 @@ export class ItemManager
return TagsToFoldersMigrationApplicator.isApplicableToCurrentData(this) return TagsToFoldersMigrationApplicator.isApplicableToCurrentData(this)
} }
public addNoteCountChangeObserver(observer: Models.TagNoteCountChangeObserver): () => void { public addNoteCountChangeObserver(observer: Models.TagItemCountChangeObserver): () => void {
return this.tagNotesIndex.addCountChangeObserver(observer) return this.tagItemsIndex.addCountChangeObserver(observer)
} }
public allCountableNotesCount(): number { public allCountableNotesCount(): number {
return this.tagNotesIndex.allCountableNotesCount() return this.tagItemsIndex.allCountableItemsCount()
} }
public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number { public countableNotesForTag(tag: Models.SNTag | Models.SmartView): number {
if (tag instanceof Models.SmartView) { if (tag instanceof Models.SmartView) {
if (tag.uuid === Models.SystemViewId.AllNotes) { 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 { public getNoteCount(): number {
@@ -406,7 +406,7 @@ export class ItemManager
} }
this.collection.onChange(delta) this.collection.onChange(delta)
this.tagNotesIndex.onChange(delta) this.tagItemsIndex.onChange(delta)
const affectedContentTypesArray = Array.from(affectedContentTypes.values()) const affectedContentTypesArray = Array.from(affectedContentTypes.values())
for (const controller of this.allDisplayControllers) { 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<Models.SNTag[]> {
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<Models.SNTag>
}),
)
}
public async linkNoteToNote(note: Models.SNNote, otherNote: Models.SNNote): Promise<Models.SNNote> {
return this.changeItem<Models.NoteMutator, Models.SNNote>(note, (mutator) => {
mutator.addNote(otherNote)
})
}
public async linkFileToFile(file: Models.FileItem, otherFile: Models.FileItem): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.addFile(otherFile)
})
}
public async unlinkItem(
item: DecryptedItemInterface<ItemContent>,
itemToUnlink: DecryptedItemInterface<ItemContent>,
) {
return this.changeItem(item, (mutator) => {
mutator.removeItemAsRelationship(itemToUnlink)
})
}
/** /**
* Get tags for a note sorted in natural order * Get tags for a note sorted in natural order
* @param note - The note whose tags will be returned * @param item - The item whose tags will be returned
* @returns Array containing tags associated with a note * @returns Array containing tags associated with an item
*/ */
public getSortedTagsForNote(note: Models.SNNote): Models.SNTag[] { public getSortedTagsForItem(item: DecryptedItemInterface<ItemContent>): Models.SNTag[] {
return naturalSort( return naturalSort(
this.itemsReferencingItem(note).filter((ref) => { this.itemsReferencingItem(item).filter((ref) => {
return ref?.content_type === ContentType.Tag return ref?.content_type === ContentType.Tag
}) as Models.SNTag[], }) as Models.SNTag[],
'title', 'title',
) )
} }
public getSortedFilesForItem(item: DecryptedItemInterface<ItemContent>): 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<ItemContent>): 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.ItemContent>): 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<Models.SNTag> { public async createTag(title: string, parentItemToLookupUuidFor?: Models.SNTag): Promise<Models.SNTag> {
const newTag = await this.createItem<Models.SNTag>( const newTag = await this.createItem<Models.SNTag>(
ContentType.Tag, 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<Models.FileItem> { public renameFile(file: Models.FileItem, name: string): Promise<Models.FileItem> {
return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => { return this.changeItem<Models.FileMutator, Models.FileItem>(file, (mutator) => {
mutator.name = name mutator.name = name
@@ -1353,6 +1424,23 @@ export class ItemManager
return this.findAnyItems(uuids) as (Models.DecryptedItemInterface | Models.DeletedItemInterface)[] 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<Models.ItemContent>,
itemTwo: Models.DecryptedItemInterface<Models.ItemContent>,
): '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<DiagnosticInfo | undefined> { override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({ return Promise.resolve({
items: { items: {

View File

@@ -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).lengthOf(tags.length)
expect(results[0].title).to.equal(tags[1].title) expect(results[0].title).to.equal(tags[1].title)

View File

@@ -77,8 +77,8 @@ describe('tags as folders', () => {
await this.application.items.addTagToNote(note2, tags.another, true) await this.application.items.addTagToNote(note2, tags.another, true)
// ## The note has been added to other tags // ## The note has been added to other tags
const note1Tags = await this.application.items.getSortedTagsForNote(note1) const note1Tags = await this.application.items.getSortedTagsForItem(note1)
const note2Tags = await this.application.items.getSortedTagsForNote(note2) const note2Tags = await this.application.items.getSortedTagsForItem(note2)
expect(note1Tags.length).to.equal(3) expect(note1Tags.length).to.equal(3)
expect(note2Tags.length).to.equal(1) expect(note2Tags.length).to.equal(1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ import FileOptionsPanel from '@/Components/FileContextMenu/FileOptionsPanel'
import FilePreview from '@/Components/FilePreview/FilePreview' import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileViewProps } from './FileViewProps' import { FileViewProps } from './FileViewProps'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton' import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel' import FilePreviewInfoPanel from '../FilePreview/FilePreviewInfoPanel'
@@ -63,6 +65,10 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<LinkedItemsButton
filesController={viewControllerManager.filesController}
linkingController={viewControllerManager.linkingController}
/>
<button <button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast" className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
title="File information panel" title="File information panel"
@@ -87,6 +93,7 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
/> />
</div> </div>
</div> </div>
<LinkedItemBubblesContainer linkingController={viewControllerManager.linkingController} />
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-grow flex-col"> <div className="flex min-h-0 flex-grow flex-col">

View File

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

View File

@@ -0,0 +1,98 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FilesController } from '@/Controllers/FilesController'
import { FileItem } from '@standardnotes/snjs'
import { useState } from 'react'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '../Icon/Icon'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import Switch from '../Switch/Switch'
type Props = {
file: FileItem
closeMenu: () => void
handleFileAction: FilesController['handleFileAction']
setIsRenamingFile: (set: boolean) => void
}
const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamingFile }: Props) => {
const [isFileProtected, setIsFileProtected] = useState(file.protected)
return (
<>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
void handleFileAction({
type: PopoverFileItemActionType.PreviewFile,
payload: {
file,
otherFiles: [],
},
})
closeMenu()
}}
>
<Icon type="file" className="mr-2 text-neutral" />
Preview file
</button>
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.ToggleFileProtection,
payload: { file },
callback: (isProtected: boolean) => {
setIsFileProtected(isProtected)
},
}).catch(console.error)
}}
>
<span className="flex items-center">
<Icon type="password" className="mr-2 text-neutral" />
Password protection
</span>
<Switch className="pointer-events-none px-0" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={isFileProtected} />
</button>
<HorizontalSeparator classes="my-1" />
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DownloadFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="download" className="mr-2 text-neutral" />
Download
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
setIsRenamingFile(true)
closeMenu()
}}
>
<Icon type="pencil" className="mr-2 text-neutral" />
Rename
</button>
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => {
handleFileAction({
type: PopoverFileItemActionType.DeleteFile,
payload: { file },
}).catch(console.error)
closeMenu()
}}
>
<Icon type="trash" className="mr-2 text-danger" />
<span className="text-danger">Delete permanently</span>
</button>
</>
)
}
export default LinkedFileMenuOptions

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { observer } from 'mobx-react-lite'
import { useRef, useCallback } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import LinkedItemsPanel from './LinkedItemsPanel'
type Props = {
linkingController: LinkingController
onClickPreprocessing?: () => Promise<void>
filesController: FilesController
}
const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing }: Props) => {
const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const buttonRef = useRef<HTMLButtonElement>(null)
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isLinkingPanelOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsLinkingPanelOpen(willMenuOpen)
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen])
return (
<>
<StyledTooltip label="Linked items panel">
<button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
aria-label="Linked items panel"
onClick={toggleMenu}
ref={buttonRef}
>
<Icon type="link" />
</button>
</StyledTooltip>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isLinkingPanelOpen} className="pb-2">
<LinkedItemsPanel
isOpen={isLinkingPanelOpen}
linkingController={linkingController}
filesController={filesController}
/>
</Popover>
</>
)
}
export default observer(LinkedItemsButton)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,15 +4,15 @@ import { useRef } from 'react'
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { NotesController } from '@/Controllers/NotesController' import { NotesController } from '@/Controllers/NotesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController' import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import { LinkingController } from '@/Controllers/LinkingController'
type Props = { type Props = {
application: WebApplication application: WebApplication
navigationController: NavigationController navigationController: NavigationController
notesController: NotesController notesController: NotesController
noteTagsController: NoteTagsController linkingController: LinkingController
historyModalController: HistoryModalController historyModalController: HistoryModalController
} }
@@ -20,7 +20,7 @@ const NotesContextMenu = ({
application, application,
navigationController, navigationController,
notesController, notesController,
noteTagsController, linkingController,
historyModalController, historyModalController,
}: Props) => { }: Props) => {
const { contextMenuOpen, contextMenuClickLocation, setContextMenuOpen } = notesController const { contextMenuOpen, contextMenuClickLocation, setContextMenuOpen } = notesController
@@ -46,7 +46,7 @@ const NotesContextMenu = ({
application={application} application={application}
navigationController={navigationController} navigationController={navigationController}
notesController={notesController} notesController={notesController}
noteTagsController={noteTagsController} linkingController={linkingController}
historyModalController={historyModalController} historyModalController={historyModalController}
closeMenu={closeMenu} closeMenu={closeMenu}
/> />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,4 +10,5 @@ export const ElementIds = {
NoteTitleEditor: 'note-title-editor', NoteTitleEditor: 'note-title-editor',
RootId: 'app-group-root', RootId: 'app-group-root',
NoteStatusTooltip: 'note-status-tooltip', NoteStatusTooltip: 'note-status-tooltip',
ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
} as const } as const

View File

@@ -99,7 +99,7 @@ export class FilesController extends AbstractViewController {
reloadAttachedFiles = () => { reloadAttachedFiles = () => {
const note = this.notesController.firstSelectedNote const note = this.notesController.firstSelectedNote
if (note) { if (note) {
this.attachedFiles = this.application.items.getFilesForNote(note) this.attachedFiles = this.application.items.getSortedFilesForItem(note)
} }
} }

View File

@@ -29,10 +29,10 @@ import { CrossControllerEvent } from '../CrossControllerEvent'
import { SearchOptionsController } from '../SearchOptionsController' import { SearchOptionsController } from '../SearchOptionsController'
import { SelectedItemsController } from '../SelectedItemsController' import { SelectedItemsController } from '../SelectedItemsController'
import { NotesController } from '../NotesController' import { NotesController } from '../NotesController'
import { NoteTagsController } from '../NoteTagsController'
import { formatDateAndTimeForNote } from '@/Utils/DateUtils' import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { PrefDefaults } from '@/Constants/PrefDefaults' import { PrefDefaults } from '@/Constants/PrefDefaults'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { LinkingController } from '../LinkingController'
const MinNoteCellHeight = 51.0 const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20 const DefaultListNumNotes = 20
@@ -85,7 +85,6 @@ export class ItemListController extends AbstractViewController implements Intern
;(this.searchOptionsController as unknown) = undefined ;(this.searchOptionsController as unknown) = undefined
;(this.selectionController as unknown) = undefined ;(this.selectionController as unknown) = undefined
;(this.notesController as unknown) = undefined ;(this.notesController as unknown) = undefined
;(this.noteTagsController as unknown) = undefined
;(window.onresize as unknown) = undefined ;(window.onresize as unknown) = undefined
destroyAllObjectProperties(this) destroyAllObjectProperties(this)
@@ -97,7 +96,7 @@ export class ItemListController extends AbstractViewController implements Intern
private searchOptionsController: SearchOptionsController, private searchOptionsController: SearchOptionsController,
private selectionController: SelectedItemsController, private selectionController: SelectedItemsController,
private notesController: NotesController, private notesController: NotesController,
private noteTagsController: NoteTagsController, private linkingController: LinkingController,
eventBus: InternalEventBus, eventBus: InternalEventBus,
) { ) {
super(application, eventBus) super(application, eventBus)
@@ -228,13 +227,12 @@ export class ItemListController extends AbstractViewController implements Intern
return this.application.itemControllerGroup.activeItemViewController return this.application.itemControllerGroup.activeItemViewController
} }
public get activeControllerNote(): SNNote | undefined { public get activeControllerItem() {
const activeController = this.getActiveItemController() return this.getActiveItemController()?.item
return activeController instanceof NoteViewController ? activeController.item : undefined
} }
async openNote(uuid: string): Promise<void> { async openNote(uuid: string): Promise<void> {
if (this.activeControllerNote?.uuid === uuid) { if (this.activeControllerItem?.uuid === uuid) {
return return
} }
@@ -246,7 +244,7 @@ export class ItemListController extends AbstractViewController implements Intern
await this.application.itemControllerGroup.createItemController(note) await this.application.itemControllerGroup.createItemController(note)
this.noteTagsController.reloadTagsForCurrentNote() this.linkingController.reloadAllLinks()
await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged) await this.publishEventSync(CrossControllerEvent.ActiveEditorChanged)
} }
@@ -263,6 +261,8 @@ export class ItemListController extends AbstractViewController implements Intern
} }
await this.application.itemControllerGroup.createItemController(file) await this.application.itemControllerGroup.createItemController(file)
this.linkingController.reloadAllLinks()
} }
setCompletedFullSync = (completed: boolean) => { setCompletedFullSync = (completed: boolean) => {
@@ -545,7 +545,7 @@ export class ItemListController extends AbstractViewController implements Intern
await this.createNewNoteController(title) await this.createNewNoteController(title)
this.noteTagsController.reloadTagsForCurrentNote() this.linkingController.reloadAllLinks()
} }
createPlaceholderNote = () => { createPlaceholderNote = () => {

View File

@@ -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<ItemContent>
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<AppPaneId | undefined> => {
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<ItemContent>,
itemB: DecryptedItemInterface<ItemContent>,
) => {
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,
}
}
}

View File

@@ -394,10 +394,18 @@ export class NavigationController extends AbstractViewController {
await this.setSelectedTag(this.homeNavigationView) await this.setSelectedTag(this.homeNavigationView)
} }
public async selectFilesView() {
await this.setSelectedTag(this.filesNavigationView)
}
get homeNavigationView(): SmartView { get homeNavigationView(): SmartView {
return this.smartViews[0] return this.smartViews[0]
} }
get filesNavigationView(): SmartView {
return this.smartViews.find((view) => view.uuid === SystemViewId.Files) as SmartView
}
private setSelectedTagInstance(tag: AnyTag | undefined): void { private setSelectedTagInstance(tag: AnyTag | undefined): void {
runInAction(() => (this.selected_ = tag)) runInAction(() => (this.selected_ = tag))
} }

View File

@@ -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<void> {
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<void> {
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<void> {
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)
}
}

View File

@@ -8,7 +8,6 @@ import { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController' import { AbstractViewController } from './Abstract/AbstractViewController'
import { SelectedItemsController } from './SelectedItemsController' import { SelectedItemsController } from './SelectedItemsController'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController } from './ItemList/ItemListController'
import { NoteTagsController } from './NoteTagsController'
import { NavigationController } from './Navigation/NavigationController' import { NavigationController } from './Navigation/NavigationController'
export class NotesController extends AbstractViewController { export class NotesController extends AbstractViewController {
@@ -27,7 +26,6 @@ export class NotesController extends AbstractViewController {
super.deinit() super.deinit()
;(this.lastSelectedNote as unknown) = undefined ;(this.lastSelectedNote as unknown) = undefined
;(this.selectionController as unknown) = undefined ;(this.selectionController as unknown) = undefined
;(this.noteTagsController as unknown) = undefined
;(this.navigationController as unknown) = undefined ;(this.navigationController as unknown) = undefined
;(this.itemListController as unknown) = undefined ;(this.itemListController as unknown) = undefined
@@ -37,7 +35,6 @@ export class NotesController extends AbstractViewController {
constructor( constructor(
application: WebApplication, application: WebApplication,
private selectionController: SelectedItemsController, private selectionController: SelectedItemsController,
private noteTagsController: NoteTagsController,
private navigationController: NavigationController, private navigationController: NavigationController,
eventBus: InternalEventBus, eventBus: InternalEventBus,
) { ) {

View File

@@ -34,6 +34,7 @@ export class SelectedItemsController extends AbstractViewController {
selectedItemsCount: computed, selectedItemsCount: computed,
selectedFiles: computed, selectedFiles: computed,
selectedFilesCount: computed, selectedFilesCount: computed,
firstSelectedItem: computed,
selectItem: action, selectItem: action,
setSelectedItems: action, setSelectedItems: action,
@@ -79,6 +80,10 @@ export class SelectedItemsController extends AbstractViewController {
return this.selectedFiles.length return this.selectedFiles.length
} }
get firstSelectedItem() {
return this.getSelectedItems()[0]
}
getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => { getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => {
return Object.values(this.selectedItems).filter((item) => { return Object.values(this.selectedItems).filter((item) => {
return !contentType ? true : item.content_type === contentType return !contentType ? true : item.content_type === contentType

View File

@@ -17,7 +17,6 @@ import { FeaturesController } from './FeaturesController'
import { FilesController } from './FilesController' import { FilesController } from './FilesController'
import { NotesController } from './NotesController' import { NotesController } from './NotesController'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController } from './ItemList/ItemListController'
import { NoteTagsController } from './NoteTagsController'
import { NoAccountWarningController } from './NoAccountWarningController' import { NoAccountWarningController } from './NoAccountWarningController'
import { PreferencesController } from './PreferencesController' import { PreferencesController } from './PreferencesController'
import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController' import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController'
@@ -31,6 +30,7 @@ import { SelectedItemsController } from './SelectedItemsController'
import { HistoryModalController } from './NoteHistory/HistoryModalController' import { HistoryModalController } from './NoteHistory/HistoryModalController'
import { PreferenceId } from '@/Components/Preferences/PreferencesMenu' import { PreferenceId } from '@/Components/Preferences/PreferencesMenu'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { LinkingController } from './LinkingController'
export class ViewControllerManager { export class ViewControllerManager {
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
@@ -47,7 +47,6 @@ export class ViewControllerManager {
readonly noAccountWarningController: NoAccountWarningController readonly noAccountWarningController: NoAccountWarningController
readonly notesController: NotesController readonly notesController: NotesController
readonly itemListController: ItemListController readonly itemListController: ItemListController
readonly noteTagsController: NoteTagsController
readonly preferencesController = new PreferencesController() readonly preferencesController = new PreferencesController()
readonly purchaseFlowController: PurchaseFlowController readonly purchaseFlowController: PurchaseFlowController
readonly quickSettingsMenuController = new QuickSettingsController() readonly quickSettingsMenuController = new QuickSettingsController()
@@ -57,6 +56,7 @@ export class ViewControllerManager {
readonly navigationController: NavigationController readonly navigationController: NavigationController
readonly selectionController: SelectedItemsController readonly selectionController: SelectedItemsController
readonly historyModalController: HistoryModalController readonly historyModalController: HistoryModalController
readonly linkingController: LinkingController
public isSessionsModalVisible = false public isSessionsModalVisible = false
@@ -74,8 +74,6 @@ export class ViewControllerManager {
this.selectionController = new SelectedItemsController(application, this.eventBus) this.selectionController = new SelectedItemsController(application, this.eventBus)
this.noteTagsController = new NoteTagsController(application, this.eventBus)
this.featuresController = new FeaturesController(application, this.eventBus) this.featuresController = new FeaturesController(application, this.eventBus)
this.navigationController = new NavigationController(application, this.featuresController, this.eventBus) this.navigationController = new NavigationController(application, this.featuresController, this.eventBus)
@@ -83,25 +81,30 @@ export class ViewControllerManager {
this.notesController = new NotesController( this.notesController = new NotesController(
application, application,
this.selectionController, this.selectionController,
this.noteTagsController,
this.navigationController, this.navigationController,
this.eventBus, this.eventBus,
) )
this.searchOptionsController = new SearchOptionsController(application, this.eventBus) this.searchOptionsController = new SearchOptionsController(application, this.eventBus)
this.linkingController = new LinkingController(
application,
this.navigationController,
this.selectionController,
this.eventBus,
)
this.itemListController = new ItemListController( this.itemListController = new ItemListController(
application, application,
this.navigationController, this.navigationController,
this.searchOptionsController, this.searchOptionsController,
this.selectionController, this.selectionController,
this.notesController, this.notesController,
this.noteTagsController, this.linkingController,
this.eventBus, this.eventBus,
) )
this.notesController.setServicesPostConstruction(this.itemListController) this.notesController.setServicesPostConstruction(this.itemListController)
this.noteTagsController.setServicesPostConstruction(this.itemListController)
this.selectionController.setServicesPostConstruction(this.itemListController) this.selectionController.setServicesPostConstruction(this.itemListController)
this.noAccountWarningController = new NoAccountWarningController(application, this.eventBus) this.noAccountWarningController = new NoAccountWarningController(application, this.eventBus)
@@ -119,6 +122,12 @@ export class ViewControllerManager {
this.eventBus, this.eventBus,
) )
this.linkingController.setServicesPostConstruction(
this.itemListController,
this.filesController,
this.subscriptionController,
)
this.historyModalController = new HistoryModalController(this.application, this.eventBus) this.historyModalController = new HistoryModalController(this.application, this.eventBus)
this.addAppEventObserver() this.addAppEventObserver()
@@ -180,8 +189,8 @@ export class ViewControllerManager {
this.itemListController.deinit() this.itemListController.deinit()
;(this.itemListController as unknown) = undefined ;(this.itemListController as unknown) = undefined
this.noteTagsController.deinit() this.linkingController.deinit()
;(this.noteTagsController as unknown) = undefined ;(this.linkingController as unknown) = undefined
this.purchaseFlowController.deinit() this.purchaseFlowController.deinit()
;(this.purchaseFlowController as unknown) = undefined ;(this.purchaseFlowController as unknown) = undefined

View File

@@ -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<T = any>(refs: Array<MutableRefObject<T> | LegacyRef<T>>): RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value)
} else if (ref != null) {
;(ref as MutableRefObject<T | null>).current = value
}
})
}
}

View File

@@ -4,6 +4,7 @@
--z-index-resizer-overlay: 1000; --z-index-resizer-overlay: 1000;
--z-index-component-view: 1000; --z-index-component-view: 1000;
--z-index-panel-resizer: 1001; --z-index-panel-resizer: 1001;
--z-index-tooltip: 2000;
--z-index-footer-bar: 2000; --z-index-footer-bar: 2000;
--z-index-footer-bar-item: 2000; --z-index-footer-bar-item: 2000;
--z-index-footer-bar-item-panel: 2000; --z-index-footer-bar-item-panel: 2000;