feat: item linking (#1779)
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
ChangeEventHandler,
|
||||||
|
FocusEventHandler,
|
||||||
|
FormEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
|
||||||
|
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import LinkedItemSearchResults from './LinkedItemSearchResults'
|
||||||
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||||
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
|
import Menu from '../Menu/Menu'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
linkingController: LinkingController
|
||||||
|
focusPreviousItem: () => void
|
||||||
|
focusedId: string | undefined
|
||||||
|
setFocusedId: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemLinkAutocompleteInput = ({ linkingController, focusPreviousItem, focusedId, setFocusedId }: Props) => {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
getLinkedItemIcon,
|
||||||
|
getSearchResults,
|
||||||
|
linkItemToSelectedItem,
|
||||||
|
createAndAddNewTag,
|
||||||
|
isEntitledToNoteLinking,
|
||||||
|
} = linkingController
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const { unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
|
||||||
|
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false)
|
||||||
|
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)
|
||||||
|
|
||||||
|
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
|
||||||
|
setDropdownVisible(visible)
|
||||||
|
setSearchQuery('')
|
||||||
|
})
|
||||||
|
|
||||||
|
const showDropdown = () => {
|
||||||
|
const { clientHeight } = document.documentElement
|
||||||
|
const inputRect = inputRef.current?.getBoundingClientRect()
|
||||||
|
if (inputRect) {
|
||||||
|
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
|
||||||
|
setDropdownVisible(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
|
setSearchQuery(event.currentTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onFormSubmit: FormEventHandler = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (searchQuery !== '') {
|
||||||
|
await createAndAddNewTag(searchQuery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (focusedId !== ElementIds.ItemLinkAutocompleteInput) {
|
||||||
|
setFocusedId(ElementIds.ItemLinkAutocompleteInput)
|
||||||
|
}
|
||||||
|
showDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBlur: FocusEventHandler = (event) => {
|
||||||
|
closeOnBlur(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown: KeyboardEventHandler = (event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case KeyboardKey.Left:
|
||||||
|
if (searchQuery.length === 0) {
|
||||||
|
focusPreviousItem()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case KeyboardKey.Down:
|
||||||
|
if (searchQuery.length > 0) {
|
||||||
|
searchResultsMenuRef.current?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedId === ElementIds.ItemLinkAutocompleteInput) {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [focusedId])
|
||||||
|
|
||||||
|
const areSearchResultsVisible = dropdownVisible && (unlinkedResults.length > 0 || shouldShowCreateTag)
|
||||||
|
|
||||||
|
const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => {
|
||||||
|
if (event.key === KeyboardKey.Escape) {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef}>
|
||||||
|
<form onSubmit={onFormSubmit}>
|
||||||
|
<Disclosure open={dropdownVisible} onChange={showDropdown}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className={`${tags.length > 0 ? 'w-80' : 'mr-10 w-70'} no-border h-7
|
||||||
|
bg-transparent text-xs text-text focus:border-b-2 focus:border-solid focus:border-info focus:shadow-none focus:outline-none`}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={onSearchQueryChange}
|
||||||
|
type="text"
|
||||||
|
placeholder="Link tags, notes, files..."
|
||||||
|
onBlur={onBlur}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
id={ElementIds.ItemLinkAutocompleteInput}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{areSearchResultsVisible && (
|
||||||
|
<DisclosurePanel
|
||||||
|
className={classNames(
|
||||||
|
tags.length > 0 ? 'w-80' : 'mr-10 w-70',
|
||||||
|
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
maxHeight: dropdownMaxHeight,
|
||||||
|
}}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
isOpen={areSearchResultsVisible}
|
||||||
|
a11yLabel="Unlinked items search results"
|
||||||
|
onKeyDown={handleMenuKeyDown}
|
||||||
|
ref={searchResultsMenuRef}
|
||||||
|
shouldAutoFocus={false}
|
||||||
|
>
|
||||||
|
<LinkedItemSearchResults
|
||||||
|
createAndAddNewTag={createAndAddNewTag}
|
||||||
|
getLinkedItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
linkItemToSelectedItem={linkItemToSelectedItem}
|
||||||
|
results={unlinkedResults}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
shouldShowCreateTag={shouldShowCreateTag}
|
||||||
|
onClickCallback={() => setSearchQuery('')}
|
||||||
|
isEntitledToNoteLinking={isEntitledToNoteLinking}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
</DisclosurePanel>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(ItemLinkAutocompleteInput)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||||
|
import Switch from '../Switch/Switch'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
file: FileItem
|
||||||
|
closeMenu: () => void
|
||||||
|
handleFileAction: FilesController['handleFileAction']
|
||||||
|
setIsRenamingFile: (set: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedFileMenuOptions = ({ file, closeMenu, handleFileAction, setIsRenamingFile }: Props) => {
|
||||||
|
const [isFileProtected, setIsFileProtected] = useState(file.protected)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||||
|
onClick={() => {
|
||||||
|
void handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.PreviewFile,
|
||||||
|
payload: {
|
||||||
|
file,
|
||||||
|
otherFiles: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="file" className="mr-2 text-neutral" />
|
||||||
|
Preview file
|
||||||
|
</button>
|
||||||
|
<HorizontalSeparator classes="my-1" />
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.ToggleFileProtection,
|
||||||
|
payload: { file },
|
||||||
|
callback: (isProtected: boolean) => {
|
||||||
|
setIsFileProtected(isProtected)
|
||||||
|
},
|
||||||
|
}).catch(console.error)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center">
|
||||||
|
<Icon type="password" className="mr-2 text-neutral" />
|
||||||
|
Password protection
|
||||||
|
</span>
|
||||||
|
<Switch className="pointer-events-none px-0" tabIndex={FOCUSABLE_BUT_NOT_TABBABLE} checked={isFileProtected} />
|
||||||
|
</button>
|
||||||
|
<HorizontalSeparator classes="my-1" />
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.DownloadFile,
|
||||||
|
payload: { file },
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="download" className="mr-2 text-neutral" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||||
|
onClick={() => {
|
||||||
|
setIsRenamingFile(true)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="pencil" className="mr-2 text-neutral" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||||
|
onClick={() => {
|
||||||
|
handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.DeleteFile,
|
||||||
|
payload: { file },
|
||||||
|
}).catch(console.error)
|
||||||
|
closeMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="trash" className="mr-2 text-danger" />
|
||||||
|
<span className="text-danger">Delete permanently</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LinkedFileMenuOptions
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { KeyboardEventHandler, MouseEventHandler, useEffect, useRef, useState } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
item: LinkableItem
|
||||||
|
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||||
|
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||||
|
activateItem: (item: LinkableItem) => Promise<void>
|
||||||
|
unlinkItem: (item: LinkableItem) => void
|
||||||
|
focusPreviousItem: () => void
|
||||||
|
focusNextItem: () => void
|
||||||
|
focusedId: string | undefined
|
||||||
|
setFocusedId: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedItemBubble = ({
|
||||||
|
item,
|
||||||
|
getItemIcon,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
activateItem,
|
||||||
|
unlinkItem,
|
||||||
|
focusPreviousItem,
|
||||||
|
focusNextItem,
|
||||||
|
focusedId,
|
||||||
|
setFocusedId,
|
||||||
|
}: Props) => {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [showUnlinkButton, setShowUnlinkButton] = useState(false)
|
||||||
|
const unlinkButtonRef = useRef<HTMLAnchorElement | null>(null)
|
||||||
|
|
||||||
|
const [wasClicked, setWasClicked] = useState(false)
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (focusedId !== item.uuid) {
|
||||||
|
setFocusedId(item.uuid)
|
||||||
|
}
|
||||||
|
setShowUnlinkButton(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
setShowUnlinkButton(false)
|
||||||
|
setWasClicked(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClick: MouseEventHandler = (event) => {
|
||||||
|
if (wasClicked && event.target !== unlinkButtonRef.current) {
|
||||||
|
setWasClicked(false)
|
||||||
|
void activateItem(item)
|
||||||
|
} else {
|
||||||
|
setWasClicked(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUnlinkClick: MouseEventHandler = (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
unlinkItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown: KeyboardEventHandler = (event) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case KeyboardKey.Backspace: {
|
||||||
|
focusPreviousItem()
|
||||||
|
unlinkItem(item)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case KeyboardKey.Left:
|
||||||
|
focusPreviousItem()
|
||||||
|
break
|
||||||
|
case KeyboardKey.Right:
|
||||||
|
focusNextItem()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [icon, iconClassName] = getItemIcon(item)
|
||||||
|
const tagTitle = getTitleForLinkedTag(item)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item.uuid === focusedId) {
|
||||||
|
ref.current?.focus()
|
||||||
|
}
|
||||||
|
}, [focusedId, item.uuid])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className="flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-xs text-text hover:bg-contrast focus:bg-contrast"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onClick={onClick}
|
||||||
|
title={tagTitle ? tagTitle.longTitle : item.title}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" />
|
||||||
|
<span className="max-w-290px overflow-hidden overflow-ellipsis whitespace-nowrap">
|
||||||
|
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
|
||||||
|
{item.title}
|
||||||
|
</span>
|
||||||
|
{showUnlinkButton && (
|
||||||
|
<a
|
||||||
|
ref={unlinkButtonRef}
|
||||||
|
role="button"
|
||||||
|
className="ml-2 -mr-1 flex cursor-pointer border-0 bg-transparent p-0"
|
||||||
|
onClick={onUnlinkClick}
|
||||||
|
>
|
||||||
|
<Icon type="close" className="text-neutral hover:text-info" size="small" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemBubble)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
|
||||||
|
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import LinkedItemBubble from './LinkedItemBubble'
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
|
||||||
|
import { ElementIds } from '@/Constants/ElementIDs'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
linkingController: LinkingController
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedItemBubblesContainer = ({ linkingController }: Props) => {
|
||||||
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
const {
|
||||||
|
allLinkedItems,
|
||||||
|
notesLinkingToItem,
|
||||||
|
unlinkItemFromSelectedItem: unlinkItem,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
getLinkedItemIcon: getItemIcon,
|
||||||
|
activateItem,
|
||||||
|
} = linkingController
|
||||||
|
|
||||||
|
const [focusedId, setFocusedId] = useState<string>()
|
||||||
|
const focusableIds = allLinkedItems.map((item) => item.uuid).concat([ElementIds.ItemLinkAutocompleteInput])
|
||||||
|
|
||||||
|
const focusPreviousItem = useCallback(() => {
|
||||||
|
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
|
||||||
|
const previousIndex = currentFocusedIndex - 1
|
||||||
|
|
||||||
|
if (previousIndex > -1) {
|
||||||
|
setFocusedId(focusableIds[previousIndex])
|
||||||
|
}
|
||||||
|
}, [focusableIds, focusedId])
|
||||||
|
|
||||||
|
const focusNextItem = useCallback(() => {
|
||||||
|
const currentFocusedIndex = focusableIds.findIndex((id) => id === focusedId)
|
||||||
|
const nextIndex = currentFocusedIndex + 1
|
||||||
|
|
||||||
|
if (nextIndex < focusableIds.length) {
|
||||||
|
setFocusedId(focusableIds[nextIndex])
|
||||||
|
}
|
||||||
|
}, [focusableIds, focusedId])
|
||||||
|
|
||||||
|
const activateItemAndTogglePane = useCallback(
|
||||||
|
async (item: LinkableItem) => {
|
||||||
|
const paneId = await activateItem(item)
|
||||||
|
if (paneId) {
|
||||||
|
toggleAppPane(paneId)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activateItem, toggleAppPane],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'hidden min-w-80 max-w-full flex-wrap items-center gap-2 bg-transparent md:-mr-2 md:flex',
|
||||||
|
allLinkedItems.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{allLinkedItems.concat(notesLinkingToItem).map((item) => (
|
||||||
|
<LinkedItemBubble
|
||||||
|
item={item}
|
||||||
|
key={item.uuid}
|
||||||
|
getItemIcon={getItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
activateItem={activateItemAndTogglePane}
|
||||||
|
unlinkItem={unlinkItem}
|
||||||
|
focusPreviousItem={focusPreviousItem}
|
||||||
|
focusNextItem={focusNextItem}
|
||||||
|
focusedId={focusedId}
|
||||||
|
setFocusedId={setFocusedId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<ItemLinkAutocompleteInput
|
||||||
|
focusedId={focusedId}
|
||||||
|
linkingController={linkingController}
|
||||||
|
focusPreviousItem={focusPreviousItem}
|
||||||
|
setFocusedId={setFocusedId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemBubblesContainer)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { splitQueryInString } from '@/Utils'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
|
||||||
|
const LinkedItemMeta = ({
|
||||||
|
item,
|
||||||
|
getItemIcon,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
searchQuery,
|
||||||
|
}: {
|
||||||
|
item: LinkableItem
|
||||||
|
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||||
|
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||||
|
searchQuery?: string
|
||||||
|
}) => {
|
||||||
|
const [icon, className] = getItemIcon(item)
|
||||||
|
const tagTitle = getTitleForLinkedTag(item)
|
||||||
|
const title = item.title ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
|
||||||
|
<div className="min-w-0 flex-grow break-words text-left text-sm">
|
||||||
|
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
|
||||||
|
{searchQuery
|
||||||
|
? splitQueryInString(title, searchQuery).map((substring, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`${
|
||||||
|
substring.toLowerCase() === searchQuery.toLowerCase()
|
||||||
|
? 'whitespace-pre-wrap font-bold'
|
||||||
|
: 'whitespace-pre-wrap '
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{substring}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: title}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemMeta)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { SNNote } from '@standardnotes/snjs'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||||
|
import LinkedItemMeta from './LinkedItemMeta'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
createAndAddNewTag: LinkingController['createAndAddNewTag']
|
||||||
|
getLinkedItemIcon: LinkingController['getLinkedItemIcon']
|
||||||
|
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||||
|
linkItemToSelectedItem: LinkingController['linkItemToSelectedItem']
|
||||||
|
results: LinkableItem[]
|
||||||
|
searchQuery: string
|
||||||
|
shouldShowCreateTag: boolean
|
||||||
|
onClickCallback?: () => void
|
||||||
|
isEntitledToNoteLinking: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedItemSearchResults = ({
|
||||||
|
createAndAddNewTag,
|
||||||
|
getLinkedItemIcon,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
linkItemToSelectedItem,
|
||||||
|
results,
|
||||||
|
searchQuery,
|
||||||
|
shouldShowCreateTag,
|
||||||
|
onClickCallback,
|
||||||
|
isEntitledToNoteLinking,
|
||||||
|
}: Props) => {
|
||||||
|
const premiumModal = usePremiumModal()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-1">
|
||||||
|
{results.map((result) => {
|
||||||
|
const cannotLinkItem = !isEntitledToNoteLinking && result instanceof SNNote
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={result.uuid}
|
||||||
|
className="flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
if (cannotLinkItem) {
|
||||||
|
premiumModal.activate('Note linking')
|
||||||
|
} else {
|
||||||
|
linkItemToSelectedItem(result)
|
||||||
|
onClickCallback?.()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinkedItemMeta
|
||||||
|
item={result}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
{cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{shouldShowCreateTag && (
|
||||||
|
<button
|
||||||
|
className="group flex w-full items-center gap-2 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground focus:bg-info-backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
createAndAddNewTag(searchQuery)
|
||||||
|
onClickCallback?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 align-middle">Create & add tag</span>{' '}
|
||||||
|
<span className="inline-flex min-w-0 items-center gap-1 rounded bg-contrast py-1 pl-1 pr-2 align-middle text-xs text-text group-hover:bg-info group-hover:text-info-contrast">
|
||||||
|
<Icon type="hashtag" className="flex-shrink-0 text-info group-hover:text-info-contrast" size="small" />
|
||||||
|
<span className="min-w-0 overflow-hidden text-ellipsis">{searchQuery}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemSearchResults)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useRef, useCallback } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import Popover from '../Popover/Popover'
|
||||||
|
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
||||||
|
import LinkedItemsPanel from './LinkedItemsPanel'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
linkingController: LinkingController
|
||||||
|
onClickPreprocessing?: () => Promise<void>
|
||||||
|
filesController: FilesController
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedItemsButton = ({ linkingController, filesController, onClickPreprocessing }: Props) => {
|
||||||
|
const { isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const toggleMenu = useCallback(async () => {
|
||||||
|
const willMenuOpen = !isLinkingPanelOpen
|
||||||
|
if (willMenuOpen && onClickPreprocessing) {
|
||||||
|
await onClickPreprocessing()
|
||||||
|
}
|
||||||
|
setIsLinkingPanelOpen(willMenuOpen)
|
||||||
|
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StyledTooltip label="Linked items panel">
|
||||||
|
<button
|
||||||
|
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast"
|
||||||
|
aria-label="Linked items panel"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
ref={buttonRef}
|
||||||
|
>
|
||||||
|
<Icon type="link" />
|
||||||
|
</button>
|
||||||
|
</StyledTooltip>
|
||||||
|
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isLinkingPanelOpen} className="pb-2">
|
||||||
|
<LinkedItemsPanel
|
||||||
|
isOpen={isLinkingPanelOpen}
|
||||||
|
linkingController={linkingController}
|
||||||
|
filesController={filesController}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemsButton)
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
import { FilesController } from '@/Controllers/FilesController'
|
||||||
|
import { LinkableItem, LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||||
|
import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||||
|
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||||
|
import { FileItem } from '@standardnotes/snjs'
|
||||||
|
import { KeyboardKey } from '@standardnotes/ui-services'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
|
||||||
|
import ClearInputButton from '../ClearInputButton/ClearInputButton'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import DecoratedInput from '../Input/DecoratedInput'
|
||||||
|
import MenuItem from '../Menu/MenuItem'
|
||||||
|
import { MenuItemType } from '../Menu/MenuItemType'
|
||||||
|
import Popover from '../Popover/Popover'
|
||||||
|
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||||
|
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
|
||||||
|
import LinkedItemMeta from './LinkedItemMeta'
|
||||||
|
import LinkedItemSearchResults from './LinkedItemSearchResults'
|
||||||
|
|
||||||
|
const LinkedItemsSectionItem = ({
|
||||||
|
activateItem,
|
||||||
|
getItemIcon,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
item,
|
||||||
|
searchQuery,
|
||||||
|
unlinkItem,
|
||||||
|
handleFileAction,
|
||||||
|
}: {
|
||||||
|
activateItem: LinkingController['activateItem']
|
||||||
|
getItemIcon: LinkingController['getLinkedItemIcon']
|
||||||
|
getTitleForLinkedTag: LinkingController['getTitleForLinkedTag']
|
||||||
|
item: LinkableItem
|
||||||
|
searchQuery?: string
|
||||||
|
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||||
|
handleFileAction: FilesController['handleFileAction']
|
||||||
|
}) => {
|
||||||
|
const menuButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||||
|
const toggleMenu = () => setIsMenuOpen((open) => !open)
|
||||||
|
|
||||||
|
const [isRenamingFile, setIsRenamingFile] = useState(false)
|
||||||
|
|
||||||
|
const [icon, className] = getItemIcon(item)
|
||||||
|
const title = item.title ?? ''
|
||||||
|
|
||||||
|
const renameFile = async (name: string) => {
|
||||||
|
if (!(item instanceof FileItem)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await handleFileAction({
|
||||||
|
type: PopoverFileItemActionType.RenameFile,
|
||||||
|
payload: {
|
||||||
|
file: item,
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setIsRenamingFile(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex items-center justify-between">
|
||||||
|
{isRenamingFile && item instanceof FileItem ? (
|
||||||
|
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
|
||||||
|
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-grow text-sm"
|
||||||
|
defaultValue={title}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === KeyboardKey.Escape) {
|
||||||
|
setIsRenamingFile(false)
|
||||||
|
} else if (event.key === KeyboardKey.Enter) {
|
||||||
|
const newTitle = event.currentTarget.value
|
||||||
|
void renameFile(newTitle)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
node.focus()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
|
||||||
|
onClick={() => activateItem(item)}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LinkedItemMeta
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
|
||||||
|
onClick={toggleMenu}
|
||||||
|
ref={menuButtonRef}
|
||||||
|
>
|
||||||
|
<Icon type="more" className="text-neutral" />
|
||||||
|
</button>
|
||||||
|
<Popover
|
||||||
|
open={isMenuOpen}
|
||||||
|
togglePopover={toggleMenu}
|
||||||
|
anchorElement={menuButtonRef.current}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
className="py-2"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
type={MenuItemType.IconButton}
|
||||||
|
onClick={() => {
|
||||||
|
unlinkItem(item)
|
||||||
|
toggleMenu()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon type="link-off" className="mr-2 text-danger" />
|
||||||
|
Unlink
|
||||||
|
</MenuItem>
|
||||||
|
{item instanceof FileItem && (
|
||||||
|
<LinkedFileMenuOptions
|
||||||
|
file={item}
|
||||||
|
closeMenu={toggleMenu}
|
||||||
|
handleFileAction={handleFileAction}
|
||||||
|
setIsRenamingFile={setIsRenamingFile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<HorizontalSeparator classes="my-2" />
|
||||||
|
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
|
||||||
|
</div>
|
||||||
|
<div className="mb-1">
|
||||||
|
<span className="font-semibold">ID:</span> {item.uuid}
|
||||||
|
</div>
|
||||||
|
{item instanceof FileItem && (
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LinkedItemsPanel = ({
|
||||||
|
linkingController,
|
||||||
|
filesController,
|
||||||
|
isOpen,
|
||||||
|
}: {
|
||||||
|
linkingController: LinkingController
|
||||||
|
filesController: FilesController
|
||||||
|
isOpen: boolean
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
tags,
|
||||||
|
files,
|
||||||
|
notesLinkedToItem,
|
||||||
|
notesLinkingToItem,
|
||||||
|
allLinkedItems,
|
||||||
|
getTitleForLinkedTag,
|
||||||
|
getLinkedItemIcon,
|
||||||
|
getSearchResults,
|
||||||
|
linkItemToSelectedItem,
|
||||||
|
unlinkItemFromSelectedItem,
|
||||||
|
activateItem,
|
||||||
|
createAndAddNewTag,
|
||||||
|
isEntitledToNoteLinking,
|
||||||
|
} = linkingController
|
||||||
|
|
||||||
|
const searchInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const isSearching = !!searchQuery.length
|
||||||
|
const { linkedResults, unlinkedResults, shouldShowCreateTag } = getSearchResults(searchQuery)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && searchInputRef.current) {
|
||||||
|
searchInputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<form
|
||||||
|
className={classNames(
|
||||||
|
'sticky top-0 z-10 bg-default px-2.5 pt-2.5',
|
||||||
|
allLinkedItems.length || linkedResults.length || unlinkedResults.length || notesLinkingToItem.length
|
||||||
|
? 'border-b border-border pb-2.5'
|
||||||
|
: 'pb-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<DecoratedInput
|
||||||
|
type="text"
|
||||||
|
className={{ container: !isSearching ? 'py-1.5 px-0.5' : 'py-0', input: 'placeholder:text-passive-0' }}
|
||||||
|
placeholder="Search items to link..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={setSearchQuery}
|
||||||
|
ref={searchInputRef}
|
||||||
|
right={[
|
||||||
|
isSearching && (
|
||||||
|
<ClearInputButton
|
||||||
|
onClick={() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
searchInputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{isSearching ? (
|
||||||
|
<>
|
||||||
|
{(!!unlinkedResults.length || shouldShowCreateTag) && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Unlinked</div>
|
||||||
|
<LinkedItemSearchResults
|
||||||
|
createAndAddNewTag={createAndAddNewTag}
|
||||||
|
getLinkedItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
linkItemToSelectedItem={linkItemToSelectedItem}
|
||||||
|
results={unlinkedResults}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
shouldShowCreateTag={shouldShowCreateTag}
|
||||||
|
isEntitledToNoteLinking={isEntitledToNoteLinking}
|
||||||
|
onClickCallback={() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
searchInputRef.current?.focus()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!linkedResults.length && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked</div>
|
||||||
|
<div className="my-1">
|
||||||
|
{linkedResults.map((item) => (
|
||||||
|
<LinkedItemsSectionItem
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
unlinkItem={unlinkItemFromSelectedItem}
|
||||||
|
activateItem={activateItem}
|
||||||
|
handleFileAction={filesController.handleFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!!tags.length && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Tags</div>
|
||||||
|
<div className="my-1">
|
||||||
|
{tags.map((item) => (
|
||||||
|
<LinkedItemsSectionItem
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
unlinkItem={unlinkItemFromSelectedItem}
|
||||||
|
activateItem={activateItem}
|
||||||
|
handleFileAction={filesController.handleFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!files.length && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
|
||||||
|
<div className="my-1">
|
||||||
|
{files.map((item) => (
|
||||||
|
<LinkedItemsSectionItem
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
unlinkItem={unlinkItemFromSelectedItem}
|
||||||
|
activateItem={activateItem}
|
||||||
|
handleFileAction={filesController.handleFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!notesLinkedToItem.length && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Notes</div>
|
||||||
|
<div className="my-1">
|
||||||
|
{notesLinkedToItem.map((item) => (
|
||||||
|
<LinkedItemsSectionItem
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
unlinkItem={unlinkItemFromSelectedItem}
|
||||||
|
activateItem={activateItem}
|
||||||
|
handleFileAction={filesController.handleFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!!notesLinkingToItem.length && (
|
||||||
|
<div>
|
||||||
|
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
|
||||||
|
Notes Linking To This Note
|
||||||
|
</div>
|
||||||
|
<div className="my-1">
|
||||||
|
{notesLinkingToItem.map((item) => (
|
||||||
|
<LinkedItemsSectionItem
|
||||||
|
key={item.uuid}
|
||||||
|
item={item}
|
||||||
|
getItemIcon={getLinkedItemIcon}
|
||||||
|
getTitleForLinkedTag={getTitleForLinkedTag}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
unlinkItem={unlinkItemFromSelectedItem}
|
||||||
|
activateItem={activateItem}
|
||||||
|
handleFileAction={filesController.handleFileAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default observer(LinkedItemsPanel)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
339
packages/web/src/javascripts/Controllers/LinkingController.tsx
Normal file
339
packages/web/src/javascripts/Controllers/LinkingController.tsx
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
37
packages/web/src/javascripts/Hooks/mergeRefs.ts
Normal file
37
packages/web/src/javascripts/Hooks/mergeRefs.ts
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user