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:
@@ -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,
|
||||
|
||||
117
app/assets/javascripts/components/Navigation.tsx
Normal file
117
app/assets/javascripts/components/Navigation.tsx
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -124,9 +124,9 @@ const NotesView: FunctionComponent<Props> = observer(
|
||||
};
|
||||
|
||||
const panelResizeFinishCallback: ResizeFinishCallback = (
|
||||
_w,
|
||||
_l,
|
||||
_mw,
|
||||
_lastWidth,
|
||||
_lastLeft,
|
||||
_isMaxWidth,
|
||||
isCollapsed
|
||||
) => {
|
||||
appState.noteTags.reloadTagsContainerMaxWidth();
|
||||
|
||||
@@ -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_>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>(
|
||||
() => ({
|
||||
29
app/assets/javascripts/components/Tags/SmartTagsList.tsx
Normal file
29
app/assets/javascripts/components/Tags/SmartTagsList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
163
app/assets/javascripts/components/Tags/SmartTagsListItem.tsx
Normal file
163
app/assets/javascripts/components/Tags/SmartTagsListItem.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
19
app/assets/javascripts/components/Tags/SmartTagsSection.tsx
Normal file
19
app/assets/javascripts/components/Tags/SmartTagsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
48
app/assets/javascripts/components/Tags/TagsList.tsx
Normal file
48
app/assets/javascripts/components/Tags/TagsList.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
62
app/assets/javascripts/components/Tags/TagsSectionTitle.tsx
Normal file
62
app/assets/javascripts/components/Tags/TagsSectionTitle.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
9
app/assets/javascripts/components/Tags/dragndrop.ts
Normal file
9
app/assets/javascripts/components/Tags/dragndrop.ts
Normal 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 };
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user