feat: native smart tags (#782)

* feat: introduce native smart tags

* feat: introduce react navigation

* feat: render smart tag special cases

* feat: add create tag & all count

* feat: move components to react + mobx

* fix: workaround issue with snjs

* feat: nice smart tag icons in experimental

* feat: add back components

* fix: typo on all tags

* feat: add panel resizer + simplif code

* fix: panel resize size & refresh

* fix: auto select all notes

* style: remove legacy tag view

* style: remove legacy directives

* fix: select tag from note view

* feat: WIP smart tag rename

* fix: template checks

* fix: user can create new notes

* panel: init width

* fix: panel resizer ref

* fix: update with new component viewer

* fix: use fixed isTemplateItem & fixed findItems

* refactor: rename tags panel into navigation

* style: remove TODOs that are ok

* feat: smart tag premium check with premium service

* refactor: multi-select variables for debuggability

* fix: clean deinit code

* fix: prevent trigger tag changes event for the same uuid

* fix: typings

* fix: use minimal state

* style: remove dead code

* style: long variable names

* refactor: move magic string to module

* fix: use smart filter feature

* refactor: add task id in todo
This commit is contained in:
Laurent Senta
2022-01-04 14:02:58 +01:00
committed by GitHub
parent 7dd4a60595
commit c3772e06b4
33 changed files with 1030 additions and 868 deletions

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.55556 3C3.69222 3 3 3.69222 3 4.55556V15.4444C3 16.3078 3.69222 17 4.55556 17H15.4444C16.3078 17 17 16.3078 17 15.4444V4.55556C17 3.69222 16.3078 3 15.4444 3H4.55556ZM4.55556 4.55556H15.4444V15.4444H4.55556V4.55556ZM6.11111 6.11111V7.66667H13.8889V6.11111H6.11111ZM6.11111 9.22222V10.7778H13.8889V9.22222H6.11111ZM6.11111 12.3333V13.8889H11.5556V12.3333H6.11111Z"/>
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@@ -20,23 +20,32 @@ declare global {
} }
} }
import { SNLog } from '@standardnotes/snjs'; import { ComponentViewDirective } from '@/components/ComponentView';
import angular from 'angular'; import { NavigationDirective } from '@/components/Navigation';
import { configRoutes } from './routes'; import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { IsWebPlatform, WebAppVersion } from '@/version';
import { ApplicationGroup } from './ui_models/application_group';
import { AccountSwitcher } from './views/account_switcher/account_switcher';
import { import {
ApplicationGroupView, ApplicationGroupView,
ApplicationView, ApplicationView, ChallengeModal,
NoteGroupViewDirective, FooterView, NoteGroupViewDirective,
NoteViewDirective, NoteViewDirective
TagsView,
FooterView,
ChallengeModal,
} from '@/views'; } from '@/views';
import { SNLog } from '@standardnotes/snjs';
import angular from 'angular';
import { AccountMenuDirective } from './components/AccountMenu';
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
import { IconDirective } from './components/Icon';
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
import { NoAccountWarningDirective } from './components/NoAccountWarning';
import { NotesContextMenuDirective } from './components/NotesContextMenu';
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
import { NotesViewDirective } from './components/NotesView';
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
import { SearchOptionsDirective } from './components/SearchOptions';
import { SessionsModalDirective } from './components/SessionsModal';
import { import {
autofocus, autofocus,
clickOutside, clickOutside,
@@ -46,49 +55,31 @@ import {
infiniteScroll, infiniteScroll,
lowercase, lowercase,
selectOnFocus, selectOnFocus,
snEnter, snEnter
} from './directives/functional'; } from './directives/functional';
import { import {
ActionsMenu, ActionsMenu,
EditorMenu, EditorMenu,
HistoryMenu,
InputModal, InputModal,
MenuRow, MenuRow,
PanelResizer, PanelResizer,
PasswordWizard, PasswordWizard,
PermissionsModal, PermissionsModal,
RevisionPreviewModal, RevisionPreviewModal,
HistoryMenu, SyncResolutionMenu
SyncResolutionMenu,
} from './directives/views'; } from './directives/views';
import { trusted } from './filters'; import { trusted } from './filters';
import { isDev } from './utils'; import { PreferencesDirective } from './preferences';
import { PurchaseFlowDirective } from './purchaseFlow';
import { configRoutes } from './routes';
import { Bridge } from './services/bridge';
import { BrowserBridge } from './services/browserBridge'; import { BrowserBridge } from './services/browserBridge';
import { startErrorReporting } from './services/errorReporting'; import { startErrorReporting } from './services/errorReporting';
import { StartApplication } from './startApplication'; import { StartApplication } from './startApplication';
import { Bridge } from './services/bridge'; import { ApplicationGroup } from './ui_models/application_group';
import { SessionsModalDirective } from './components/SessionsModal'; import { isDev } from './utils';
import { NoAccountWarningDirective } from './components/NoAccountWarning'; import { AccountSwitcher } from './views/account_switcher/account_switcher';
import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay';
import { SearchOptionsDirective } from './components/SearchOptions';
import { AccountMenuDirective } from './components/AccountMenu';
import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal';
import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes';
import { NotesContextMenuDirective } from './components/NotesContextMenu';
import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel';
import { IconDirective } from './components/Icon';
import { NoteTagsContainerDirective } from './components/NoteTagsContainer';
import { PreferencesDirective } from './preferences';
import { WebAppVersion, IsWebPlatform } from '@/version';
import { NotesListOptionsDirective } from './components/NotesListOptionsMenu';
import { PurchaseFlowDirective } from './purchaseFlow';
import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu';
import { ComponentViewDirective } from '@/components/ComponentView';
import { TagsListDirective } from '@/components/TagsList';
import { NotesViewDirective } from './components/NotesView';
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { TagsSectionDirective } from './components/Tags/TagsSection';
function reloadHiddenFirefoxTab(): boolean { function reloadHiddenFirefoxTab(): boolean {
/** /**
@@ -143,7 +134,6 @@ const startApplication: StartApplication = async function startApplication(
.directive('applicationView', () => new ApplicationView()) .directive('applicationView', () => new ApplicationView())
.directive('noteGroupView', () => new NoteGroupViewDirective()) .directive('noteGroupView', () => new NoteGroupViewDirective())
.directive('noteView', () => new NoteViewDirective()) .directive('noteView', () => new NoteViewDirective())
.directive('tagsView', () => new TagsView())
.directive('footerView', () => new FooterView()); .directive('footerView', () => new FooterView());
// Directives - Functional // Directives - Functional
@@ -188,8 +178,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective) .directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective) .directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('tagsList', TagsListDirective) .directive('navigation', NavigationDirective)
.directive('tagsSection', TagsSectionDirective)
.directive('preferences', PreferencesDirective) .directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective) .directive('purchaseFlow', PurchaseFlowDirective)
.directive('notesView', NotesViewDirective) .directive('notesView', NotesViewDirective)

View File

@@ -23,6 +23,7 @@ import AuthenticatorIcon from '../../icons/ic-authenticator.svg';
import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg'; import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg';
import TasksIcon from '../../icons/ic-tasks.svg'; import TasksIcon from '../../icons/ic-tasks.svg';
import MarkdownIcon from '../../icons/ic-markdown.svg'; import MarkdownIcon from '../../icons/ic-markdown.svg';
import NotesIcon from '../../icons/ic-notes.svg';
import CodeIcon from '../../icons/ic-code.svg'; import CodeIcon from '../../icons/ic-code.svg';
import AccessibilityIcon from '../../icons/ic-accessibility.svg'; import AccessibilityIcon from '../../icons/ic-accessibility.svg';
@@ -69,6 +70,7 @@ import { FunctionalComponent } from 'preact';
const ICONS = { const ICONS = {
'menu-arrow-down-alt': MenuArrowDownAlt, 'menu-arrow-down-alt': MenuArrowDownAlt,
'menu-arrow-right': MenuArrowRight, 'menu-arrow-right': MenuArrowRight,
notes: NotesIcon,
'arrows-sort-up': ArrowsSortUpIcon, 'arrows-sort-up': ArrowsSortUpIcon,
'arrows-sort-down': ArrowsSortDownIcon, 'arrows-sort-down': ArrowsSortDownIcon,
lock: LockIcon, lock: LockIcon,

View File

@@ -0,0 +1,117 @@
import { ComponentView } from '@/components/ComponentView';
import { PanelResizer } from '@/components/PanelResizer';
import { SmartTagsSection } from '@/components/Tags/SmartTagsSection';
import { TagsSection } from '@/components/Tags/TagsSection';
import { toDirective } from '@/components/utils';
import {
PanelSide,
ResizeFinishCallback,
} from '@/directives/views/panelResizer';
import { WebApplication } from '@/ui_models/application';
import { PANEL_NAME_NAVIGATION } from '@/views/constants';
import { PrefKey } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks';
import { PremiumModalProvider } from './Premium';
type Props = {
application: WebApplication;
};
export const Navigation: FunctionComponent<Props> = observer(
({ application }) => {
const appState = useMemo(() => application.getAppState(), [application]);
const componentViewer = appState.foldersComponentViewer;
const enableNativeSmartTagsFeature =
appState.features.enableNativeSmartTagsFeature;
const [panelRef, setPanelRef] = useState<HTMLDivElement | null>(null);
useEffect(() => {
const elem = document.querySelector(
'navigation'
) as HTMLDivElement | null;
setPanelRef(elem);
}, [setPanelRef]);
const onCreateNewTag = useCallback(() => {
appState.tags.createNewTemplate();
}, [appState]);
const panelResizeFinishCallback: ResizeFinishCallback = useCallback(
(_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => {
appState.noteTags.reloadTagsContainerMaxWidth();
appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed);
},
[appState]
);
const panelWidthEventCallback = useCallback(() => {
appState.noteTags.reloadTagsContainerMaxWidth();
}, [appState]);
return (
<PremiumModalProvider state={appState.features}>
<div
id="tags-column"
ref={setPanelRef}
className="sn-component section tags"
data-aria-label="Tags"
>
{componentViewer ? (
<div className="component-view-container">
<div className="component-view">
<ComponentView
componentViewer={componentViewer}
application={application}
appState={appState}
/>
</div>
</div>
) : (
<div id="tags-content" className="content">
<div className="tags-title-section section-title-bar">
<div className="section-title-bar-header">
<div className="sk-h3 title">
<span className="sk-bold">Views</span>
</div>
{!enableNativeSmartTagsFeature && (
<div
className="sk-button sk-secondary-contrast wide"
onClick={onCreateNewTag}
title="Create a new tag"
>
<div className="sk-label">
<i className="icon ion-plus add-button" />
</div>
</div>
)}
</div>
</div>
<div className="scrollable">
<div className="infinite-scroll">
<SmartTagsSection appState={appState} />
<TagsSection appState={appState} />
</div>
</div>
</div>
)}
{panelRef && (
<PanelResizer
application={application}
collapsable={true}
defaultWidth={150}
panel={panelRef}
prefKey={PrefKey.TagsPanelWidth}
side={PanelSide.Right}
resizeFinishCallback={panelResizeFinishCallback}
widthEventCallback={panelWidthEventCallback}
/>
)}
</div>
</PremiumModalProvider>
);
}
);
export const NavigationDirective = toDirective<Props>(Navigation);

View File

@@ -32,7 +32,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => {
const onTagClick = (event: MouseEvent) => { const onTagClick = (event: MouseEvent) => {
if (tagClicked && event.target !== deleteTagRef.current) { if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false); setTagClicked(false);
appState.setSelectedTag(tag); appState.selectedTag = tag;
} else { } else {
setTagClicked(true); setTagClicked(true);
} }

View File

@@ -124,9 +124,9 @@ const NotesView: FunctionComponent<Props> = observer(
}; };
const panelResizeFinishCallback: ResizeFinishCallback = ( const panelResizeFinishCallback: ResizeFinishCallback = (
_w, _lastWidth,
_l, _lastLeft,
_mw, _isMaxWidth,
isCollapsed isCollapsed
) => { ) => {
appState.noteTags.reloadTagsContainerMaxWidth(); appState.noteTags.reloadTagsContainerMaxWidth();

View File

@@ -1,3 +1,5 @@
import { FeaturesState } from '@/ui_models/app_state/features_state';
import { observer } from 'mobx-react-lite';
import { FunctionalComponent } from 'preact'; import { FunctionalComponent } from 'preact';
import { useCallback, useContext, useState } from 'preact/hooks'; import { useCallback, useContext, useState } from 'preact/hooks';
import { createContext } from 'react'; import { createContext } from 'react';
@@ -21,29 +23,31 @@ export const usePremiumModal = (): PremiumModalContextData => {
return value; return value;
}; };
export const PremiumModalProvider: FunctionalComponent = ({ children }) => { interface Props {
const [featureName, setFeatureName] = useState<null | string>(null); state: FeaturesState;
}
const activate = setFeatureName; export const PremiumModalProvider: FunctionalComponent<Props> = observer(
({ state, children }) => {
const featureName = state._premiumAlertFeatureName;
const activate = state.showPremiumAlert;
const close = state.closePremiumAlert;
const closeModal = useCallback(() => { const showModal = !!featureName;
setFeatureName(null);
}, [setFeatureName]);
const showModal = !!featureName; return (
<>
return ( {showModal && (
<> <PremiumFeaturesModal
{showModal && ( showModal={!!featureName}
<PremiumFeaturesModal featureName={featureName}
showModal={!!featureName} onClose={close}
featureName={featureName} />
onClose={closeModal} )}
/> <PremiumModalProvider_ value={{ activate }}>
)} {children}
<PremiumModalProvider_ value={{ activate }}> </PremiumModalProvider_>
{children} </>
</PremiumModalProvider_> );
</> }
); );
};

View File

@@ -1,3 +1,5 @@
import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium';
import { import {
FeaturesState, FeaturesState,
TAG_FOLDERS_FEATURE_NAME, TAG_FOLDERS_FEATURE_NAME,
@@ -5,9 +7,7 @@ import {
import { TagsState } from '@/ui_models/app_state/tags_state'; import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd'; import { useDrop } from 'react-dnd';
import { Icon } from './Icon'; import { DropItem, DropProps, ItemTypes } from './dragndrop';
import { usePremiumModal } from './Premium';
import { DropItem, DropProps, ItemTypes } from './TagsListItem';
type Props = { type Props = {
tagsState: TagsState; tagsState: TagsState;
@@ -18,7 +18,7 @@ export const RootTagDropZone: React.FC<Props> = observer(
({ tagsState, featuresState }) => { ({ tagsState, featuresState }) => {
const premiumModal = usePremiumModal(); const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature; const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
const hasFolders = tagsState.hasFolders; const hasFolders = featuresState.hasFolders;
const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>( const [{ isOver, canDrop }, dropRef] = useDrop<DropItem, void, DropProps>(
() => ({ () => ({

View File

@@ -0,0 +1,29 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { SmartTagsListItem } from './SmartTagsListItem';
type Props = {
appState: AppState;
};
export const SmartTagsList: FunctionComponent<Props> = observer(
({ appState }) => {
const allTags = appState.tags.smartTags;
return (
<>
{allTags.map((tag) => {
return (
<SmartTagsListItem
key={tag.uuid}
tag={tag}
tagsState={appState.tags}
features={appState.features}
/>
);
})}
</>
);
}
);

View File

@@ -0,0 +1,163 @@
import { Icon, IconType } from '@/components/Icon';
import { FeaturesState } from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css';
import { SNSmartTag } from '@standardnotes/snjs';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
type Props = {
tag: SNSmartTag;
tagsState: TagsState;
features: FeaturesState;
};
const smartTagIconType = (tag: SNSmartTag): IconType => {
if (tag.isAllTag) {
return 'notes';
}
if (tag.isArchiveTag) {
return 'archive';
}
if (tag.isTrashTag) {
return 'trash';
}
return 'hashtag';
};
export const SmartTagsListItem: FunctionComponent<Props> = observer(
({ tag, tagsState, features }) => {
const [title, setTitle] = useState(tag.title || '');
const inputRef = useRef<HTMLInputElement>(null);
const level = 0;
const isSelected = tagsState.selected === tag;
const isEditing = tagsState.editingTag === tag;
const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
useEffect(() => {
setTitle(tag.title || '');
}, [setTitle, tag]);
const selectCurrentTag = useCallback(() => {
tagsState.selected = tag;
}, [tagsState, tag]);
const onBlur = useCallback(() => {
tagsState.save(tag, title);
setTitle(tag.title);
}, [tagsState, tag, title, setTitle]);
const onInput = useCallback(
(e: Event) => {
const value = (e.target as HTMLInputElement).value;
setTitle(value);
},
[setTitle]
);
const onKeyUp = useCallback(
(e: KeyboardEvent) => {
if (e.code === 'Enter') {
inputRef.current?.blur();
e.preventDefault();
}
},
[inputRef]
);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
}
}, [inputRef, isEditing]);
const onClickRename = useCallback(() => {
tagsState.editingTag = tag;
}, [tagsState, tag]);
const onClickSave = useCallback(() => {
inputRef.current?.blur();
}, [inputRef]);
const onClickDelete = useCallback(() => {
tagsState.remove(tag);
}, [tagsState, tag]);
const isFaded = !isSmartTagsEnabled && !tag.isAllTag;
const iconType = smartTagIconType(tag);
return (
<>
<div
className={`tag ${isSelected ? 'selected' : ''} ${
isFaded ? 'faded' : ''
}`}
onClick={selectCurrentTag}
style={{ paddingLeft: `${level + 0.5}rem` }}
>
{!tag.errorDecrypting ? (
<div className="tag-info">
{isSmartTagsEnabled && (
<div className={`tag-icon mr-1`}>
<Icon
type={iconType}
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
/>
</div>
)}
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
spellCheck={false}
ref={inputRef}
/>
<div className="count">
{tag.isAllTag && tagsState.allNotesCount}
</div>
</div>
) : null}
{!tag.isSystemSmartTag && (
<div className="meta">
{tag.conflictOf && (
<div className="danger small-text font-bold">
Conflicted Copy {tag.conflictOf}
</div>
)}
{tag.errorDecrypting && !tag.waitingForKey && (
<div className="danger small-text font-bold">Missing Keys</div>
)}
{tag.errorDecrypting && tag.waitingForKey && (
<div className="info small-text font-bold">
Waiting For Keys
</div>
)}
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
)}
<a className="item" onClick={onClickDelete}>
Delete
</a>
</div>
)}
</div>
)}
</div>
</>
);
}
);

View File

@@ -0,0 +1,19 @@
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { SmartTagsList } from './SmartTagsList';
type Props = {
appState: AppState;
};
export const SmartTagsSection: FunctionComponent<Props> = observer(
({ appState }) => {
return (
<section>
<SmartTagsList appState={appState} />
</section>
);
}
);

View File

@@ -0,0 +1,48 @@
import { AppState } from '@/ui_models/app_state';
import { isMobile } from '@/utils';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';
import { RootTagDropZone } from './RootTagDropZone';
import { TagsListItem } from './TagsListItem';
type Props = {
appState: AppState;
};
export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
const tagsState = appState.tags;
const allTags = tagsState.allLocalRootTags;
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
return (
<DndProvider backend={backend}>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
tagsState={tagsState}
features={appState.features}
/>
);
})}
<RootTagDropZone
tagsState={appState.tags}
featuresState={appState.features}
/>
</>
)}
</DndProvider>
);
});

View File

@@ -1,3 +1,5 @@
import { Icon } from '@/components/Icon';
import { usePremiumModal } from '@/components/Premium';
import { import {
FeaturesState, FeaturesState,
TAG_FOLDERS_FEATURE_NAME, TAG_FOLDERS_FEATURE_NAME,
@@ -5,56 +7,36 @@ import {
import { TagsState } from '@/ui_models/app_state/tags_state'; import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css'; import '@reach/tooltip/styles.css';
import { SNTag } from '@standardnotes/snjs'; import { SNTag } from '@standardnotes/snjs';
import { computed, runInAction } from 'mobx'; import { computed } from 'mobx';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent, JSX } from 'preact'; import { FunctionComponent, JSX } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useDrag, useDrop } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd';
import { Icon } from './Icon'; import { DropItem, DropProps, ItemTypes } from './dragndrop';
import { usePremiumModal } from './Premium';
export enum ItemTypes {
TAG = 'TAG',
}
export type DropItemTag = { uuid: string };
export type DropItem = DropItemTag;
export type DropProps = { isOver: boolean; canDrop: boolean };
type Props = { type Props = {
tag: SNTag; tag: SNTag;
tagsState: TagsState; tagsState: TagsState;
selectTag: (tag: SNTag) => void; features: FeaturesState;
removeTag: (tag: SNTag) => void;
saveTag: (tag: SNTag, newTitle: string) => void;
appState: TagsListState;
level: number; level: number;
}; };
export type TagsListState = {
readonly selectedTag: SNTag | undefined;
tags: TagsState;
editingTag: SNTag | undefined;
features: FeaturesState;
};
export const TagsListItem: FunctionComponent<Props> = observer( export const TagsListItem: FunctionComponent<Props> = observer(
({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => { ({ tag, features, tagsState, level }) => {
const [title, setTitle] = useState(tag.title || ''); const [title, setTitle] = useState(tag.title || '');
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const isSelected = appState.selectedTag === tag; const isSelected = tagsState.selected === tag;
const isEditing = appState.editingTag === tag; const isEditing = tagsState.editingTag === tag;
const noteCounts = computed(() => appState.tags.getNotesCount(tag)); const noteCounts = computed(() => tagsState.getNotesCount(tag));
const childrenTags = computed(() => tagsState.getChildren(tag)).get(); const childrenTags = computed(() => tagsState.getChildren(tag)).get();
const hasChildren = childrenTags.length > 0; const hasChildren = childrenTags.length > 0;
const hasFolders = tagsState.hasFolders; const hasFolders = features.hasFolders;
const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature; const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder; const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
const premiumModal = usePremiumModal(); const premiumModal = usePremiumModal();
const [showChildren, setShowChildren] = useState(hasChildren); const [showChildren, setShowChildren] = useState(hasChildren);
@@ -80,16 +62,13 @@ export const TagsListItem: FunctionComponent<Props> = observer(
); );
const selectCurrentTag = useCallback(() => { const selectCurrentTag = useCallback(() => {
if (isEditing || isSelected) { tagsState.selected = tag;
return; }, [tagsState, tag]);
}
selectTag(tag);
}, [isSelected, isEditing, selectTag, tag]);
const onBlur = useCallback(() => { const onBlur = useCallback(() => {
saveTag(tag, title); tagsState.save(tag, title);
setTitle(tag.title); setTitle(tag.title);
}, [tag, saveTag, title, setTitle]); }, [tagsState, tag, title, setTitle]);
const onInput = useCallback( const onInput = useCallback(
(e: JSX.TargetedEvent<HTMLInputElement>) => { (e: JSX.TargetedEvent<HTMLInputElement>) => {
@@ -116,18 +95,16 @@ export const TagsListItem: FunctionComponent<Props> = observer(
}, [inputRef, isEditing]); }, [inputRef, isEditing]);
const onClickRename = useCallback(() => { const onClickRename = useCallback(() => {
runInAction(() => { tagsState.editingTag = tag;
appState.editingTag = tag; }, [tagsState, tag]);
});
}, [appState, tag]);
const onClickSave = useCallback(() => { const onClickSave = useCallback(() => {
inputRef.current?.blur(); inputRef.current?.blur();
}, [inputRef]); }, [inputRef]);
const onClickDelete = useCallback(() => { const onClickDelete = useCallback(() => {
removeTag(tag); tagsState.remove(tag);
}, [removeTag, tag]); }, [tagsState, tag]);
const [, dragRef] = useDrag( const [, dragRef] = useDrag(
() => ({ () => ({
@@ -255,10 +232,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
key={tag.uuid} key={tag.uuid}
tag={tag} tag={tag}
tagsState={tagsState} tagsState={tagsState}
selectTag={selectTag} features={features}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/> />
); );
})} })}

View File

@@ -1,108 +1,29 @@
import { TagsList } from '@/components/TagsList'; import { TagsList } from '@/components/Tags/TagsList';
import { toDirective } from '@/components/utils';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state'; import { AppState } from '@/ui_models/app_state';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
TAG_FOLDERS_FEATURE_TOOLTIP,
} from '@/ui_models/app_state/features_state';
import { Tooltip } from '@reach/tooltip';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact'; import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks'; import { TagsSectionAddButton } from './TagsSectionAddButton';
import { IconButton } from '../IconButton'; import { TagsSectionTitle } from './TagsSectionTitle';
import { PremiumModalProvider, usePremiumModal } from '../Premium';
type Props = { type Props = {
application: WebApplication;
appState: AppState; appState: AppState;
}; };
const TagAddButton: FunctionComponent<{
appState: AppState;
features: FeaturesState;
}> = observer(({ appState, features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
if (!isNativeFoldersEnabled) {
return null;
}
return (
<IconButton
icon="add"
title="Create a new tag"
focusable={true}
onClick={() => appState.createNewTag()}
/>
);
});
const TagTitle: FunctionComponent<{
features: FeaturesState;
}> = observer(({ features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasFolders = features.hasFolders;
const modal = usePremiumModal();
const showPremiumAlert = useCallback(() => {
modal.activate(TAG_FOLDERS_FEATURE_NAME);
}, [modal]);
if (!isNativeFoldersEnabled) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
</>
);
}
if (hasFolders) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Folders</span>
</div>
</>
);
}
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
<Tooltip label={TAG_FOLDERS_FEATURE_TOOLTIP}>
<label
className="ml-1 sk-bold color-grey-2 cursor-pointer"
onClick={showPremiumAlert}
>
Folders
</label>
</Tooltip>
</div>
</>
);
});
export const TagsSection: FunctionComponent<Props> = observer( export const TagsSection: FunctionComponent<Props> = observer(
({ application, appState }) => { ({ appState }) => {
return ( return (
<PremiumModalProvider> <section>
<section> <div className="tags-title-section section-title-bar">
<div className="tags-title-section section-title-bar"> <div className="section-title-bar-header">
<div className="section-title-bar-header"> <TagsSectionTitle features={appState.features} />
<TagTitle features={appState.features} /> <TagsSectionAddButton
<TagAddButton appState={appState} features={appState.features} /> tags={appState.tags}
</div> features={appState.features}
/>
</div> </div>
<TagsList application={application} appState={appState} /> </div>
</section> <TagsList appState={appState} />
</PremiumModalProvider> </section>
); );
} }
); );
export const TagsSectionDirective = toDirective<Props>(TagsSection);

View File

@@ -0,0 +1,30 @@
import { IconButton } from '@/components/IconButton';
import { AppState } from '@/ui_models/app_state';
import { FeaturesState } from '@/ui_models/app_state/features_state';
import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
type Props = {
tags: TagsState;
features: FeaturesState;
};
export const TagsSectionAddButton: FunctionComponent<Props> = observer(
({ tags, features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
if (!isNativeFoldersEnabled) {
return null;
}
return (
<IconButton
focusable={true}
icon="add"
title="Create a new tag"
onClick={() => tags.createNewTemplate()}
/>
);
}
);

View File

@@ -0,0 +1,62 @@
import { usePremiumModal } from '@/components/Premium';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
TAG_FOLDERS_FEATURE_TOOLTIP,
} from '@/ui_models/app_state/features_state';
import { Tooltip } from '@reach/tooltip';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
type Props = {
features: FeaturesState;
};
export const TagsSectionTitle: FunctionComponent<Props> = observer(
({ features }) => {
const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasFolders = features.hasFolders;
const modal = usePremiumModal();
const showPremiumAlert = useCallback(() => {
modal.activate(TAG_FOLDERS_FEATURE_NAME);
}, [modal]);
if (!isNativeFoldersEnabled) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
</>
);
}
if (hasFolders) {
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Folders</span>
</div>
</>
);
}
return (
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
<Tooltip label={TAG_FOLDERS_FEATURE_TOOLTIP}>
<label
className="ml-1 sk-bold color-grey-2 cursor-pointer"
onClick={showPremiumAlert}
>
Folders
</label>
</Tooltip>
</div>
</>
);
}
);

View File

@@ -0,0 +1,9 @@
export enum ItemTypes {
TAG = 'TAG',
}
export type DropItemTag = { uuid: string };
export type DropItem = DropItemTag;
export type DropProps = { isOver: boolean; canDrop: boolean };

View File

@@ -1,153 +0,0 @@
import { PremiumModalProvider } from '@/components/Premium';
import { confirmDialog } from '@/services/alertService';
import { STRING_DELETE_TAG } from '@/strings';
import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { isMobile } from '@/utils';
import { SNTag, TagMutator } from '@standardnotes/snjs';
import { runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback } from 'preact/hooks';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TouchBackend } from 'react-dnd-touch-backend';
import { RootTagDropZone } from './RootTagDropZone';
import { TagsListItem } from './TagsListItem';
import { toDirective } from './utils';
type Props = {
application: WebApplication;
appState: AppState;
};
const tagsWithOptionalTemplate = (
template: SNTag | undefined,
tags: SNTag[]
): SNTag[] => {
if (!template) {
return tags;
}
return [template, ...tags];
};
export const TagsList: FunctionComponent<Props> = observer(
({ application, appState }) => {
const templateTag = appState.templateTag;
const rootTags = appState.tags.rootTags;
const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
const selectTag = useCallback(
(tag: SNTag) => {
appState.setSelectedTag(tag);
},
[appState]
);
const saveTag = useCallback(
async (tag: SNTag, newTitle: string) => {
const templateTag = appState.templateTag;
const hasEmptyTitle = newTitle.length === 0;
const hasNotChangedTitle = newTitle === tag.title;
const isTemplateChange = templateTag && tag.uuid === templateTag.uuid;
const hasDuplicatedTitle = !!application.findTagByTitle(newTitle);
runInAction(() => {
appState.templateTag = undefined;
appState.editingTag = undefined;
});
if (hasEmptyTitle || hasNotChangedTitle) {
if (isTemplateChange) {
appState.undoCreateNewTag();
}
return;
}
if (hasDuplicatedTitle) {
if (isTemplateChange) {
appState.undoCreateNewTag();
}
application.alertService?.alert(
'A tag with this name already exists.'
);
return;
}
if (isTemplateChange) {
const insertedTag = await application.insertItem(templateTag);
const changedTag = await application.changeItem<TagMutator>(
insertedTag.uuid,
(m) => {
m.title = newTitle;
}
);
selectTag(changedTag as SNTag);
await application.saveItem(insertedTag.uuid);
} else {
await application.changeAndSaveItem<TagMutator>(
tag.uuid,
(mutator) => {
mutator.title = newTitle;
}
);
}
},
[appState, application, selectTag]
);
const removeTag = useCallback(
async (tag: SNTag) => {
if (
await confirmDialog({
text: STRING_DELETE_TAG,
confirmButtonStyle: 'danger',
})
) {
appState.removeTag(tag);
}
},
[appState]
);
const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
return (
<PremiumModalProvider>
<DndProvider backend={backend}>
{allTags.length === 0 ? (
<div className="no-tags-placeholder">
No tags. Create one using the add button above.
</div>
) : (
<>
{allTags.map((tag) => {
return (
<TagsListItem
level={0}
key={tag.uuid}
tag={tag}
tagsState={appState.tags}
selectTag={selectTag}
saveTag={saveTag}
removeTag={removeTag}
appState={appState}
/>
);
})}
<RootTagDropZone
tagsState={appState.tags}
featuresState={appState.features}
/>
</>
)}
</DndProvider>
</PremiumModalProvider>
);
}
);
export const TagsListDirective = toDirective<Props>(TagsList);

View File

@@ -22,6 +22,8 @@ export const STRING_NEW_UPDATE_READY =
export const STRING_DELETE_TAG = export const STRING_DELETE_TAG =
'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.'; 'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.';
export const STRING_MISSING_SYSTEM_TAG = 'We are missing a System Tag.';
/** @editor */ /** @editor */
export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN = export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN =
'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.'; 'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.';

View File

@@ -6,20 +6,26 @@ import { NoteViewController } from '@/views/note_view/note_view_controller';
import { isDesktopApplication } from '@/utils'; import { isDesktopApplication } from '@/utils';
import { import {
ApplicationEvent, ApplicationEvent,
ComponentArea,
ContentType, ContentType,
DeinitSource, DeinitSource,
isPayloadSourceInternalChange,
PayloadSource, PayloadSource,
PrefKey, PrefKey,
SNComponent,
SNNote, SNNote,
SNSmartTag,
ComponentViewer,
SNTag, SNTag,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import pull from 'lodash/pull'; import pull from 'lodash/pull';
import { import {
action, action,
computed, computed,
IReactionDisposer,
makeObservable, makeObservable,
observable, observable,
runInAction, reaction,
} from 'mobx'; } from 'mobx';
import { ActionsMenuState } from './actions_menu_state'; import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state'; import { FeaturesState } from './features_state';
@@ -72,11 +78,6 @@ export class AppState {
onVisibilityChange: any; onVisibilityChange: any;
showBetaWarning: boolean; showBetaWarning: boolean;
selectedTag: SNTag | undefined;
previouslySelectedTag: SNTag | undefined;
editingTag: SNTag | undefined;
_templateTag: SNTag | undefined;
private multiEditorSupport = false; private multiEditorSupport = false;
readonly quickSettingsMenu = new QuickSettingsState(); readonly quickSettingsMenu = new QuickSettingsState();
@@ -92,10 +93,16 @@ export class AppState {
readonly features: FeaturesState; readonly features: FeaturesState;
readonly tags: TagsState; readonly tags: TagsState;
readonly notesView: NotesViewState; readonly notesView: NotesViewState;
public foldersComponentViewer?: ComponentViewer;
isSessionsModalVisible = false; isSessionsModalVisible = false;
private appEventObserverRemovers: (() => void)[] = []; private appEventObserverRemovers: (() => void)[] = [];
private readonly tagChangedDisposer: IReactionDisposer;
private readonly foldersComponentViewerDisposer: () => void;
/* @ngInject */ /* @ngInject */
constructor( constructor(
$rootScope: ng.IRootScopeService, $rootScope: ng.IRootScopeService,
@@ -160,30 +167,27 @@ export class AppState {
this.showBetaWarning = false; this.showBetaWarning = false;
} }
this.selectedTag = undefined; this.foldersComponentViewer = undefined;
this.previouslySelectedTag = undefined;
this.editingTag = undefined;
this._templateTag = undefined;
makeObservable(this, { makeObservable(this, {
selectedTag: computed,
showBetaWarning: observable, showBetaWarning: observable,
isSessionsModalVisible: observable, isSessionsModalVisible: observable,
preferences: observable, preferences: observable,
selectedTag: observable,
previouslySelectedTag: observable,
_templateTag: observable,
templateTag: computed,
createNewTag: action,
editingTag: observable,
setSelectedTag: action,
removeTag: action,
enableBetaWarning: action, enableBetaWarning: action,
disableBetaWarning: action, disableBetaWarning: action,
openSessionsModal: action, openSessionsModal: action,
closeSessionsModal: action, closeSessionsModal: action,
foldersComponentViewer: observable.ref,
setFoldersComponent: action,
}); });
this.tagChangedDisposer = this.tagChangedNotifier();
this.foldersComponentViewerDisposer =
this.subscribeToFoldersComponentChanges();
} }
deinit(source: DeinitSource): void { deinit(source: DeinitSource): void {
@@ -206,6 +210,8 @@ export class AppState {
} }
document.removeEventListener('visibilitychange', this.onVisibilityChange); document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.onVisibilityChange = undefined; this.onVisibilityChange = undefined;
this.tagChangedDisposer();
this.foldersComponentViewerDisposer();
} }
openSessionsModal(): void { openSessionsModal(): void {
@@ -234,16 +240,16 @@ export class AppState {
if (!this.multiEditorSupport) { if (!this.multiEditorSupport) {
this.closeActiveNoteController(); this.closeActiveNoteController();
} }
const activeTagUuid = this.selectedTag
? this.selectedTag.isSmartTag const selectedTag = this.selectedTag;
? undefined
: this.selectedTag.uuid const activeRegularTagUuid =
: undefined; selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined;
await this.application.noteControllerGroup.createNoteView( await this.application.noteControllerGroup.createNoteView(
undefined, undefined,
title, title,
activeTagUuid activeRegularTagUuid
); );
} }
@@ -275,10 +281,88 @@ export class AppState {
} }
} }
private tagChangedNotifier(): IReactionDisposer {
return reaction(
() => this.tags.selectedUuid,
() => {
const tag = this.tags.selected;
const previousTag = this.tags.previouslySelected;
if (!tag) {
return;
}
if (this.application.isTemplateItem(tag)) {
return;
}
this.notifyEvent(AppStateEvent.TagChanged, {
tag,
previousTag,
});
}
);
}
async setFoldersComponent(component?: SNComponent) {
const foldersComponentViewer = this.foldersComponentViewer;
if (foldersComponentViewer) {
this.application.componentManager.destroyComponentViewer(
foldersComponentViewer
);
this.foldersComponentViewer = undefined;
}
if (component) {
this.foldersComponentViewer =
this.application.componentManager.createComponentViewer(
component,
undefined,
this.tags.onFoldersComponentMessage.bind(this.tags)
);
}
}
private subscribeToFoldersComponentChanges() {
return this.application.streamItems(
[ContentType.Component],
async (items, source) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
const components = items as SNComponent[];
const hasFoldersChange = !!components.find(
(component) => component.area === ComponentArea.TagsList
);
if (hasFoldersChange) {
const componentViewer = this.application.componentManager
.componentsForArea(ComponentArea.TagsList)
.find((component) => component.active);
this.setFoldersComponent(componentViewer);
}
}
);
}
public get selectedTag(): SNTag | SNSmartTag | undefined {
return this.tags.selected;
}
public set selectedTag(tag: SNTag | SNSmartTag | undefined) {
this.tags.selected = tag;
}
streamNotesAndTags() { streamNotesAndTags() {
this.application.streamItems( this.application.streamItems(
[ContentType.Note, ContentType.Tag], [ContentType.Note, ContentType.Tag],
async (items, source) => { async (items, source) => {
const selectedTag = this.tags.selected;
/** Close any note controllers for deleted/trashed/archived notes */ /** Close any note controllers for deleted/trashed/archived notes */
if (source === PayloadSource.PreSyncSave) { if (source === PayloadSource.PreSyncSave) {
const notes = items.filter( const notes = items.filter(
@@ -293,13 +377,13 @@ export class AppState {
this.closeNoteController(noteController); this.closeNoteController(noteController);
} else if ( } else if (
note.trashed && note.trashed &&
!this.selectedTag?.isTrashTag && !selectedTag?.isTrashTag &&
!this.searchOptions.includeTrashed !this.searchOptions.includeTrashed
) { ) {
this.closeNoteController(noteController); this.closeNoteController(noteController);
} else if ( } else if (
note.archived && note.archived &&
!this.selectedTag?.isArchiveTag && !selectedTag?.isArchiveTag &&
!this.searchOptions.includeArchived && !this.searchOptions.includeArchived &&
!this.application.getPreference(PrefKey.NotesShowArchived, false) !this.application.getPreference(PrefKey.NotesShowArchived, false)
) { ) {
@@ -307,17 +391,6 @@ export class AppState {
} }
} }
} }
if (this.selectedTag) {
const matchingTag = items.find(
(candidate) =>
this.selectedTag && candidate.uuid === this.selectedTag.uuid
);
if (matchingTag) {
runInAction(() => {
this.selectedTag = matchingTag as SNTag;
});
}
}
} }
); );
} }
@@ -385,74 +458,6 @@ export class AppState {
}); });
} }
setSelectedTag(tag: SNTag) {
if (tag.conflictOf) {
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
if (this.selectedTag === tag) {
return;
}
this.previouslySelectedTag = this.selectedTag;
this.selectedTag = tag;
if (this.templateTag?.uuid === tag.uuid) {
return;
}
this.notifyEvent(AppStateEvent.TagChanged, {
tag: tag,
previousTag: this.previouslySelectedTag,
});
}
public getSelectedTag() {
return this.selectedTag;
}
public get templateTag(): SNTag | undefined {
return this._templateTag;
}
public set templateTag(tag: SNTag | undefined) {
const previous = this._templateTag;
this._templateTag = tag;
if (tag) {
this.setSelectedTag(tag);
this.editingTag = tag;
} else if (previous) {
this.selectedTag =
previous === this.selectedTag ? undefined : this.selectedTag;
this.editingTag =
previous === this.editingTag ? undefined : this.editingTag;
}
}
public removeTag(tag: SNTag) {
this.application.deleteItem(tag);
this.setSelectedTag(this.tags.smartTags[0]);
}
public async createNewTag() {
if (this.templateTag) {
return;
}
const newTag = (await this.application.createTemplateItem(
ContentType.Tag
)) as SNTag;
this.templateTag = newTag;
}
public async undoCreateNewTag() {
const previousTag = this.previouslySelectedTag || this.tags.smartTags[0];
this.setSelectedTag(previousTag);
}
/** Returns the tags that are referncing this note */ /** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) { public getNoteTags(note: SNNote) {
return this.application.referencingForItem(note).filter((ref) => { return this.application.referencingForItem(note).filter((ref) => {

View File

@@ -3,13 +3,22 @@ import {
FeatureIdentifier, FeatureIdentifier,
FeatureStatus, FeatureStatus,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { computed, makeObservable, observable, runInAction } from 'mobx'; import {
action,
computed,
makeObservable,
observable,
runInAction,
when,
} from 'mobx';
import { WebApplication } from '../application'; import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders'; export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP = export const TAG_FOLDERS_FEATURE_TOOLTIP =
'A Plus or Pro plan is required to enable Tag folders.'; 'A Plus or Pro plan is required to enable Tag folders.';
export const SMART_TAGS_FEATURE_NAME = 'Smart Tags';
/** /**
* Holds state for premium/non premium features for the current user features, * Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags). * and eventually for in-development features (feature flags).
@@ -19,23 +28,37 @@ export class FeaturesState {
window?._enable_unfinished_features; window?._enable_unfinished_features;
_hasFolders = false; _hasFolders = false;
_hasSmartTags = false;
_premiumAlertFeatureName: string | undefined;
private unsub: () => void; private unsub: () => void;
constructor(private application: WebApplication) { constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders(); this._hasFolders = this.hasNativeFolders();
this._hasSmartTags = this.hasNativeSmartTags();
this._premiumAlertFeatureName = undefined;
makeObservable(this, { makeObservable(this, {
_hasFolders: observable, _hasFolders: observable,
_hasSmartTags: observable,
hasFolders: computed, hasFolders: computed,
enableNativeFoldersFeature: computed, enableNativeFoldersFeature: computed,
enableNativeSmartTagsFeature: computed,
_premiumAlertFeatureName: observable,
showPremiumAlert: action,
closePremiumAlert: action,
}); });
this.showPremiumAlert = this.showPremiumAlert.bind(this);
this.closePremiumAlert = this.closePremiumAlert.bind(this);
this.unsub = this.application.addEventObserver(async (eventName) => { this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) { switch (eventName) {
case ApplicationEvent.FeaturesUpdated: case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched: case ApplicationEvent.Launched:
runInAction(() => { runInAction(() => {
this._hasFolders = this.hasNativeFolders(); this._hasFolders = this.hasNativeFolders();
this._hasSmartTags = this.hasNativeSmartTags();
}); });
break; break;
default: default:
@@ -52,25 +75,25 @@ export class FeaturesState {
return this.enableUnfinishedFeatures; return this.enableUnfinishedFeatures;
} }
public get enableNativeSmartTagsFeature(): boolean {
return this.enableUnfinishedFeatures;
}
public get hasFolders(): boolean { public get hasFolders(): boolean {
return this._hasFolders; return this._hasFolders;
} }
public set hasFolders(hasFolders: boolean) { public get hasSmartTags(): boolean {
if (!hasFolders) { return this._hasSmartTags;
this._hasFolders = false; }
return;
}
if (!this.hasNativeFolders()) { public async showPremiumAlert(featureName: string): Promise<void> {
this.application.alertService?.alert( this._premiumAlertFeatureName = featureName;
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.` return when(() => this._premiumAlertFeatureName === undefined);
); }
this._hasFolders = false;
return;
}
this._hasFolders = hasFolders; public async closePremiumAlert(): Promise<void> {
this._premiumAlertFeatureName = undefined;
} }
private hasNativeFolders(): boolean { private hasNativeFolders(): boolean {
@@ -84,4 +107,16 @@ export class FeaturesState {
return status === FeatureStatus.Entitled; return status === FeatureStatus.Entitled;
} }
private hasNativeSmartTags(): boolean {
if (!this.enableNativeSmartTagsFeature) {
return false;
}
const status = this.application.getFeatureStatus(
FeatureIdentifier.SmartFilters
);
return status === FeatureStatus.Entitled;
}
} }

View File

@@ -115,12 +115,12 @@ export class NotesState {
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> { async selectNote(uuid: UuidString, userTriggered?: boolean): Promise<void> {
const note = this.application.findItem(uuid) as SNNote; const note = this.application.findItem(uuid) as SNNote;
const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta);
const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl);
const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift);
if (note) { if (note) {
if ( if (userTriggered && (hasMeta || hasCtrl)) {
userTriggered &&
(this.io.activeModifiers.has(KeyboardModifier.Meta) ||
this.io.activeModifiers.has(KeyboardModifier.Ctrl))
) {
if (this.selectedNotes[uuid]) { if (this.selectedNotes[uuid]) {
delete this.selectedNotes[uuid]; delete this.selectedNotes[uuid];
} else if (await this.application.authorizeNoteAccess(note)) { } else if (await this.application.authorizeNoteAccess(note)) {
@@ -129,10 +129,7 @@ export class NotesState {
this.lastSelectedNote = note; this.lastSelectedNote = note;
}); });
} }
} else if ( } else if (userTriggered && hasShift) {
userTriggered &&
this.io.activeModifiers.has(KeyboardModifier.Shift)
) {
await this.selectNotesRange(note); await this.selectNotesRange(note);
} else { } else {
const shouldSelectNote = const shouldSelectNote =

View File

@@ -495,7 +495,9 @@ export class NotesViewState {
this.reloadNotesDisplayOptions(); this.reloadNotesDisplayOptions();
this.reloadNotes(); this.reloadNotes();
if (this.notes.length > 0) { const hasSomeNotes = this.notes.length > 0;
if (hasSomeNotes) {
this.selectFirstNote(); this.selectFirstNote();
} else if (dbLoaded) { } else if (dbLoaded) {
if ( if (

View File

@@ -1,8 +1,13 @@
import { confirmDialog } from '@/services/alertService';
import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings';
import { import {
ComponentAction,
ContentType, ContentType,
MessageData,
SNSmartTag, SNSmartTag,
SNTag, SNTag,
UuidString, TagMutator,
UuidString
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { import {
action, action,
@@ -10,14 +15,21 @@ import {
makeAutoObservable, makeAutoObservable,
makeObservable, makeObservable,
observable, observable,
runInAction, runInAction
} from 'mobx'; } from 'mobx';
import { WebApplication } from '../application'; import { WebApplication } from '../application';
import { FeaturesState } from './features_state'; import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state';
type AnyTag = SNTag | SNSmartTag;
export class TagsState { export class TagsState {
tags: SNTag[] = []; tags: SNTag[] = [];
smartTags: SNSmartTag[] = []; smartTags: SNSmartTag[] = [];
allNotesCount_ = 0;
selected_: AnyTag | undefined;
previouslySelected_: AnyTag | undefined;
editing_: SNTag | undefined;
private readonly tagsCountsState: TagsCountsState; private readonly tagsCountsState: TagsCountsState;
constructor( constructor(
@@ -27,22 +39,40 @@ export class TagsState {
) { ) {
this.tagsCountsState = new TagsCountsState(this.application); this.tagsCountsState = new TagsCountsState(this.application);
this.selected_ = undefined;
this.previouslySelected_ = undefined;
this.editing_ = undefined;
makeObservable(this, { makeObservable(this, {
tags: observable.ref, tags: observable.ref,
smartTags: observable.ref, smartTags: observable.ref,
hasFolders: computed,
hasAtLeastOneFolder: computed, hasAtLeastOneFolder: computed,
allNotesCount_: observable,
allNotesCount: computed,
selected_: observable.ref,
previouslySelected_: observable.ref,
previouslySelected: computed,
editing_: observable.ref,
selected: computed,
selectedUuid: computed,
editingTag: computed,
assignParent: action, assignParent: action,
rootTags: computed, rootTags: computed,
tagsCount: computed, tagsCount: computed,
createNewTemplate: action,
undoCreateNewTag: action,
save: action,
remove: action,
}); });
appEventListeners.push( appEventListeners.push(
this.application.streamItems( this.application.streamItems(
[ContentType.Tag, ContentType.SmartTag], [ContentType.Tag, ContentType.SmartTag],
() => { (items) => {
runInAction(() => { runInAction(() => {
this.tags = this.application.getDisplayableItems( this.tags = this.application.getDisplayableItems(
ContentType.Tag ContentType.Tag
@@ -50,18 +80,42 @@ export class TagsState {
this.smartTags = this.application.getSmartTags(); this.smartTags = this.application.getSmartTags();
this.tagsCountsState.update(this.tags); this.tagsCountsState.update(this.tags);
this.allNotesCount_ = this.countAllNotes();
const selectedTag = this.selected_;
if (selectedTag) {
const matchingTag = items.find(
(candidate) => candidate.uuid === selectedTag.uuid
);
if (matchingTag) {
if (matchingTag.deleted) {
this.selected_ = this.smartTags[0];
} else {
this.selected_ = matchingTag as AnyTag;
}
}
} else {
this.selected_ = this.smartTags[0];
}
}); });
} }
) )
); );
} }
public get allLocalRootTags(): SNTag[] {
if (this.editing_ && this.application.isTemplateItem(this.editing_)) {
return [this.editing_, ...this.rootTags];
}
return this.rootTags;
}
public getNotesCount(tag: SNTag): number { public getNotesCount(tag: SNTag): number {
return this.tagsCountsState.counts[tag.uuid] || 0; return this.tagsCountsState.counts[tag.uuid] || 0;
} }
getChildren(tag: SNTag): SNTag[] { getChildren(tag: SNTag): SNTag[] {
if (!this.hasFolders) { if (!this.features.hasFolders) {
return []; return [];
} }
@@ -69,7 +123,10 @@ export class TagsState {
return []; return [];
} }
const children = this.application.getTagChildren(tag); const children = this.application
.getTagChildren(tag)
.filter((tag) => !tag.isSmartTag);
const childrenUuids = children.map((childTag) => childTag.uuid); const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) => const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid) childrenUuids.includes(tag.uuid)
@@ -100,7 +157,7 @@ export class TagsState {
} }
get rootTags(): SNTag[] { get rootTags(): SNTag[] {
if (!this.hasFolders) { if (!this.features.hasFolders) {
return this.tags; return this.tags;
} }
@@ -111,12 +168,192 @@ export class TagsState {
return this.tags.length; return this.tags.length;
} }
public get hasFolders(): boolean { public get allNotesCount(): number {
return this.features.hasFolders; return this.allNotesCount_;
} }
public set hasFolders(hasFolders: boolean) { public get previouslySelected(): AnyTag | undefined {
this.features.hasFolders = hasFolders; return this.previouslySelected_;
}
public get selected(): AnyTag | undefined {
return this.selected_;
}
public set selected(tag: AnyTag | undefined) {
if (tag && tag.conflictOf) {
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid;
if (selectionHasNotChanged) {
return;
}
this.previouslySelected_ = this.selected_;
this.selected_ = tag;
}
public get selectedUuid(): UuidString | undefined {
return this.selected_?.uuid;
}
public get editingTag(): SNTag | undefined {
return this.editing_;
}
public set editingTag(editingTag: SNTag | undefined) {
this.editing_ = editingTag;
this.selected = editingTag;
}
public async createNewTemplate() {
const isAlreadyEditingATemplate =
this.editing_ && this.application.isTemplateItem(this.editing_);
if (isAlreadyEditingATemplate) {
return;
}
const newTag = (await this.application.createTemplateItem(
ContentType.Tag
)) as SNTag;
runInAction(() => {
this.editing_ = newTag;
});
}
public undoCreateNewTag() {
this.editing_ = undefined;
const previousTag = this.previouslySelected_ || this.smartTags[0];
this.selected = previousTag;
}
public async remove(tag: SNTag) {
if (
await confirmDialog({
text: STRING_DELETE_TAG,
confirmButtonStyle: 'danger',
})
) {
this.application.deleteItem(tag);
this.selected = this.smartTags[0];
}
}
public async save(tag: SNTag, newTitle: string) {
const hasEmptyTitle = newTitle.length === 0;
const hasNotChangedTitle = newTitle === tag.title;
const isTemplateChange = this.application.isTemplateItem(tag);
const hasDuplicatedTitle = !!this.application.findTagByTitle(newTitle);
runInAction(() => {
this.editing_ = undefined;
});
if (hasEmptyTitle || hasNotChangedTitle) {
if (isTemplateChange) {
this.undoCreateNewTag();
}
return;
}
if (hasDuplicatedTitle) {
if (isTemplateChange) {
this.undoCreateNewTag();
}
this.application.alertService?.alert(
'A tag with this name already exists.'
);
return;
}
if (isTemplateChange) {
if (this.features.enableNativeSmartTagsFeature) {
const isSmartTagTitle = this.application.isSmartTagTitle(newTitle);
if (isSmartTagTitle) {
if (!this.features.hasSmartTags) {
await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME);
return;
}
}
const insertedTag = await this.application.createTagOrSmartTag(
newTitle
);
runInAction(() => {
this.selected = insertedTag as SNTag;
});
} else {
// Legacy code, remove me after we enableNativeSmartTagsFeature for everyone.
// See https://app.asana.com/0/0/1201612665552831/f
const insertedTag = await this.application.insertItem(tag);
const changedTag = await this.application.changeItem<TagMutator>(
insertedTag.uuid,
(m) => {
m.title = newTitle;
}
);
this.selected = changedTag as SNTag;
await this.application.saveItem(insertedTag.uuid);
}
} else {
await this.application.changeAndSaveItem<TagMutator>(
tag.uuid,
(mutator) => {
mutator.title = newTitle;
}
);
}
}
private countAllNotes(): number {
const allTag = this.application.getSmartTags().find((tag) => tag.isAllTag);
if (!allTag) {
console.error(STRING_MISSING_SYSTEM_TAG);
return -1;
}
const notes = this.application
.notesMatchingSmartTag(allTag)
.filter((note) => {
return !note.archived && !note.trashed;
});
return notes.length;
}
public onFoldersComponentMessage(
action: ComponentAction,
data: MessageData
): void {
if (action === ComponentAction.SelectItem) {
const item = data.item;
if (!item) {
return;
}
if (
item.content_type === ContentType.Tag ||
item.content_type === ContentType.SmartTag
) {
const matchingTag = this.application.findItem(item.uuid);
if (matchingTag) {
this.selected = matchingTag as AnyTag;
return;
}
}
} else if (action === ComponentAction.ClearSelection) {
this.selected = this.smartTags[0];
}
} }
public get hasAtLeastOneFolder(): boolean { public get hasAtLeastOneFolder(): boolean {

View File

@@ -56,6 +56,12 @@ export class PanelResizerState {
side, side,
widthEventCallback, widthEventCallback,
}: PanelResizerProps) { }: PanelResizerProps) {
const currentKnownPref =
(application.getPreference(prefKey) as number) ?? defaultWidth ?? 0;
this.panel = panel;
this.startLeft = this.panel.offsetLeft;
this.startWidth = this.panel.scrollWidth;
this.alwaysVisible = alwaysVisible ?? false; this.alwaysVisible = alwaysVisible ?? false;
this.application = application; this.application = application;
this.collapsable = collapsable ?? false; this.collapsable = collapsable ?? false;
@@ -66,16 +72,15 @@ export class PanelResizerState {
this.lastDownX = 0; this.lastDownX = 0;
this.lastLeft = this.startLeft; this.lastLeft = this.startLeft;
this.lastWidth = this.startWidth; this.lastWidth = this.startWidth;
this.panel = panel;
this.prefKey = prefKey; this.prefKey = prefKey;
this.pressed = false; this.pressed = false;
this.side = side; this.side = side;
this.startLeft = this.panel.offsetLeft;
this.startWidth = this.panel.scrollWidth;
this.widthBeforeLastDblClick = 0; this.widthBeforeLastDblClick = 0;
this.widthEventCallback = widthEventCallback; this.widthEventCallback = widthEventCallback;
this.resizeFinishCallback = resizeFinishCallback; this.resizeFinishCallback = resizeFinishCallback;
this.setWidth(currentKnownPref, true);
application.addEventObserver(async () => { application.addEventObserver(async () => {
const changedWidth = application.getPreference(prefKey) as number; const changedWidth = application.getPreference(prefKey) as number;
if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true); if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true);

View File

@@ -5,7 +5,7 @@
ng-class='self.state.appClass', ng-class='self.state.appClass',
ng-if='!self.state.needsUnlock && self.state.launched' ng-if='!self.state.needsUnlock && self.state.launched'
) )
tags-view(application='self.application') navigation(application='self.application', appState='self.appState')
notes-view( notes-view(
application='self.application' application='self.application'
app-state='self.appState' app-state='self.appState'

View File

@@ -8,7 +8,7 @@ import {
Challenge, Challenge,
removeFromArray, removeFromArray,
} from '@standardnotes/snjs'; } from '@standardnotes/snjs';
import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants'; import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings'; import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService'; import { alertDialog } from '@/services/alertService';
@@ -24,7 +24,7 @@ class ApplicationViewCtrl extends PureViewCtrl<
> { > {
public platformString: string; public platformString: string;
private notesCollapsed = false; private notesCollapsed = false;
private tagsCollapsed = false; private navigationCollapsed = false;
/** /**
* To prevent stale state reads (setState is async), * To prevent stale state reads (setState is async),
@@ -136,15 +136,15 @@ class ApplicationViewCtrl extends PureViewCtrl<
if (panel === PANEL_NAME_NOTES) { if (panel === PANEL_NAME_NOTES) {
this.notesCollapsed = collapsed; this.notesCollapsed = collapsed;
} }
if (panel === PANEL_NAME_TAGS) { if (panel === PANEL_NAME_NAVIGATION) {
this.tagsCollapsed = collapsed; this.navigationCollapsed = collapsed;
} }
let appClass = ''; let appClass = '';
if (this.notesCollapsed) { if (this.notesCollapsed) {
appClass += 'collapsed-notes'; appClass += 'collapsed-notes';
} }
if (this.tagsCollapsed) { if (this.navigationCollapsed) {
appClass += ' collapsed-tags'; appClass += ' collapsed-navigation';
} }
this.setState({ appClass }); this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) { } else if (eventName === AppStateEvent.WindowDidFocus) {

View File

@@ -1,4 +1,4 @@
export const PANEL_NAME_NOTES = 'notes'; export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_TAGS = 'tags'; export const PANEL_NAME_NAVIGATION = 'navigation';
export const EMAIL_REGEX = export const EMAIL_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/; /^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;

View File

@@ -4,5 +4,4 @@ export { ApplicationView } from './application/application_view';
export { NoteGroupViewDirective } from './note_group_view/note_group_view'; export { NoteGroupViewDirective } from './note_group_view/note_group_view';
export { NoteViewDirective } from './note_view/note_view'; export { NoteViewDirective } from './note_view/note_view';
export { FooterView } from './footer/footer_view'; export { FooterView } from './footer/footer_view';
export { TagsView } from './tags/tags_view';
export { ChallengeModal } from './challenge_modal/challenge_modal'; export { ChallengeModal } from './challenge_modal/challenge_modal';

View File

@@ -1,43 +0,0 @@
#tags-column.sn-component.section.tags(aria-label='Tags')
.component-view-container(ng-if='self.state.componentViewer')
component-view.component-view(
component-viewer='self.state.componentViewer',
application='self.application'
app-state='self.appState'
)
#tags-content.content(ng-if='!(self.state.componentViewer)')
.tags-title-section.section-title-bar
.section-title-bar-header
.sk-h3.title
span.sk-bold Views
.sk-button.sk-secondary-contrast.wide(
ng-click='self.clickedAddNewTag()',
title='Create a new tag'
)
.sk-label
i.icon.ion-plus.add-button
.scrollable
.infinite-scroll
.tag(
ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}",
ng-click='self.selectTag(tag)',
ng-repeat='tag in self.state.smartTags track by tag.uuid'
)
.tag-info
.title(ng-if="!tag.errorDecrypting") {{tag.title}}
.count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}}
.danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy
.danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
.info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
tags-section(
application='self.application',
app-state='self.appState'
)
panel-resizer(
collapsable='true',
control='self.panelPuppet',
default-width='150',
hoverable='true',
on-resize-finish='self.onPanelResize',
panel-id="'tags-column'"
)

View File

@@ -1,298 +0,0 @@
import { PanelPuppet, WebDirective } from '@/types';
import { WebApplication } from '@/ui_models/application';
import { AppStateEvent } from '@/ui_models/app_state';
import { PANEL_NAME_TAGS } from '@/views/constants';
import {
ApplicationEvent,
ComponentAction,
ComponentArea,
ComponentViewer,
ContentType,
isPayloadSourceInternalChange,
MessageData,
PayloadSource,
PrefKey,
SNComponent,
SNSmartTag,
SNTag,
UuidString,
} from '@standardnotes/snjs';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import template from './tags-view.pug';
type NoteCounts = Partial<Record<string, number>>;
type TagState = {
smartTags: SNSmartTag[];
noteCounts: NoteCounts;
selectedTag?: SNTag;
componentViewer?: ComponentViewer;
};
class TagsViewCtrl extends PureViewCtrl<unknown, TagState> {
/** Passed through template */
readonly application!: WebApplication;
private readonly panelPuppet: PanelPuppet;
private unregisterComponent?: () => void;
/** The original name of the edtingTag before it began editing */
formData: { tagTitle?: string } = {};
titles: Partial<Record<UuidString, string>> = {};
private removeTagsObserver!: () => void;
private removeFoldersObserver!: () => void;
/* @ngInject */
constructor($timeout: ng.ITimeoutService) {
super($timeout);
this.panelPuppet = {
onReady: () => this.loadPreferences(),
};
}
deinit() {
this.removeTagsObserver?.();
(this.removeTagsObserver as unknown) = undefined;
(this.removeFoldersObserver as unknown) = undefined;
this.unregisterComponent?.();
this.unregisterComponent = undefined;
super.deinit();
}
getInitialState(): TagState {
return {
smartTags: [],
noteCounts: {},
};
}
getState(): TagState {
return this.state;
}
async onAppLaunch() {
super.onAppLaunch();
this.loadPreferences();
this.streamForFoldersComponent();
const smartTags = this.application.getSmartTags();
this.setState({ smartTags });
this.selectTag(smartTags[0]);
}
/** @override */
onAppIncrementalSync() {
super.onAppIncrementalSync();
this.reloadNoteCounts();
}
async setFoldersComponent(component?: SNComponent) {
if (this.state.componentViewer) {
this.application.componentManager.destroyComponentViewer(
this.state.componentViewer
);
await this.setState({ componentViewer: undefined });
}
if (component) {
await this.setState({
componentViewer:
this.application.componentManager.createComponentViewer(
component,
undefined,
this.handleFoldersComponentMessage.bind(this)
),
});
}
}
handleFoldersComponentMessage(
action: ComponentAction,
data: MessageData
): void {
if (action === ComponentAction.SelectItem) {
const item = data.item;
if (!item) {
return;
}
if (item.content_type === ContentType.Tag) {
const matchingTag = this.application.findItem(item.uuid);
if (matchingTag) {
this.selectTag(matchingTag as SNTag);
}
} else if (item.content_type === ContentType.SmartTag) {
const matchingTag = this.getState().smartTags.find(
(t) => t.uuid === item.uuid
);
if (matchingTag) {
this.selectTag(matchingTag);
}
}
} else if (action === ComponentAction.ClearSelection) {
this.selectTag(this.getState().smartTags[0]);
}
}
streamForFoldersComponent() {
this.removeFoldersObserver = this.application.streamItems(
[ContentType.Component],
async (items, source) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return;
}
const components = items as SNComponent[];
const hasFoldersChange = !!components.find(
(component) => component.area === ComponentArea.TagsList
);
if (hasFoldersChange) {
this.setFoldersComponent(
this.application.componentManager
.componentsForArea(ComponentArea.TagsList)
.find((component) => component.active)
);
}
}
);
this.removeTagsObserver = this.application.streamItems(
[ContentType.Tag, ContentType.SmartTag],
async (items) => {
const tags = items as Array<SNTag | SNSmartTag>;
await this.setState({
smartTags: this.application.getSmartTags(),
});
for (const tag of tags) {
this.titles[tag.uuid] = tag.title;
}
this.reloadNoteCounts();
const selectedTag = this.state.selectedTag;
if (selectedTag) {
/** If the selected tag has been deleted, revert to All view. */
const matchingTag = tags.find((tag) => {
return tag.uuid === selectedTag.uuid;
});
if (matchingTag) {
if (matchingTag.deleted) {
this.selectTag(this.getState().smartTags[0]);
} else {
this.setState({
selectedTag: matchingTag,
});
}
}
}
}
);
}
/** @override */
onAppStateEvent(eventName: AppStateEvent) {
if (eventName === AppStateEvent.TagChanged) {
this.setState({
selectedTag: this.application.getAppState().getSelectedTag(),
});
}
}
/** @override */
async onAppEvent(eventName: ApplicationEvent) {
super.onAppEvent(eventName);
switch (eventName) {
case ApplicationEvent.LocalDataIncrementalLoad:
this.reloadNoteCounts();
break;
case ApplicationEvent.PreferencesChanged:
this.loadPreferences();
break;
}
}
reloadNoteCounts() {
const smartTags = this.state.smartTags;
const noteCounts: NoteCounts = {};
for (const tag of smartTags) {
/** Other smart tags do not contain counts */
if (tag.isAllTag) {
const notes = this.application
.notesMatchingSmartTag(tag as SNSmartTag)
.filter((note) => {
return !note.archived && !note.trashed;
});
noteCounts[tag.uuid] = notes.length;
}
}
this.setState({
noteCounts: noteCounts,
});
}
loadPreferences() {
if (!this.panelPuppet.ready) {
return;
}
const width = this.application.getPreference(PrefKey.TagsPanelWidth);
if (width) {
this.panelPuppet.setWidth!(width);
if (this.panelPuppet.isCollapsed!()) {
this.application
.getAppState()
.panelDidResize(PANEL_NAME_TAGS, this.panelPuppet.isCollapsed!());
}
}
}
onPanelResize = (
newWidth: number,
_lastLeft: number,
_isAtMaxWidth: boolean,
isCollapsed: boolean
) => {
this.application
.setPreference(PrefKey.TagsPanelWidth, newWidth)
.then(() => this.application.sync());
this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed);
};
async selectTag(tag: SNTag) {
if (tag.conflictOf) {
this.application.changeAndSaveItem(tag.uuid, (mutator) => {
mutator.conflictOf = undefined;
});
}
this.application.getAppState().setSelectedTag(tag);
}
async clickedAddNewTag() {
if (this.appState.templateTag) {
return;
}
this.appState.createNewTag();
}
}
export class TagsView extends WebDirective {
constructor() {
super();
this.restrict = 'E';
this.scope = {
application: '=',
};
this.template = template;
this.replace = true;
this.controller = TagsViewCtrl;
this.controllerAs = 'self';
this.bindToController = true;
}
}

View File

@@ -35,7 +35,7 @@
opacity: 1; opacity: 1;
} }
.section.tags, navigation,
notes-view { notes-view {
will-change: opacity; will-change: opacity;
animation: fade-out 1.25s forwards; animation: fade-out 1.25s forwards;
@@ -45,7 +45,7 @@
flex: none !important; flex: none !important;
} }
.section.tags:hover { navigation:hover {
flex: initial; flex: initial;
width: 0px !important; width: 0px !important;
} }
@@ -57,7 +57,7 @@
} }
.disable-focus-mode { .disable-focus-mode {
.section.tags, navigation,
notes-view { notes-view {
transition: width 1.25s; transition: width 1.25s;
will-change: opacity; will-change: opacity;

View File

@@ -1,3 +1,7 @@
#tags-column {
width: 100%;
}
.tags { .tags {
width: 180px; width: 180px;
flex-grow: 0; flex-grow: 0;