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

@@ -23,6 +23,7 @@ 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';
@@ -69,6 +70,7 @@ 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

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

View File

@@ -124,9 +124,9 @@ const NotesView: FunctionComponent<Props> = observer(
};
const panelResizeFinishCallback: ResizeFinishCallback = (
_w,
_l,
_mw,
_lastWidth,
_lastLeft,
_isMaxWidth,
isCollapsed
) => {
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 { useCallback, useContext, useState } from 'preact/hooks';
import { createContext } from 'react';
@@ -21,29 +23,31 @@ export const usePremiumModal = (): PremiumModalContextData => {
return value;
};
export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
const [featureName, setFeatureName] = useState<null | string>(null);
interface Props {
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(() => {
setFeatureName(null);
}, [setFeatureName]);
const showModal = !!featureName;
const showModal = !!featureName;
return (
<>
{showModal && (
<PremiumFeaturesModal
showModal={!!featureName}
featureName={featureName}
onClose={closeModal}
/>
)}
<PremiumModalProvider_ value={{ activate }}>
{children}
</PremiumModalProvider_>
</>
);
};
return (
<>
{showModal && (
<PremiumFeaturesModal
showModal={!!featureName}
featureName={featureName}
onClose={close}
/>
)}
<PremiumModalProvider_ value={{ activate }}>
{children}
</PremiumModalProvider_>
</>
);
}
);

View File

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

View File

@@ -1,108 +1,29 @@
import { TagsList } from '@/components/TagsList';
import { toDirective } from '@/components/utils';
import { WebApplication } from '@/ui_models/application';
import { TagsList } from '@/components/Tags/TagsList';
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 { useCallback } from 'preact/hooks';
import { IconButton } from '../IconButton';
import { PremiumModalProvider, usePremiumModal } from '../Premium';
import { TagsSectionAddButton } from './TagsSectionAddButton';
import { TagsSectionTitle } from './TagsSectionTitle';
type Props = {
application: WebApplication;
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(
({ application, appState }) => {
({ 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>
<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>
<TagsList application={application} appState={appState} />
</section>
</PremiumModalProvider>
</div>
<TagsList appState={appState} />
</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);