feat: ability to favorite tags + customize icon (#1858)

This commit is contained in:
Mo
2022-10-21 11:11:31 -05:00
committed by GitHub
parent 3b048a31aa
commit cbd0063926
38 changed files with 568 additions and 262 deletions

View File

@@ -25,6 +25,7 @@
"@types/jest": "^28.1.5", "@types/jest": "^28.1.5",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@typescript-eslint/eslint-plugin": "^5.30.0", "@typescript-eslint/eslint-plugin": "^5.30.0",
"eslint": "*",
"eslint-plugin-prettier": "*", "eslint-plugin-prettier": "*",
"jest": "^28.1.2", "jest": "^28.1.2",
"ts-jest": "^28.0.5", "ts-jest": "^28.0.5",

View File

@@ -1,41 +1,33 @@
import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem'
import { PredicateInterface, PredicateJsonForm } from '../../Runtime/Predicate/Interface' import { PredicateInterface } from '../../Runtime/Predicate/Interface'
import { predicateFromJson } from '../../Runtime/Predicate/Generators' import { predicateFromJson } from '../../Runtime/Predicate/Generators'
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
import { SystemViewId } from './SystemViewId'
import { EmojiString, IconType } from '../../Utilities/Icon/IconType'
import { SmartViewDefaultIconName, systemViewIcon } from './SmartViewIcons'
import { SmartViewContent } from './SmartViewContent'
export const SMART_TAG_DSL_PREFIX = '![' export const SMART_TAG_DSL_PREFIX = '!['
export enum SystemViewId {
AllNotes = 'all-notes',
Files = 'files',
ArchivedNotes = 'archived-notes',
TrashedNotes = 'trashed-notes',
UntaggedNotes = 'untagged-notes',
StarredNotes = 'starred-notes',
}
export interface SmartViewContent extends ItemContent {
title: string
predicate: PredicateJsonForm
}
export function isSystemView(view: SmartView): boolean { export function isSystemView(view: SmartView): boolean {
return Object.values(SystemViewId).includes(view.uuid as SystemViewId) return Object.values(SystemViewId).includes(view.uuid as SystemViewId)
} }
/**
* A tag that defines a predicate that consumers can use
* to retrieve a dynamic list of items.
*/
export class SmartView extends DecryptedItem<SmartViewContent> { export class SmartView extends DecryptedItem<SmartViewContent> {
public readonly predicate!: PredicateInterface<DecryptedItem> public readonly predicate!: PredicateInterface<DecryptedItem>
public readonly title: string public readonly title: string
public readonly iconString: IconType | EmojiString
constructor(payload: DecryptedPayloadInterface<SmartViewContent>) { constructor(payload: DecryptedPayloadInterface<SmartViewContent>) {
super(payload) super(payload)
this.title = String(this.content.title || '') this.title = String(this.content.title || '')
if (isSystemView(this)) {
this.iconString = systemViewIcon(this.uuid as SystemViewId)
} else {
this.iconString = this.payload.content.iconString || SmartViewDefaultIconName
}
try { try {
this.predicate = this.content.predicate && predicateFromJson(this.content.predicate) this.predicate = this.content.predicate && predicateFromJson(this.content.predicate)
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,8 @@
import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload' import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload'
import { SNNote } from '../Note/Note' import { SNNote } from '../Note/Note'
import { SmartViewContent, SmartView, SystemViewId } from './SmartView' import { SmartView } from './SmartView'
import { SmartViewContent } from './SmartViewContent'
import { SystemViewId } from './SystemViewId'
import { ItemWithTags } from '../../Runtime/Display/Search/ItemWithTags' import { ItemWithTags } from '../../Runtime/Display/Search/ItemWithTags'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent' import { FillItemContent } from '../../Abstract/Content/ItemContent'

View File

@@ -0,0 +1,6 @@
import { TagContent } from './../Tag/TagContent'
import { PredicateJsonForm } from '../../Runtime/Predicate/Interface'
export interface SmartViewContent extends TagContent {
predicate: PredicateJsonForm
}

View File

@@ -0,0 +1,17 @@
import { SystemViewId } from './SystemViewId'
import { IconType } from '../../Utilities/Icon/IconType'
export const SmartViewIcons: Record<SystemViewId, IconType> = {
[SystemViewId.AllNotes]: 'notes',
[SystemViewId.Files]: 'folder',
[SystemViewId.ArchivedNotes]: 'archive',
[SystemViewId.TrashedNotes]: 'trash',
[SystemViewId.UntaggedNotes]: 'hashtag-off',
[SystemViewId.StarredNotes]: 'star-filled',
}
export function systemViewIcon(id: SystemViewId): IconType {
return SmartViewIcons[id]
}
export const SmartViewDefaultIconName: IconType = 'restore'

View File

@@ -0,0 +1,8 @@
export enum SystemViewId {
AllNotes = 'all-notes',
Files = 'files',
ArchivedNotes = 'archived-notes',
TrashedNotes = 'trashed-notes',
UntaggedNotes = 'untagged-notes',
StarredNotes = 'starred-notes',
}

View File

@@ -1,2 +1,4 @@
export * from './SmartView' export * from './SmartView'
export * from './SmartViewBuilder' export * from './SmartViewBuilder'
export * from './SystemViewId'
export * from './SmartViewContent'

View File

@@ -1,10 +1,11 @@
import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource' import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource'
import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload' import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload'
import { SNTag, TagContent } from './Tag' import { SNTag } from './Tag'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { FillItemContent } from '../../Abstract/Content/ItemContent' import { FillItemContent } from '../../Abstract/Content/ItemContent'
import { ContentReference } from '../../Abstract/Reference/ContentReference' import { ContentReference } from '../../Abstract/Reference/ContentReference'
import { PayloadTimestampDefaults } from '../../Abstract/Payload' import { PayloadTimestampDefaults } from '../../Abstract/Payload'
import { TagContent } from './TagContent'
const randUuid = () => String(Math.random()) const randUuid = () => String(Math.random())

View File

@@ -1,32 +1,28 @@
import { VectorIconNameOrEmoji, IconType } from './../../Utilities/Icon/IconType'
import { ContentType, Uuid } from '@standardnotes/common' import { ContentType, Uuid } from '@standardnotes/common'
import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem' import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem'
import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface' import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface'
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { ContentReference } from '../../Abstract/Reference/ContentReference' import { ContentReference } from '../../Abstract/Reference/ContentReference'
import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' import { isTagToParentTagReference } from '../../Abstract/Reference/Functions'
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload' import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
import { TagContent, TagContentSpecialized } from './TagContent'
export const TagFolderDelimitter = '.' export const TagFolderDelimitter = '.'
interface TagInterface { export const DefaultTagIconName: IconType = 'hashtag'
title: string
expanded: boolean
}
export type TagContent = TagInterface & ItemContent
export const isTag = (x: ItemInterface): x is SNTag => x.content_type === ContentType.Tag export const isTag = (x: ItemInterface): x is SNTag => x.content_type === ContentType.Tag
export class SNTag extends DecryptedItem<TagContent> implements TagInterface { export class SNTag extends DecryptedItem<TagContent> implements TagContentSpecialized {
public readonly title: string public readonly title: string
public readonly iconString: VectorIconNameOrEmoji
/** Whether to render child tags in view hierarchy. Opposite of collapsed. */
public readonly expanded: boolean public readonly expanded: boolean
constructor(payload: DecryptedPayloadInterface<TagContent>) { constructor(payload: DecryptedPayloadInterface<TagContent>) {
super(payload) super(payload)
this.title = this.payload.content.title || '' this.title = this.payload.content.title || ''
this.expanded = this.payload.content.expanded != undefined ? this.payload.content.expanded : true this.expanded = this.payload.content.expanded != undefined ? this.payload.content.expanded : true
this.iconString = this.payload.content.iconString || DefaultTagIconName
} }
get noteReferences(): ContentReference[] { get noteReferences(): ContentReference[] {

View File

@@ -0,0 +1,11 @@
import { IconType } from './../../Utilities/Icon/IconType'
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { EmojiString } from '../../Utilities/Icon/IconType'
export interface TagContentSpecialized {
title: string
expanded: boolean
iconString: IconType | EmojiString
}
export type TagContent = TagContentSpecialized & ItemContent

View File

@@ -1,5 +1,6 @@
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { TagContent, SNTag } from './Tag' import { SNTag } from './Tag'
import { TagContent } from './TagContent'
import { FileItem } from '../File' import { FileItem } from '../File'
import { SNNote } from '../Note' import { SNNote } from '../Note'
import { isTagToParentTagReference } from '../../Abstract/Reference/Functions' import { isTagToParentTagReference } from '../../Abstract/Reference/Functions'
@@ -17,6 +18,10 @@ export class TagMutator extends DecryptedItemMutator<TagContent> {
this.mutableContent.expanded = expanded this.mutableContent.expanded = expanded
} }
set iconString(iconString: string) {
this.mutableContent.iconString = iconString
}
public makeChildOf(tag: SNTag): void { public makeChildOf(tag: SNTag): void {
const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref)) const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref))

View File

@@ -1,2 +1,3 @@
export * from './Tag' export * from './Tag'
export * from './TagMutator' export * from './TagMutator'
export * from './TagContent'

View File

@@ -1,3 +1,7 @@
export type VectorIconNameOrEmoji = EmojiString | IconType
export type EmojiString = string
export type IconType = export type IconType =
| 'accessibility' | 'accessibility'
| 'account-card-details-outline' | 'account-card-details-outline'

View File

@@ -1,4 +1,4 @@
import { TagContent } from './../../Syncable/Tag/Tag' import { TagContent } from './../../Syncable/Tag/TagContent'
import { ContentType } from '@standardnotes/common' import { ContentType } from '@standardnotes/common'
import { FillItemContent, ItemContent } from '../../Abstract/Content/ItemContent' import { FillItemContent, ItemContent } from '../../Abstract/Content/ItemContent'
import { DecryptedPayload, PayloadSource, PayloadTimestampDefaults } from '../../Abstract/Payload' import { DecryptedPayload, PayloadSource, PayloadTimestampDefaults } from '../../Abstract/Payload'

View File

@@ -1,7 +1,7 @@
export * from './Abstract/Component/ActionObserver' export * from './Abstract/Component/ActionObserver'
export * from './Abstract/Component/ComponentViewerEvent'
export * from './Abstract/Component/ComponentMessage'
export * from './Abstract/Component/ComponentEventObserver' export * from './Abstract/Component/ComponentEventObserver'
export * from './Abstract/Component/ComponentMessage'
export * from './Abstract/Component/ComponentViewerEvent'
export * from './Abstract/Component/IncomingComponentItemPayload' export * from './Abstract/Component/IncomingComponentItemPayload'
export * from './Abstract/Component/KeyboardModifier' export * from './Abstract/Component/KeyboardModifier'
export * from './Abstract/Component/MessageData' export * from './Abstract/Component/MessageData'
@@ -43,9 +43,9 @@ 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'
export * from './Runtime/DirtyCounter/DirtyCounter' export * from './Runtime/DirtyCounter/DirtyCounter'
export * from './Runtime/Display'
export * from './Runtime/Display/ItemDisplayController' export * from './Runtime/Display/ItemDisplayController'
export * from './Runtime/Display/Types' export * from './Runtime/Display/Types'
export * from './Runtime/Display'
export * from './Runtime/History' export * from './Runtime/History'
export * from './Runtime/Index/ItemDelta' export * from './Runtime/Index/ItemDelta'
export * from './Runtime/Index/SNIndex' export * from './Runtime/Index/SNIndex'
@@ -70,6 +70,7 @@ export * from './Syncable/SmartView'
export * from './Syncable/Tag' export * from './Syncable/Tag'
export * from './Syncable/Theme' export * from './Syncable/Theme'
export * from './Syncable/UserPrefs' export * from './Syncable/UserPrefs'
export * from './Utilities/Icon/IconType'
export * from './Utilities/Item/FindItem' export * from './Utilities/Item/FindItem'
export * from './Utilities/Item/ItemContentsDiffer' export * from './Utilities/Item/ItemContentsDiffer'
export * from './Utilities/Item/ItemContentsEqual' export * from './Utilities/Item/ItemContentsEqual'

View File

@@ -1,5 +1,5 @@
import { NoteType } from '@standardnotes/features' import { NoteType } from '@standardnotes/features'
import { IconType } from '@Lib/Types/IconType' import { IconType } from '@standardnotes/models'
export class IconsController { export class IconsController {
getIconForFileType(type: string): IconType { getIconForFileType(type: string): IconType {

View File

@@ -1,3 +1,2 @@
export * from './ApplicationEventPayload' export * from './ApplicationEventPayload'
export * from './IconType'
export * from './UuidString' export * from './UuidString'

View File

@@ -111,6 +111,10 @@ const ContentListView: FunctionComponent<Props> = ({
const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController
const { selected: selectedTag } = navigationController
const icon = selectedTag?.iconString
const isFilesSmartView = useMemo( const isFilesSmartView = useMemo(
() => navigationController.selected?.uuid === SystemViewId.Files, () => navigationController.selected?.uuid === SystemViewId.Files,
[navigationController.selected?.uuid], [navigationController.selected?.uuid],
@@ -259,6 +263,7 @@ const ContentListView: FunctionComponent<Props> = ({
<ContentListHeader <ContentListHeader
application={application} application={application}
panelTitle={panelTitle} panelTitle={panelTitle}
icon={icon}
addButtonLabel={addButtonLabel} addButtonLabel={addButtonLabel}
addNewItem={addNewItem} addNewItem={addNewItem}
isFilesSmartView={isFilesSmartView} isFilesSmartView={isFilesSmartView}

View File

@@ -5,6 +5,7 @@ import { classNames } from '@/Utils/ConcatenateClassNames'
import Popover from '@/Components/Popover/Popover' import Popover from '@/Components/Popover/Popover'
import DisplayOptionsMenu from './DisplayOptionsMenu' import DisplayOptionsMenu from './DisplayOptionsMenu'
import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu' import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu'
import { IconType } from '@standardnotes/snjs'
import RoundIconButton from '@/Components/Button/RoundIconButton' import RoundIconButton from '@/Components/Button/RoundIconButton'
type Props = { type Props = {
@@ -14,6 +15,7 @@ type Props = {
isNativeMobileWeb: WebApplication['isNativeMobileWeb'] isNativeMobileWeb: WebApplication['isNativeMobileWeb']
} }
panelTitle: string panelTitle: string
icon?: IconType | string
addButtonLabel: string addButtonLabel: string
addNewItem: () => void addNewItem: () => void
isFilesSmartView: boolean isFilesSmartView: boolean
@@ -23,6 +25,7 @@ type Props = {
const ContentListHeader = ({ const ContentListHeader = ({
application, application,
panelTitle, panelTitle,
icon,
addButtonLabel, addButtonLabel,
addNewItem, addNewItem,
isFilesSmartView, isFilesSmartView,
@@ -41,8 +44,19 @@ const ContentListHeader = ({
<div className="section-title-bar-header items-start gap-1 overflow-hidden"> <div className="section-title-bar-header items-start gap-1 overflow-hidden">
<NavigationMenuButton /> <NavigationMenuButton />
<div className="flex min-w-0 flex-grow flex-col break-words"> <div className="flex min-w-0 flex-grow flex-col break-words">
<div className="text-lg font-semibold text-text">{panelTitle}</div> <div className={`flex min-w-0 flex-grow flex-row ${!optionsSubtitle ? 'items-center' : ''}`}>
{optionsSubtitle && <div className="text-xs text-passive-0">{optionsSubtitle}</div>} {icon && (
<Icon
type={icon as IconType}
size={'large'}
className={`ml-0.5 mr-1.5 text-neutral ${optionsSubtitle ? 'mt-1' : ''}`}
/>
)}
<div className="flex min-w-0 flex-grow flex-col break-words">
<div className="text-lg font-semibold text-text">{panelTitle}</div>
{optionsSubtitle && <div className="text-xs text-passive-0">{optionsSubtitle}</div>}
</div>
</div>
</div> </div>
<div className="flex"> <div className="flex">
<div className="relative" ref={displayOptionsContainerRef}> <div className="relative" ref={displayOptionsContainerRef}>

View File

@@ -19,7 +19,7 @@ const ListItemTags: FunctionComponent<Props> = ({ hideTags, tags }) => {
className="inline-flex items-center rounded-sm bg-passive-4-opacity-variant py-1 px-1.5 text-foreground" className="inline-flex items-center rounded-sm bg-passive-4-opacity-variant py-1 px-1.5 text-foreground"
key={tag.uuid} key={tag.uuid}
> >
<Icon type="hashtag" className="mr-1 text-passive-1" size="small" /> <Icon type={tag.iconString} className="mr-1 text-passive-1" size="small" />
<span>{tag.title}</span> <span>{tag.title}</span>
</span> </span>
))} ))}

View File

@@ -5,5 +5,6 @@ export type DisplayableListItemProps = AbstractListItemProps & {
tags: { tags: {
uuid: SNTag['uuid'] uuid: SNTag['uuid']
title: SNTag['title'] title: SNTag['title']
iconString: SNTag['iconString']
}[] }[]
} }

View File

@@ -1,7 +1,7 @@
import { ICONS } from '@/Components/Icon/Icon' import { IconNameToSvgMapping } from '@/Components/Icon/IconNameToSvgMapping'
export const getFileIconComponent = (iconType: string, className: string) => { export const getFileIconComponent = (iconType: string, className: string) => {
const IconComponent = ICONS[iconType as keyof typeof ICONS] const IconComponent = IconNameToSvgMapping[iconType as keyof typeof IconNameToSvgMapping]
return <IconComponent className={className} /> return <IconComponent className={className} />
} }

View File

@@ -1,141 +1,77 @@
import { FunctionComponent, useMemo } from 'react' import { FunctionComponent } from 'react'
import { IconType } from '@standardnotes/snjs' import { VectorIconNameOrEmoji } from '@standardnotes/snjs'
import * as icons from '@standardnotes/icons' import { IconNameToSvgMapping } from './IconNameToSvgMapping'
import { classNames } from '@/Utils/ConcatenateClassNames'
export const ICONS = {
'account-circle': icons.AccountCircleIcon,
'arrow-left': icons.ArrowLeftIcon,
'arrow-right': icons.ArrowRightIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon,
'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon,
'chevron-down': icons.ChevronDownIcon,
'chevron-left': icons.ChevronLeftIcon,
'chevron-right': icons.ChevronRightIcon,
'clear-circle-filled': icons.ClearCircleFilledIcon,
'cloud-off': icons.CloudOffIcon,
'diamond-filled': icons.DiamondFilledIcon,
'eye-off': icons.EyeOffIcon,
'file-doc': icons.FileDocIcon,
'file-image': icons.FileImageIcon,
'file-mov': icons.FileMovIcon,
'file-music': icons.FileMusicIcon,
'file-other': icons.FileOtherIcon,
'file-pdf': icons.FilePdfIcon,
'file-ppt': icons.FilePptIcon,
'file-xls': icons.FileXlsIcon,
'file-zip': icons.FileZipIcon,
'hashtag-off': icons.HashtagOffIcon,
'link-off': icons.LinkOffIcon,
'list-bulleted': icons.ListBulleted,
'lock-filled': icons.LockFilledIcon,
'menu-arrow-down-alt': icons.MenuArrowDownAlt,
'menu-arrow-down': icons.MenuArrowDownIcon,
'menu-arrow-right': icons.MenuArrowRightIcon,
'menu-close': icons.MenuCloseIcon,
'menu-variant': icons.MenuVariantIcon,
'notes-filled': icons.NotesFilledIcon,
'pencil-filled': icons.PencilFilledIcon,
'pencil-off': icons.PencilOffIcon,
'pin-filled': icons.PinFilledIcon,
'plain-text': icons.PlainTextIcon,
'premium-feature': icons.PremiumFeatureIcon,
'rich-text': icons.RichTextIcon,
'sort-descending': icons.SortDescendingIcon,
'star-circle-filled': icons.StarCircleFilled,
'star-filled': icons.StarFilledIcon,
'star-variant-filled': icons.StarVariantFilledIcon,
'trash-filled': icons.TrashFilledIcon,
'trash-sweep': icons.TrashSweepIcon,
'user-add': icons.UserAddIcon,
'user-switch': icons.UserSwitch,
accessibility: icons.AccessibilityIcon,
add: icons.AddIcon,
archive: icons.ArchiveIcon,
asterisk: icons.AsteriskIcon,
authenticator: icons.AuthenticatorIcon,
check: icons.CheckIcon,
close: icons.CloseIcon,
code: icons.CodeIcon,
copy: icons.CopyIcon,
dashboard: icons.DashboardIcon,
diamond: icons.DiamondIcon,
download: icons.DownloadIcon,
editor: icons.EditorIcon,
email: icons.EmailIcon,
eye: icons.EyeIcon,
file: icons.FileIcon,
folder: icons.FolderIcon,
hashtag: icons.HashtagIcon,
help: icons.HelpIcon,
history: icons.HistoryIcon,
info: icons.InfoIcon,
keyboard: icons.KeyboardIcon,
link: icons.LinkIcon,
listed: icons.ListedIcon,
lock: icons.LockIcon,
markdown: icons.MarkdownIcon,
more: icons.MoreIcon,
notes: icons.NotesIcon,
password: icons.PasswordIcon,
pencil: icons.PencilIcon,
pin: icons.PinIcon,
restore: icons.RestoreIcon,
search: icons.SearchIcon,
security: icons.SecurityIcon,
server: icons.ServerIcon,
settings: icons.SettingsIcon,
share: icons.ShareIcon,
signIn: icons.SignInIcon,
signOut: icons.SignOutIcon,
spreadsheets: icons.SpreadsheetsIcon,
star: icons.StarIcon,
subtract: icons.SubtractIcon,
sync: icons.SyncIcon,
tasks: icons.TasksIcon,
themes: icons.ThemesIcon,
trash: icons.TrashIcon,
tune: icons.TuneIcon,
unarchive: icons.UnarchiveIcon,
unpin: icons.UnpinIcon,
user: icons.UserIcon,
view: icons.ViewIcon,
warning: icons.WarningIcon,
window: icons.WindowIcon,
}
type Props = { type Props = {
type: IconType type: VectorIconNameOrEmoji
className?: string className?: string
ariaLabel?: string ariaLabel?: string
size?: 'small' | 'medium' | 'normal' | 'custom' size?: 'small' | 'medium' | 'normal' | 'large' | 'custom'
}
const ContainerDimensions = {
small: 'w-3.5 h-3.5',
medium: 'w-4 h-4',
normal: 'w-5 h-5',
large: 'w-6 h-6',
custom: '',
}
const EmojiContainerDimensions = {
small: 'w-4 h-4',
medium: 'w-5 h-5',
normal: 'w-5 h-5',
large: 'w-7 h-6',
custom: '',
}
const EmojiOffset = {
small: '',
medium: '',
normal: '-mt-0.5',
large: '',
custom: '',
}
const EmojiSize = {
small: 'text-xs',
medium: 'text-sm',
normal: 'text-base',
large: 'text-lg',
custom: '',
}
const getIconComponent = (type: VectorIconNameOrEmoji) => {
return IconNameToSvgMapping[type as keyof typeof IconNameToSvgMapping]
}
export const isIconEmoji = (type: VectorIconNameOrEmoji): boolean => {
return getIconComponent(type) == undefined
} }
const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel, size = 'normal' }) => { const Icon: FunctionComponent<Props> = ({ type, className = '', ariaLabel, size = 'normal' }) => {
const IconComponent = ICONS[type as keyof typeof ICONS] const IconComponent = getIconComponent(type)
const dimensions = useMemo(() => {
switch (size) {
case 'small':
return 'w-3.5 h-3.5'
case 'medium':
return 'w-4 h-4'
case 'custom':
return ''
default:
return 'w-5 h-5'
}
}, [size])
if (!IconComponent) { if (!IconComponent) {
return null return (
<label
className={classNames(
'fill-current',
'text-center',
EmojiSize[size],
EmojiContainerDimensions[size],
EmojiOffset[size],
className,
)}
>
{type}
</label>
)
} }
return ( return (
<IconComponent <IconComponent
className={`${dimensions} fill-current ${className}`} className={`${ContainerDimensions[size]} fill-current ${className}`}
role="img" role="img"
{...(ariaLabel ? { 'aria-label': ariaLabel } : { 'aria-hidden': true })} {...(ariaLabel ? { 'aria-label': ariaLabel } : { 'aria-hidden': true })}
/> />

View File

@@ -0,0 +1,106 @@
import * as icons from '@standardnotes/icons'
export const IconNameToSvgMapping = {
'account-circle': icons.AccountCircleIcon,
'arrow-left': icons.ArrowLeftIcon,
'arrow-right': icons.ArrowRightIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon,
'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon,
'chevron-down': icons.ChevronDownIcon,
'chevron-left': icons.ChevronLeftIcon,
'chevron-right': icons.ChevronRightIcon,
'clear-circle-filled': icons.ClearCircleFilledIcon,
'cloud-off': icons.CloudOffIcon,
'diamond-filled': icons.DiamondFilledIcon,
'eye-off': icons.EyeOffIcon,
'file-doc': icons.FileDocIcon,
'file-image': icons.FileImageIcon,
'file-mov': icons.FileMovIcon,
'file-music': icons.FileMusicIcon,
'file-other': icons.FileOtherIcon,
'file-pdf': icons.FilePdfIcon,
'file-ppt': icons.FilePptIcon,
'file-xls': icons.FileXlsIcon,
'file-zip': icons.FileZipIcon,
'hashtag-off': icons.HashtagOffIcon,
'link-off': icons.LinkOffIcon,
'list-bulleted': icons.ListBulleted,
'lock-filled': icons.LockFilledIcon,
'menu-arrow-down-alt': icons.MenuArrowDownAlt,
'menu-arrow-down': icons.MenuArrowDownIcon,
'menu-arrow-right': icons.MenuArrowRightIcon,
'menu-close': icons.MenuCloseIcon,
'menu-variant': icons.MenuVariantIcon,
'notes-filled': icons.NotesFilledIcon,
'pencil-filled': icons.PencilFilledIcon,
'pencil-off': icons.PencilOffIcon,
'pin-filled': icons.PinFilledIcon,
'plain-text': icons.PlainTextIcon,
'premium-feature': icons.PremiumFeatureIcon,
'rich-text': icons.RichTextIcon,
'sort-descending': icons.SortDescendingIcon,
'star-circle-filled': icons.StarCircleFilled,
'star-filled': icons.StarFilledIcon,
'star-variant-filled': icons.StarVariantFilledIcon,
'trash-filled': icons.TrashFilledIcon,
'trash-sweep': icons.TrashSweepIcon,
'user-add': icons.UserAddIcon,
'user-switch': icons.UserSwitch,
'fullscreen-exit': icons.FullscreenExitIcon,
accessibility: icons.AccessibilityIcon,
add: icons.AddIcon,
archive: icons.ArchiveIcon,
asterisk: icons.AsteriskIcon,
authenticator: icons.AuthenticatorIcon,
check: icons.CheckIcon,
close: icons.CloseIcon,
code: icons.CodeIcon,
copy: icons.CopyIcon,
dashboard: icons.DashboardIcon,
diamond: icons.DiamondIcon,
download: icons.DownloadIcon,
editor: icons.EditorIcon,
email: icons.EmailIcon,
eye: icons.EyeIcon,
file: icons.FileIcon,
folder: icons.FolderIcon,
hashtag: icons.HashtagIcon,
help: icons.HelpIcon,
history: icons.HistoryIcon,
info: icons.InfoIcon,
keyboard: icons.KeyboardIcon,
link: icons.LinkIcon,
listed: icons.ListedIcon,
lock: icons.LockIcon,
markdown: icons.MarkdownIcon,
more: icons.MoreIcon,
notes: icons.NotesIcon,
password: icons.PasswordIcon,
pencil: icons.PencilIcon,
pin: icons.PinIcon,
restore: icons.RestoreIcon,
search: icons.SearchIcon,
security: icons.SecurityIcon,
server: icons.ServerIcon,
settings: icons.SettingsIcon,
share: icons.ShareIcon,
signIn: icons.SignInIcon,
signOut: icons.SignOutIcon,
spreadsheets: icons.SpreadsheetsIcon,
star: icons.StarIcon,
subtract: icons.SubtractIcon,
sync: icons.SyncIcon,
tasks: icons.TasksIcon,
themes: icons.ThemesIcon,
trash: icons.TrashIcon,
tune: icons.TuneIcon,
unarchive: icons.UnarchiveIcon,
unpin: icons.UnpinIcon,
user: icons.UserIcon,
view: icons.ViewIcon,
warning: icons.WarningIcon,
window: icons.WindowIcon,
}

View File

@@ -0,0 +1,131 @@
import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import { FunctionComponent, useMemo, useRef, useState } from 'react'
import Dropdown from '../Dropdown/Dropdown'
import { DropdownItem } from '../Dropdown/DropdownItem'
import { isIconEmoji } from './Icon'
import { IconNameToSvgMapping } from './IconNameToSvgMapping'
import { IconPickerType } from './IconPickerType'
type Props = {
selectedValue: VectorIconNameOrEmoji
onIconChange: (value?: string) => void
platform: Platform
className?: string
}
const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props) => {
const iconOptions = useMemo(
() =>
[...Object.keys(IconNameToSvgMapping)].map(
(value) =>
({
label: value,
value: value,
icon: value,
} as DropdownItem),
),
[],
)
const isSelectedEmoji = isIconEmoji(selectedValue)
const isMacOS = platform === Platform.MacWeb || platform === Platform.MacDesktop
const isWindows = platform === Platform.WindowsWeb || platform === Platform.WindowsDesktop
const emojiInputRef = useRef<HTMLInputElement>(null)
const [emojiInputFocused, setEmojiInputFocused] = useState(true)
const [currentType, setCurrentType] = useState<IconPickerType>(isSelectedEmoji ? 'emoji' : 'icon')
const [emojiInputValue, setEmojiInputValue] = useState(isSelectedEmoji ? selectedValue : '')
const selectTab = (type: IconPickerType | 'reset') => {
if (type === 'reset') {
onIconChange(undefined)
setEmojiInputValue('')
} else {
setCurrentType(type)
}
}
const TabButton: FunctionComponent<{
label: string
type: IconPickerType | 'reset'
}> = ({ type, label }) => {
const isSelected = currentType === type
return (
<button
className={`relative mr-2 cursor-pointer border-0 bg-default pb-1.5 text-sm focus:shadow-none ${
isSelected ? 'font-medium text-info' : 'text-text'
}`}
onClick={() => {
selectTab(type)
}}
>
{label}
</button>
)
}
const handleIconChange = (value: string) => {
onIconChange(value)
}
const handleEmojiChange = (value: EmojiString) => {
setEmojiInputValue(value)
const emojiLength = [...value].length
if (emojiLength === 1) {
onIconChange(value)
emojiInputRef.current?.blur()
setEmojiInputFocused(false)
} else {
setEmojiInputFocused(true)
}
}
return (
<div className={`flex h-full flex-grow flex-col overflow-auto ${className}`}>
<div className="flex">
<TabButton label="Icon" type={'icon'} />
<TabButton label="Emoji" type={'emoji'} />
<TabButton label="Reset" type={'reset'} />
</div>
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
{currentType === 'icon' && (
<Dropdown
id="change-tag-icon-dropdown"
label="Change the icon for a tag"
items={iconOptions}
value={selectedValue}
onChange={handleIconChange}
/>
)}
{currentType === 'emoji' && (
<>
<div>
<input
ref={emojiInputRef}
autoComplete="off"
autoFocus={emojiInputFocused}
className="w-full flex-grow rounded border border-solid border-passive-3 bg-default px-2 py-1 text-base font-bold text-text focus:shadow-none focus:outline-none"
type="text"
value={emojiInputValue}
onChange={({ target: input }) => handleEmojiChange((input as HTMLInputElement)?.value)}
/>
</div>
<div className="mt-2 text-xs text-passive-0">
Use your keyboard to enter or paste in an emoji character.
</div>
{isMacOS && (
<div className="mt-2 text-xs text-passive-0">On macOS: + + Space bar to bring up emoji picker.</div>
)}
{isWindows && (
<div className="mt-2 text-xs text-passive-0">On Windows: Windows key + . to bring up emoji picker.</div>
)}
</>
)}
</div>
</div>
)
}
export default IconPicker

View File

@@ -0,0 +1 @@
export type IconPickerType = 'icon' | 'emoji'

View File

@@ -6,6 +6,7 @@ import { IconType } from '@standardnotes/snjs'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { MenuItemType } from './MenuItemType' import { MenuItemType } from './MenuItemType'
import RadioIndicator from '../Radio/RadioIndicator' import RadioIndicator from '../Radio/RadioIndicator'
import { classNames } from '@/Utils/ConcatenateClassNames'
type MenuItemProps = { type MenuItemProps = {
children: ReactNode children: ReactNode
@@ -40,7 +41,10 @@ const MenuItem = forwardRef(
<li className="list-none" role="none"> <li className="list-none" role="none">
<button <button
ref={ref} ref={ref}
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none" className={classNames(
'flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5',
'text-left text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none',
)}
onClick={() => { onClick={() => {
onChange(!checked) onChange(!checked)
}} }}
@@ -59,7 +63,12 @@ const MenuItem = forwardRef(
ref={ref} ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'} role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE} tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
className={`flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-menu-item ${className}`} className={classNames(
'flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left',
'text-mobile-menu-item text-text hover:bg-contrast hover:text-foreground',
'focus:bg-info-backdrop focus:shadow-none md:text-menu-item',
className,
)}
onClick={onClick} onClick={onClick}
onBlur={onBlur} onBlur={onBlur}
{...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})} {...(type === MenuItemType.RadioButton ? { 'aria-checked': checked } : {})}

View File

@@ -1,19 +1,5 @@
import { IconType } from '@standardnotes/snjs'
export enum AppPaneId { export enum AppPaneId {
Navigation = 'NavigationColumn', Navigation = 'NavigationColumn',
Items = 'ItemsColumn', Items = 'ItemsColumn',
Editor = 'EditorColumn', Editor = 'EditorColumn',
} }
export const AppPaneTitles = {
[AppPaneId.Navigation]: 'Navigation',
[AppPaneId.Items]: 'Notes & Files',
[AppPaneId.Editor]: 'Editor',
}
export const AppPaneIcons: Record<AppPaneId, IconType> = {
[AppPaneId.Navigation]: 'hashtag',
[AppPaneId.Items]: 'notes',
[AppPaneId.Editor]: 'plain-text',
}

View File

@@ -2,7 +2,7 @@ import Icon from '@/Components/Icon/Icon'
import { FeaturesController } from '@/Controllers/FeaturesController' import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import '@reach/tooltip/styles.css' import '@reach/tooltip/styles.css'
import { SmartView, SystemViewId, IconType, isSystemView } from '@standardnotes/snjs' import { SmartView, SystemViewId, isSystemView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { import {
FormEventHandler, FormEventHandler,
@@ -27,19 +27,6 @@ type Props = {
const PADDING_BASE_PX = 14 const PADDING_BASE_PX = 14
const PADDING_PER_LEVEL_PX = 21 const PADDING_PER_LEVEL_PX = 21
const smartViewIconType = (view: SmartView, isSelected: boolean): IconType => {
const mapping: Record<SystemViewId, IconType> = {
[SystemViewId.AllNotes]: isSelected ? 'notes-filled' : 'notes',
[SystemViewId.Files]: 'folder',
[SystemViewId.ArchivedNotes]: 'archive',
[SystemViewId.TrashedNotes]: 'trash',
[SystemViewId.UntaggedNotes]: 'hashtag-off',
[SystemViewId.StarredNotes]: 'star-filled',
}
return mapping[view.uuid as SystemViewId] || 'hashtag'
}
const getIconClass = (view: SmartView, isSelected: boolean): string => { const getIconClass = (view: SmartView, isSelected: boolean): string => {
const mapping: Partial<Record<SystemViewId, string>> = { const mapping: Partial<Record<SystemViewId, string>> = {
[SystemViewId.StarredNotes]: 'text-warning', [SystemViewId.StarredNotes]: 'text-warning',
@@ -111,7 +98,6 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
}, [tagsState, view]) }, [tagsState, view])
const isFaded = false const isFaded = false
const iconType = smartViewIconType(view, isSelected)
const iconClass = getIconClass(view, isSelected) const iconClass = getIconClass(view, isSelected)
return ( return (
@@ -127,7 +113,7 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
> >
<div className="tag-info"> <div className="tag-info">
<div className={'tag-icon mr-2'}> <div className={'tag-icon mr-2'}>
<Icon type={iconType} className={iconClass} /> <Icon type={view.iconString} className={iconClass} />
</div> </div>
{isEditing ? ( {isEditing ? (
<input <input

View File

@@ -5,13 +5,14 @@ import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem' import MenuItem from '@/Components/Menu/MenuItem'
import { MenuItemType } from '@/Components/Menu/MenuItemType' import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { SNTag } from '@standardnotes/snjs' import { SNTag, VectorIconNameOrEmoji, DefaultTagIconName } from '@standardnotes/snjs'
import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside' import { useCloseOnClickOutside } from '@/Hooks/useCloseOnClickOutside'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import HorizontalSeparator from '../Shared/HorizontalSeparator' import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatDateForContextMenu } from '@/Utils/DateUtils' import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import Popover from '../Popover/Popover' import Popover from '../Popover/Popover'
import IconPicker from '../Icon/IconPicker'
type ContextMenuProps = { type ContextMenuProps = {
navigationController: NavigationController navigationController: NavigationController
@@ -22,7 +23,7 @@ type ContextMenuProps = {
const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag }: ContextMenuProps) => { const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag }: ContextMenuProps) => {
const premiumModal = usePremiumModal() const premiumModal = usePremiumModal()
const { contextMenuOpen, contextMenuClickLocation } = navigationController const { contextMenuOpen, contextMenuClickLocation, application } = navigationController
const contextMenuRef = useRef<HTMLDivElement>(null) const contextMenuRef = useRef<HTMLDivElement>(null)
useCloseOnClickOutside(contextMenuRef, () => navigationController.setContextMenuOpen(false)) useCloseOnClickOutside(contextMenuRef, () => navigationController.setContextMenuOpen(false))
@@ -39,6 +40,7 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
const onClickRename = useCallback(() => { const onClickRename = useCallback(() => {
navigationController.setContextMenuOpen(false) navigationController.setContextMenuOpen(false)
navigationController.editingFrom = navigationController.contextMenuOpenFrom
navigationController.editingTag = selectedTag navigationController.editingTag = selectedTag
}, [navigationController, selectedTag]) }, [navigationController, selectedTag])
@@ -51,6 +53,15 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
[selectedTag.userModifiedDate], [selectedTag.userModifiedDate],
) )
const handleIconChange = (value?: VectorIconNameOrEmoji) => {
navigationController.setIcon(selectedTag, value || DefaultTagIconName)
}
const onClickStar = useCallback(() => {
navigationController.setFavorite(selectedTag, !selectedTag.starred).catch(console.error)
navigationController.setContextMenuOpen(false)
}, [navigationController, selectedTag])
const tagCreatedAt = useMemo(() => formatDateForContextMenu(selectedTag.created_at), [selectedTag.created_at]) const tagCreatedAt = useMemo(() => formatDateForContextMenu(selectedTag.created_at), [selectedTag.created_at])
return ( return (
@@ -62,6 +73,20 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
> >
<div ref={contextMenuRef}> <div ref={contextMenuRef}>
<Menu a11yLabel="Tag context menu" isOpen={contextMenuOpen}> <Menu a11yLabel="Tag context menu" isOpen={contextMenuOpen}>
<IconPicker
key={'icon-picker'}
onIconChange={handleIconChange}
selectedValue={selectedTag.iconString}
platform={application.platform}
className={'px-3 py-1.5'}
/>
<HorizontalSeparator classes="my-2" />
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>
<div className="flex items-center">
<Icon type="star" className="mr-2 text-neutral" />
{selectedTag.starred ? 'Unfavorite' : 'Favorite'}
</div>
</MenuItem>
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickAddSubtag}> <MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickAddSubtag}>
<div className="flex items-center"> <div className="flex items-center">
<Icon type="add" className="mr-2 text-neutral" /> <Icon type="add" className="mr-2 text-neutral" />

View File

@@ -0,0 +1 @@
export type TagListSectionType = 'all' | 'favorites'

View File

@@ -5,15 +5,17 @@ import { FunctionComponent, useCallback } from 'react'
import { DndProvider } from 'react-dnd' import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend' import { HTML5Backend } from 'react-dnd-html5-backend'
import RootTagDropZone from './RootTagDropZone' import RootTagDropZone from './RootTagDropZone'
import { TagListSectionType } from './TagListSection'
import { TagsListItem } from './TagsListItem' import { TagsListItem } from './TagsListItem'
type Props = { type Props = {
viewControllerManager: ViewControllerManager viewControllerManager: ViewControllerManager
type: TagListSectionType
} }
const TagsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) => { const TagsList: FunctionComponent<Props> = ({ viewControllerManager, type }: Props) => {
const tagsState = viewControllerManager.navigationController const navigationController = viewControllerManager.navigationController
const allTags = tagsState.allLocalRootTags const allTags = type === 'all' ? navigationController.allLocalRootTags : navigationController.starredTags
const backend = HTML5Backend const backend = HTML5Backend
@@ -23,10 +25,11 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) =>
x: posX, x: posX,
y: posY, y: posY,
}) })
viewControllerManager.navigationController.setContextMenuOpenFrom(type)
viewControllerManager.navigationController.reloadContextMenuLayout() viewControllerManager.navigationController.reloadContextMenuLayout()
viewControllerManager.navigationController.setContextMenuOpen(true) viewControllerManager.navigationController.setContextMenuOpen(true)
}, },
[viewControllerManager], [viewControllerManager, type],
) )
const onContextMenu = useCallback( const onContextMenu = useCallback(
@@ -49,17 +52,20 @@ const TagsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) =>
level={0} level={0}
key={tag.uuid} key={tag.uuid}
tag={tag} tag={tag}
tagsState={tagsState} type={type}
navigationController={navigationController}
features={viewControllerManager.featuresController} features={viewControllerManager.featuresController}
linkingController={viewControllerManager.linkingController} linkingController={viewControllerManager.linkingController}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
/> />
) )
})} })}
<RootTagDropZone {type === 'all' && (
tagsState={viewControllerManager.navigationController} <RootTagDropZone
featuresState={viewControllerManager.featuresController} tagsState={viewControllerManager.navigationController}
/> featuresState={viewControllerManager.featuresController}
/>
)}
</> </>
)} )}
</DndProvider> </DndProvider>

View File

@@ -5,13 +5,14 @@ import { KeyboardKey } from '@standardnotes/ui-services'
import { FeaturesController } from '@/Controllers/FeaturesController' import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController' import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import '@reach/tooltip/styles.css' import '@reach/tooltip/styles.css'
import { SNTag } from '@standardnotes/snjs' import { IconType, SNTag } from '@standardnotes/snjs'
import { computed } from 'mobx' import { computed } from 'mobx'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { import {
FormEventHandler, FormEventHandler,
FunctionComponent, FunctionComponent,
KeyboardEventHandler, KeyboardEventHandler,
MouseEvent,
MouseEventHandler, MouseEventHandler,
useCallback, useCallback,
useEffect, useEffect,
@@ -26,10 +27,12 @@ import { classNames } from '@/Utils/ConcatenateClassNames'
import { mergeRefs } from '@/Hooks/mergeRefs' import { mergeRefs } from '@/Hooks/mergeRefs'
import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider' import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider'
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import { TagListSectionType } from './TagListSection'
type Props = { type Props = {
tag: SNTag tag: SNTag
tagsState: NavigationController type: TagListSectionType
navigationController: NavigationController
features: FeaturesController features: FeaturesController
linkingController: LinkingController linkingController: LinkingController
level: number level: number
@@ -40,25 +43,27 @@ const PADDING_BASE_PX = 14
const PADDING_PER_LEVEL_PX = 21 const PADDING_PER_LEVEL_PX = 21
export const TagsListItem: FunctionComponent<Props> = observer( export const TagsListItem: FunctionComponent<Props> = observer(
({ tag, features, tagsState, level, onContextMenu, linkingController }) => { ({ tag, type, features, navigationController: navigationController, level, onContextMenu, linkingController }) => {
const { toggleAppPane } = useResponsiveAppPane() const { toggleAppPane } = useResponsiveAppPane()
const isFavorite = type === 'favorites'
const [title, setTitle] = useState(tag.title || '') const [title, setTitle] = useState(tag.title || '')
const [subtagTitle, setSubtagTitle] = useState('') const [subtagTitle, setSubtagTitle] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const subtagInputRef = useRef<HTMLInputElement>(null) const subtagInputRef = useRef<HTMLInputElement>(null)
const menuButtonRef = useRef<HTMLAnchorElement>(null) const menuButtonRef = useRef<HTMLAnchorElement>(null)
const isSelected = tagsState.selected === tag const isSelected = navigationController.selected === tag
const isEditing = tagsState.editingTag === tag const isEditing = navigationController.editingTag === tag && navigationController.editingFrom === type
const isAddingSubtag = tagsState.addingSubtagTo === tag const isAddingSubtag = navigationController.addingSubtagTo === tag
const noteCounts = computed(() => tagsState.getNotesCount(tag)) const noteCounts = computed(() => navigationController.getNotesCount(tag))
const childrenTags = computed(() => tagsState.getChildren(tag)).get() const childrenTags = computed(() => navigationController.getChildren(tag)).get()
const hasChildren = childrenTags.length > 0 const hasChildren = childrenTags.length > 0
const hasFolders = features.hasFolders const hasFolders = features.hasFolders
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder const hasAtLeastOneFolder = navigationController.hasAtLeastOneFolder
const premiumModal = usePremiumModal() const premiumModal = usePremiumModal()
@@ -76,27 +81,28 @@ export const TagsListItem: FunctionComponent<Props> = observer(
setTitle(tag.title || '') setTitle(tag.title || '')
}, [setTitle, tag]) }, [setTitle, tag])
const toggleChildren: MouseEventHandler = useCallback( const toggleChildren = useCallback(
(e) => { (e?: MouseEvent) => {
e.stopPropagation() e?.stopPropagation()
const shouldShowChildren = !showChildren const shouldShowChildren = !showChildren
setShowChildren(shouldShowChildren) setShowChildren(shouldShowChildren)
tagsState.setExpanded(tag, shouldShowChildren) navigationController.setExpanded(tag, shouldShowChildren)
}, },
[showChildren, tag, tagsState], [showChildren, tag, navigationController],
) )
const selectCurrentTag = useCallback(async () => { const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(tag, { await navigationController.setSelectedTag(tag, {
userTriggered: true, userTriggered: true,
}) })
toggleChildren()
toggleAppPane(AppPaneId.Items) toggleAppPane(AppPaneId.Items)
}, [tagsState, tag, toggleAppPane]) }, [navigationController, tag, toggleAppPane, toggleChildren])
const onBlur = useCallback(() => { const onBlur = useCallback(() => {
tagsState.save(tag, title).catch(console.error) navigationController.save(tag, title).catch(console.error)
setTitle(tag.title) setTitle(tag.title)
}, [tagsState, tag, title, setTitle]) }, [navigationController, tag, title, setTitle])
const onInput: FormEventHandler = useCallback( const onInput: FormEventHandler = useCallback(
(e) => { (e) => {
@@ -128,9 +134,9 @@ export const TagsListItem: FunctionComponent<Props> = observer(
}, []) }, [])
const onSubtagInputBlur = useCallback(() => { const onSubtagInputBlur = useCallback(() => {
tagsState.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error) navigationController.createSubtagAndAssignParent(tag, subtagTitle).catch(console.error)
setSubtagTitle('') setSubtagTitle('')
}, [subtagTitle, tag, tagsState]) }, [subtagTitle, tag, navigationController])
const onSubtagKeyDown: KeyboardEventHandler = useCallback( const onSubtagKeyDown: KeyboardEventHandler = useCallback(
(e) => { (e) => {
@@ -166,21 +172,21 @@ export const TagsListItem: FunctionComponent<Props> = observer(
() => ({ () => ({
accept: ItemTypes.TAG, accept: ItemTypes.TAG,
canDrop: (item) => { canDrop: (item) => {
return tagsState.isValidTagParent(tag, item as SNTag) return navigationController.isValidTagParent(tag, item as SNTag)
}, },
drop: (item) => { drop: (item) => {
if (!hasFolders) { if (!hasFolders) {
premiumModal.activate(TAG_FOLDERS_FEATURE_NAME) premiumModal.activate(TAG_FOLDERS_FEATURE_NAME)
return return
} }
tagsState.assignParent(item.uuid, tag.uuid).catch(console.error) navigationController.assignParent(item.uuid, tag.uuid).catch(console.error)
}, },
collect: (monitor) => ({ collect: (monitor) => ({
isOver: !!monitor.isOver(), isOver: !!monitor.isOver(),
canDrop: !!monitor.canDrop(), canDrop: !!monitor.canDrop(),
}), }),
}), }),
[tag, tagsState, hasFolders, premiumModal], [tag, navigationController, hasFolders, premiumModal],
) )
const readyToDrop = isOver && canDrop const readyToDrop = isOver && canDrop
@@ -194,16 +200,16 @@ export const TagsListItem: FunctionComponent<Props> = observer(
return return
} }
const contextMenuOpen = tagsState.contextMenuOpen const contextMenuOpen = navigationController.contextMenuOpen
const menuButtonRect = menuButtonRef.current?.getBoundingClientRect() const menuButtonRect = menuButtonRef.current?.getBoundingClientRect()
if (contextMenuOpen) { if (contextMenuOpen) {
tagsState.setContextMenuOpen(false) navigationController.setContextMenuOpen(false)
} else { } else {
onContextMenu(tag, menuButtonRect.right, menuButtonRect.top) onContextMenu(tag, menuButtonRect.right, menuButtonRect.top)
} }
}, },
[onContextMenu, tagsState, tag], [onContextMenu, navigationController, tag],
) )
const tagRef = useRef<HTMLDivElement>(null) const tagRef = useRef<HTMLDivElement>(null)
@@ -248,7 +254,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
}} }}
> >
<div className="tag-info" title={title} ref={dropRef}> <div className="tag-info" title={title} ref={dropRef}>
{hasAtLeastOneFolder && ( {hasAtLeastOneFolder && !isFavorite && (
<div className="tag-fold-container"> <div className="tag-fold-container">
<a <a
role="button" role="button"
@@ -262,12 +268,12 @@ export const TagsListItem: FunctionComponent<Props> = observer(
</div> </div>
)} )}
<div className={'tag-icon draggable mr-2'} ref={dragRef}> <div className={'tag-icon draggable mr-2'} ref={dragRef}>
<Icon type="hashtag" className={`${isSelected ? 'text-info' : 'text-neutral'}`} /> <Icon type={tag.iconString as IconType} className={`${isSelected ? 'text-info' : 'text-neutral'}`} />
</div> </div>
{isEditing ? ( {isEditing ? (
<input <input
className={'title editing focus:shadow-none focus:outline-none'} className={'title editing focus:shadow-none focus:outline-none'}
id={`react-tag-${tag.uuid}`} id={`react-tag-${tag.uuid}-${type}`}
onBlur={onBlur} onBlur={onBlur}
onInput={onInput} onInput={onInput}
value={title} value={title}
@@ -278,7 +284,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
) : ( ) : (
<div <div
className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'} className={'title overflow-hidden text-left focus:shadow-none focus:outline-none'}
id={`react-tag-${tag.uuid}`} id={`react-tag-${tag.uuid}-${type}`}
> >
{title} {title}
</div> </div>
@@ -334,7 +340,8 @@ export const TagsListItem: FunctionComponent<Props> = observer(
level={level + 1} level={level + 1}
key={tag.uuid} key={tag.uuid}
tag={tag} tag={tag}
tagsState={tagsState} type={type}
navigationController={navigationController}
features={features} features={features}
linkingController={linkingController} linkingController={linkingController}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}

View File

@@ -52,22 +52,37 @@ const TagsSection: FunctionComponent<Props> = ({ viewControllerManager }) => {
}, [viewControllerManager, checkIfMigrationNeeded]) }, [viewControllerManager, checkIfMigrationNeeded])
return ( return (
<section> <>
<div className={'section-title-bar'}> {viewControllerManager.navigationController.starredTags.length > 0 && (
<div className="section-title-bar-header"> <section>
<TagsSectionTitle <div className={'section-title-bar'}>
features={viewControllerManager.featuresController} <div className="section-title-bar-header">
hasMigration={hasMigration} <div className="title text-sm">
onClickMigration={runMigration} <span className="font-bold">Favorites</span>
/> </div>
<TagsSectionAddButton </div>
tags={viewControllerManager.navigationController} </div>
features={viewControllerManager.featuresController} <TagsList type="favorites" viewControllerManager={viewControllerManager} />
/> </section>
)}
<section>
<div className={'section-title-bar'}>
<div className="section-title-bar-header">
<TagsSectionTitle
features={viewControllerManager.featuresController}
hasMigration={hasMigration}
onClickMigration={runMigration}
/>
<TagsSectionAddButton
tags={viewControllerManager.navigationController}
features={viewControllerManager.featuresController}
/>
</div>
</div> </div>
</div> <TagsList type="all" viewControllerManager={viewControllerManager} />
<TagsList viewControllerManager={viewControllerManager} /> </section>
</section> </>
) )
} }

View File

@@ -64,7 +64,7 @@ export class ItemListController
items: ListableContentItem[] = [] items: ListableContentItem[] = []
notesToDisplay = 0 notesToDisplay = 0
pageSize = 0 pageSize = 0
panelTitle = 'All Notes' panelTitle = 'Notes'
panelWidth = 0 panelWidth = 0
renderedItems: ListableContentItem[] = [] renderedItems: ListableContentItem[] = []
searchSubmitted = false searchSubmitted = false

View File

@@ -236,9 +236,11 @@ export class LinkingController extends AbstractViewController {
} else if (item instanceof FileItem) { } else if (item instanceof FileItem) {
const icon = this.application.iconsController.getIconForFileType(item.mimeType) const icon = this.application.iconsController.getIconForFileType(item.mimeType)
return [icon, 'text-info'] return [icon, 'text-info']
} else if (item instanceof SNTag) {
return [item.iconString as IconType, 'text-info']
} }
return ['hashtag', 'text-info'] throw new Error('Unhandled case in getLinkedItemIcon')
} }
activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => { activateItem = async (item: LinkableItem): Promise<AppPaneId | undefined> => {

View File

@@ -14,6 +14,7 @@ import {
SystemViewId, SystemViewId,
InternalEventBus, InternalEventBus,
InternalEventPublishStrategy, InternalEventPublishStrategy,
VectorIconNameOrEmoji,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx' import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application' import { WebApplication } from '../../Application/Application'
@@ -24,6 +25,7 @@ import { AnyTag } from './AnyTagType'
import { CrossControllerEvent } from '../CrossControllerEvent' import { CrossControllerEvent } from '../CrossControllerEvent'
import { AbstractViewController } from '../Abstract/AbstractViewController' import { AbstractViewController } from '../Abstract/AbstractViewController'
import { Persistable } from '../Abstract/Persistable' import { Persistable } from '../Abstract/Persistable'
import { TagListSectionType } from '@/Components/Tags/TagListSection'
export type NavigationControllerPersistableValue = { export type NavigationControllerPersistableValue = {
selectedTagUuid: AnyTag['uuid'] selectedTagUuid: AnyTag['uuid']
@@ -35,14 +37,17 @@ export class NavigationController
{ {
tags: SNTag[] = [] tags: SNTag[] = []
smartViews: SmartView[] = [] smartViews: SmartView[] = []
starredTags: SNTag[] = []
allNotesCount_ = 0 allNotesCount_ = 0
selectedUuid: AnyTag['uuid'] | undefined = undefined selectedUuid: AnyTag['uuid'] | undefined = undefined
selected_: AnyTag | undefined selected_: AnyTag | undefined
previouslySelected_: AnyTag | undefined previouslySelected_: AnyTag | undefined
editing_: SNTag | SmartView | undefined editing_: SNTag | SmartView | undefined
editingFrom?: TagListSectionType
addingSubtagTo: SNTag | undefined addingSubtagTo: SNTag | undefined
contextMenuOpen = false contextMenuOpen = false
contextMenuOpenFrom?: TagListSectionType
contextMenuPosition: { top?: number; left: number; bottom?: number } = { contextMenuPosition: { top?: number; left: number; bottom?: number } = {
top: 0, top: 0,
left: 0, left: 0,
@@ -66,6 +71,7 @@ export class NavigationController
makeObservable(this, { makeObservable(this, {
tags: observable, tags: observable,
starredTags: observable,
smartViews: observable.ref, smartViews: observable.ref,
hasAtLeastOneFolder: computed, hasAtLeastOneFolder: computed,
allNotesCount_: observable, allNotesCount_: observable,
@@ -111,7 +117,7 @@ export class NavigationController
this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => { this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => {
runInAction(() => { runInAction(() => {
this.tags = this.application.items.getDisplayableTags() this.tags = this.application.items.getDisplayableTags()
this.starredTags = this.tags.filter((tag) => tag.starred)
this.smartViews = this.application.items.getSmartViews() this.smartViews = this.application.items.getSmartViews()
const currentSelectedTag = this.selected_ const currentSelectedTag = this.selected_
@@ -266,6 +272,10 @@ export class NavigationController
this.addingSubtagTo = tag this.addingSubtagTo = tag
} }
setContextMenuOpenFrom(section: TagListSectionType): void {
this.contextMenuOpenFrom = section
}
setContextMenuOpen(open: boolean): void { setContextMenuOpen(open: boolean): void {
this.contextMenuOpen = open this.contextMenuOpen = open
} }
@@ -476,6 +486,22 @@ export class NavigationController
.catch(console.error) .catch(console.error)
} }
public async setFavorite(tag: SNTag, favorite: boolean) {
return this.application.mutator
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
mutator.starred = favorite
})
.catch(console.error)
}
public setIcon(tag: SNTag, icon: VectorIconNameOrEmoji) {
this.application.mutator
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
mutator.iconString = icon
})
.catch(console.error)
}
public get editingTag(): SNTag | SmartView | undefined { public get editingTag(): SNTag | SmartView | undefined {
return this.editing_ return this.editing_
} }
@@ -500,6 +526,7 @@ export class NavigationController
} }
public undoCreateNewTag() { public undoCreateNewTag() {
this.editingFrom = undefined
this.editing_ = undefined this.editing_ = undefined
const previousTag = this.previouslySelected_ || this.smartViews[0] const previousTag = this.previouslySelected_ || this.smartViews[0]
void this.setSelectedTag(previousTag) void this.setSelectedTag(previousTag)
@@ -528,6 +555,7 @@ export class NavigationController
const hasDuplicatedTitle = siblings.some((other) => other.title.toLowerCase() === newTitle.toLowerCase()) const hasDuplicatedTitle = siblings.some((other) => other.title.toLowerCase() === newTitle.toLowerCase())
runInAction(() => { runInAction(() => {
this.editingFrom = undefined
this.editing_ = undefined this.editing_ = undefined
}) })

View File

@@ -6666,6 +6666,7 @@ __metadata:
"@types/jest": ^28.1.5 "@types/jest": ^28.1.5
"@types/lodash": ^4.14.182 "@types/lodash": ^4.14.182
"@typescript-eslint/eslint-plugin": ^5.30.0 "@typescript-eslint/eslint-plugin": ^5.30.0
eslint: "*"
eslint-plugin-prettier: "*" eslint-plugin-prettier: "*"
jest: ^28.1.2 jest: ^28.1.2
lodash: ^4.17.21 lodash: ^4.17.21