Revert "feat: native smart tags (#782)"

This reverts commit c3772e06b4.
This commit is contained in:
Karol Sójko
2022-01-05 16:11:17 +01:00
parent 122f8fc266
commit b57350c899
33 changed files with 870 additions and 1032 deletions

View File

@@ -1,3 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 458 B

View File

@@ -20,32 +20,23 @@ declare global {
}
}
import { ComponentViewDirective } from '@/components/ComponentView';
import { NavigationDirective } from '@/components/Navigation';
import { PinNoteButtonDirective } from '@/components/PinNoteButton';
import { IsWebPlatform, WebAppVersion } from '@/version';
import {
ApplicationGroupView,
ApplicationView, ChallengeModal,
FooterView, NoteGroupViewDirective,
NoteViewDirective
} 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 { configRoutes } from './routes';
import { ApplicationGroup } from './ui_models/application_group';
import { AccountSwitcher } from './views/account_switcher/account_switcher';
import {
ApplicationGroupView,
ApplicationView,
NoteGroupViewDirective,
NoteViewDirective,
TagsView,
FooterView,
ChallengeModal,
} from '@/views';
import {
autofocus,
clickOutside,
@@ -55,31 +46,49 @@ import {
infiniteScroll,
lowercase,
selectOnFocus,
snEnter
snEnter,
} from './directives/functional';
import {
ActionsMenu,
EditorMenu,
HistoryMenu,
InputModal,
MenuRow,
PanelResizer,
PasswordWizard,
PermissionsModal,
RevisionPreviewModal,
SyncResolutionMenu
HistoryMenu,
SyncResolutionMenu,
} from './directives/views';
import { trusted } from './filters';
import { PreferencesDirective } from './preferences';
import { PurchaseFlowDirective } from './purchaseFlow';
import { configRoutes } from './routes';
import { Bridge } from './services/bridge';
import { isDev } from './utils';
import { BrowserBridge } from './services/browserBridge';
import { startErrorReporting } from './services/errorReporting';
import { StartApplication } from './startApplication';
import { ApplicationGroup } from './ui_models/application_group';
import { isDev } from './utils';
import { AccountSwitcher } from './views/account_switcher/account_switcher';
import { Bridge } from './services/bridge';
import { SessionsModalDirective } from './components/SessionsModal';
import { NoAccountWarningDirective } from './components/NoAccountWarning';
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 {
/**
@@ -134,6 +143,7 @@ const startApplication: StartApplication = async function startApplication(
.directive('applicationView', () => new ApplicationView())
.directive('noteGroupView', () => new NoteGroupViewDirective())
.directive('noteView', () => new NoteViewDirective())
.directive('tagsView', () => new TagsView())
.directive('footerView', () => new FooterView());
// Directives - Functional
@@ -178,7 +188,8 @@ const startApplication: StartApplication = async function startApplication(
.directive('notesListOptionsMenu', NotesListOptionsDirective)
.directive('icon', IconDirective)
.directive('noteTagsContainer', NoteTagsContainerDirective)
.directive('navigation', NavigationDirective)
.directive('tagsList', TagsListDirective)
.directive('tagsSection', TagsSectionDirective)
.directive('preferences', PreferencesDirective)
.directive('purchaseFlow', PurchaseFlowDirective)
.directive('notesView', NotesViewDirective)

View File

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

View File

@@ -1,117 +0,0 @@
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) => {
if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false);
appState.selectedTag = tag;
appState.setSelectedTag(tag);
} else {
setTagClicked(true);
}

View File

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

View File

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

View File

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

View File

@@ -1,29 +0,0 @@
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

@@ -1,163 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,48 +0,0 @@
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,29 +1,108 @@
import { TagsList } from '@/components/Tags/TagsList';
import { TagsList } from '@/components/TagsList';
import { toDirective } from '@/components/utils';
import { WebApplication } from '@/ui_models/application';
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 { FunctionComponent } from 'preact';
import { TagsSectionAddButton } from './TagsSectionAddButton';
import { TagsSectionTitle } from './TagsSectionTitle';
import { useCallback } from 'preact/hooks';
import { IconButton } from '../IconButton';
import { PremiumModalProvider, usePremiumModal } from '../Premium';
type Props = {
application: WebApplication;
appState: AppState;
};
export const TagsSection: FunctionComponent<Props> = observer(
({ 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 (
<section>
<div className="tags-title-section section-title-bar">
<div className="section-title-bar-header">
<TagsSectionTitle features={appState.features} />
<TagsSectionAddButton
tags={appState.tags}
features={appState.features}
/>
</div>
<>
<div className="sk-h3 title">
<span className="sk-bold">Tags</span>
</div>
<TagsList appState={appState} />
</section>
</>
);
}
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(
({ application, appState }) => {
return (
<PremiumModalProvider>
<section>
<div className="tags-title-section section-title-bar">
<div className="section-title-bar-header">
<TagTitle features={appState.features} />
<TagAddButton appState={appState} features={appState.features} />
</div>
</div>
<TagsList application={application} appState={appState} />
</section>
</PremiumModalProvider>
);
}
);
export const TagsSectionDirective = toDirective<Props>(TagsSection);

View File

@@ -1,30 +0,0 @@
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

@@ -1,62 +0,0 @@
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

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

View File

@@ -0,0 +1,153 @@
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

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

View File

@@ -22,8 +22,6 @@ export const STRING_NEW_UPDATE_READY =
export const STRING_DELETE_TAG =
'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 */
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.';

View File

@@ -6,26 +6,20 @@ import { NoteViewController } from '@/views/note_view/note_view_controller';
import { isDesktopApplication } from '@/utils';
import {
ApplicationEvent,
ComponentArea,
ContentType,
DeinitSource,
isPayloadSourceInternalChange,
PayloadSource,
PrefKey,
SNComponent,
SNNote,
SNSmartTag,
ComponentViewer,
SNTag,
} from '@standardnotes/snjs';
import pull from 'lodash/pull';
import {
action,
computed,
IReactionDisposer,
makeObservable,
observable,
reaction,
runInAction,
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
@@ -78,6 +72,11 @@ export class AppState {
onVisibilityChange: any;
showBetaWarning: boolean;
selectedTag: SNTag | undefined;
previouslySelectedTag: SNTag | undefined;
editingTag: SNTag | undefined;
_templateTag: SNTag | undefined;
private multiEditorSupport = false;
readonly quickSettingsMenu = new QuickSettingsState();
@@ -93,16 +92,10 @@ export class AppState {
readonly features: FeaturesState;
readonly tags: TagsState;
readonly notesView: NotesViewState;
public foldersComponentViewer?: ComponentViewer;
isSessionsModalVisible = false;
private appEventObserverRemovers: (() => void)[] = [];
private readonly tagChangedDisposer: IReactionDisposer;
private readonly foldersComponentViewerDisposer: () => void;
/* @ngInject */
constructor(
$rootScope: ng.IRootScopeService,
@@ -167,27 +160,30 @@ export class AppState {
this.showBetaWarning = false;
}
this.foldersComponentViewer = undefined;
this.selectedTag = undefined;
this.previouslySelectedTag = undefined;
this.editingTag = undefined;
this._templateTag = undefined;
makeObservable(this, {
selectedTag: computed,
showBetaWarning: observable,
isSessionsModalVisible: observable,
preferences: observable,
selectedTag: observable,
previouslySelectedTag: observable,
_templateTag: observable,
templateTag: computed,
createNewTag: action,
editingTag: observable,
setSelectedTag: action,
removeTag: action,
enableBetaWarning: action,
disableBetaWarning: action,
openSessionsModal: action,
closeSessionsModal: action,
foldersComponentViewer: observable.ref,
setFoldersComponent: action,
});
this.tagChangedDisposer = this.tagChangedNotifier();
this.foldersComponentViewerDisposer =
this.subscribeToFoldersComponentChanges();
}
deinit(source: DeinitSource): void {
@@ -210,8 +206,6 @@ export class AppState {
}
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.onVisibilityChange = undefined;
this.tagChangedDisposer();
this.foldersComponentViewerDisposer();
}
openSessionsModal(): void {
@@ -240,16 +234,16 @@ export class AppState {
if (!this.multiEditorSupport) {
this.closeActiveNoteController();
}
const selectedTag = this.selectedTag;
const activeRegularTagUuid =
selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined;
const activeTagUuid = this.selectedTag
? this.selectedTag.isSmartTag
? undefined
: this.selectedTag.uuid
: undefined;
await this.application.noteControllerGroup.createNoteView(
undefined,
title,
activeRegularTagUuid
activeTagUuid
);
}
@@ -281,88 +275,10 @@ 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() {
this.application.streamItems(
[ContentType.Note, ContentType.Tag],
async (items, source) => {
const selectedTag = this.tags.selected;
/** Close any note controllers for deleted/trashed/archived notes */
if (source === PayloadSource.PreSyncSave) {
const notes = items.filter(
@@ -377,13 +293,13 @@ export class AppState {
this.closeNoteController(noteController);
} else if (
note.trashed &&
!selectedTag?.isTrashTag &&
!this.selectedTag?.isTrashTag &&
!this.searchOptions.includeTrashed
) {
this.closeNoteController(noteController);
} else if (
note.archived &&
!selectedTag?.isArchiveTag &&
!this.selectedTag?.isArchiveTag &&
!this.searchOptions.includeArchived &&
!this.application.getPreference(PrefKey.NotesShowArchived, false)
) {
@@ -391,6 +307,17 @@ 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;
});
}
}
}
);
}
@@ -458,6 +385,74 @@ 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 */
public getNoteTags(note: SNNote) {
return this.application.referencingForItem(note).filter((ref) => {

View File

@@ -3,22 +3,13 @@ import {
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
import {
action,
computed,
makeObservable,
observable,
runInAction,
when,
} from 'mobx';
import { computed, makeObservable, observable, runInAction } from 'mobx';
import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP =
'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,
* and eventually for in-development features (feature flags).
@@ -28,37 +19,23 @@ export class FeaturesState {
window?._enable_unfinished_features;
_hasFolders = false;
_hasSmartTags = false;
_premiumAlertFeatureName: string | undefined;
private unsub: () => void;
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders();
this._hasSmartTags = this.hasNativeSmartTags();
this._premiumAlertFeatureName = undefined;
makeObservable(this, {
_hasFolders: observable,
_hasSmartTags: observable,
hasFolders: 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) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders();
this._hasSmartTags = this.hasNativeSmartTags();
});
break;
default:
@@ -75,25 +52,25 @@ export class FeaturesState {
return this.enableUnfinishedFeatures;
}
public get enableNativeSmartTagsFeature(): boolean {
return this.enableUnfinishedFeatures;
}
public get hasFolders(): boolean {
return this._hasFolders;
}
public get hasSmartTags(): boolean {
return this._hasSmartTags;
}
public set hasFolders(hasFolders: boolean) {
if (!hasFolders) {
this._hasFolders = false;
return;
}
public async showPremiumAlert(featureName: string): Promise<void> {
this._premiumAlertFeatureName = featureName;
return when(() => this._premiumAlertFeatureName === undefined);
}
if (!this.hasNativeFolders()) {
this.application.alertService?.alert(
`${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
);
this._hasFolders = false;
return;
}
public async closePremiumAlert(): Promise<void> {
this._premiumAlertFeatureName = undefined;
this._hasFolders = hasFolders;
}
private hasNativeFolders(): boolean {
@@ -107,16 +84,4 @@ export class FeaturesState {
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> {
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 (userTriggered && (hasMeta || hasCtrl)) {
if (
userTriggered &&
(this.io.activeModifiers.has(KeyboardModifier.Meta) ||
this.io.activeModifiers.has(KeyboardModifier.Ctrl))
) {
if (this.selectedNotes[uuid]) {
delete this.selectedNotes[uuid];
} else if (await this.application.authorizeNoteAccess(note)) {
@@ -129,7 +129,10 @@ export class NotesState {
this.lastSelectedNote = note;
});
}
} else if (userTriggered && hasShift) {
} else if (
userTriggered &&
this.io.activeModifiers.has(KeyboardModifier.Shift)
) {
await this.selectNotesRange(note);
} else {
const shouldSelectNote =

View File

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

View File

@@ -1,13 +1,8 @@
import { confirmDialog } from '@/services/alertService';
import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings';
import {
ComponentAction,
ContentType,
MessageData,
SNSmartTag,
SNTag,
TagMutator,
UuidString
UuidString,
} from '@standardnotes/snjs';
import {
action,
@@ -15,21 +10,14 @@ import {
makeAutoObservable,
makeObservable,
observable,
runInAction
runInAction,
} from 'mobx';
import { WebApplication } from '../application';
import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state';
type AnyTag = SNTag | SNSmartTag;
import { FeaturesState } from './features_state';
export class TagsState {
tags: SNTag[] = [];
smartTags: SNSmartTag[] = [];
allNotesCount_ = 0;
selected_: AnyTag | undefined;
previouslySelected_: AnyTag | undefined;
editing_: SNTag | undefined;
private readonly tagsCountsState: TagsCountsState;
constructor(
@@ -39,40 +27,22 @@ export class TagsState {
) {
this.tagsCountsState = new TagsCountsState(this.application);
this.selected_ = undefined;
this.previouslySelected_ = undefined;
this.editing_ = undefined;
makeObservable(this, {
tags: observable.ref,
smartTags: observable.ref,
hasFolders: 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,
rootTags: computed,
tagsCount: computed,
createNewTemplate: action,
undoCreateNewTag: action,
save: action,
remove: action,
});
appEventListeners.push(
this.application.streamItems(
[ContentType.Tag, ContentType.SmartTag],
(items) => {
() => {
runInAction(() => {
this.tags = this.application.getDisplayableItems(
ContentType.Tag
@@ -80,42 +50,18 @@ export class TagsState {
this.smartTags = this.application.getSmartTags();
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 {
return this.tagsCountsState.counts[tag.uuid] || 0;
}
getChildren(tag: SNTag): SNTag[] {
if (!this.features.hasFolders) {
if (!this.hasFolders) {
return [];
}
@@ -123,10 +69,7 @@ export class TagsState {
return [];
}
const children = this.application
.getTagChildren(tag)
.filter((tag) => !tag.isSmartTag);
const children = this.application.getTagChildren(tag);
const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid)
@@ -157,7 +100,7 @@ export class TagsState {
}
get rootTags(): SNTag[] {
if (!this.features.hasFolders) {
if (!this.hasFolders) {
return this.tags;
}
@@ -168,192 +111,12 @@ export class TagsState {
return this.tags.length;
}
public get allNotesCount(): number {
return this.allNotesCount_;
public get hasFolders(): boolean {
return this.features.hasFolders;
}
public get previouslySelected(): AnyTag | undefined {
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 set hasFolders(hasFolders: boolean) {
this.features.hasFolders = hasFolders;
}
public get hasAtLeastOneFolder(): boolean {

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
export const PANEL_NAME_NOTES = 'notes';
export const PANEL_NAME_NAVIGATION = 'navigation';
export const PANEL_NAME_TAGS = 'tags';
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])?)$/;

View File

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

View File

@@ -0,0 +1,43 @@
#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

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

View File

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