From 80bf7baf16e1f926d2d562bd9290d871cd5d1075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karol=20S=C3=B3jko?= Date: Wed, 5 Jan 2022 16:12:22 +0100 Subject: [PATCH] Revert "Revert "feat: native smart tags (#782)"" This reverts commit b57350c8991a87f6a9534878e9f35c5a7f31ae61. --- app/assets/icons/ic-notes.svg | 3 + app/assets/javascripts/app.ts | 79 ++--- app/assets/javascripts/components/Icon.tsx | 2 + .../javascripts/components/Navigation.tsx | 117 +++++++ app/assets/javascripts/components/NoteTag.tsx | 2 +- .../javascripts/components/NotesView.tsx | 6 +- .../components/Premium/usePremiumModal.tsx | 50 +-- .../components/{ => Tags}/RootTagDropZone.tsx | 8 +- .../components/Tags/SmartTagsList.tsx | 29 ++ .../components/Tags/SmartTagsListItem.tsx | 163 ++++++++++ .../components/Tags/SmartTagsSection.tsx | 19 ++ .../javascripts/components/Tags/TagsList.tsx | 48 +++ .../components/{ => Tags}/TagsListItem.tsx | 68 ++-- .../components/Tags/TagsSection.tsx | 109 +------ .../components/Tags/TagsSectionAddButton.tsx | 30 ++ .../components/Tags/TagsSectionTitle.tsx | 62 ++++ .../javascripts/components/Tags/dragndrop.ts | 9 + .../javascripts/components/TagsList.tsx | 153 --------- app/assets/javascripts/strings.ts | 2 + .../ui_models/app_state/app_state.ts | 217 ++++++------- .../ui_models/app_state/features_state.ts | 63 +++- .../ui_models/app_state/notes_state.ts | 15 +- .../ui_models/app_state/notes_view_state.ts | 4 +- .../ui_models/app_state/tags_state.ts | 261 ++++++++++++++- .../javascripts/ui_models/panel_resizer.ts | 11 +- .../views/application/application-view.pug | 2 +- .../views/application/application_view.ts | 12 +- app/assets/javascripts/views/constants.ts | 2 +- app/assets/javascripts/views/index.ts | 1 - .../javascripts/views/tags/tags-view.pug | 43 --- .../javascripts/views/tags/tags_view.ts | 298 ------------------ app/assets/stylesheets/_focused.scss | 6 +- app/assets/stylesheets/_tags.scss | 4 + 33 files changed, 1030 insertions(+), 868 deletions(-) create mode 100644 app/assets/icons/ic-notes.svg create mode 100644 app/assets/javascripts/components/Navigation.tsx rename app/assets/javascripts/components/{ => Tags}/RootTagDropZone.tsx (88%) create mode 100644 app/assets/javascripts/components/Tags/SmartTagsList.tsx create mode 100644 app/assets/javascripts/components/Tags/SmartTagsListItem.tsx create mode 100644 app/assets/javascripts/components/Tags/SmartTagsSection.tsx create mode 100644 app/assets/javascripts/components/Tags/TagsList.tsx rename app/assets/javascripts/components/{ => Tags}/TagsListItem.tsx (81%) create mode 100644 app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx create mode 100644 app/assets/javascripts/components/Tags/TagsSectionTitle.tsx create mode 100644 app/assets/javascripts/components/Tags/dragndrop.ts delete mode 100644 app/assets/javascripts/components/TagsList.tsx delete mode 100644 app/assets/javascripts/views/tags/tags-view.pug delete mode 100644 app/assets/javascripts/views/tags/tags_view.ts diff --git a/app/assets/icons/ic-notes.svg b/app/assets/icons/ic-notes.svg new file mode 100644 index 000000000..ece661333 --- /dev/null +++ b/app/assets/icons/ic-notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 2d1762e8d..bd5d4e066 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -20,23 +20,32 @@ declare global { } } -import { SNLog } from '@standardnotes/snjs'; -import angular from 'angular'; -import { configRoutes } from './routes'; - -import { ApplicationGroup } from './ui_models/application_group'; -import { AccountSwitcher } from './views/account_switcher/account_switcher'; - +import { ComponentViewDirective } from '@/components/ComponentView'; +import { NavigationDirective } from '@/components/Navigation'; +import { PinNoteButtonDirective } from '@/components/PinNoteButton'; +import { IsWebPlatform, WebAppVersion } from '@/version'; import { ApplicationGroupView, - ApplicationView, - NoteGroupViewDirective, - NoteViewDirective, - TagsView, - FooterView, - ChallengeModal, + 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 { autofocus, clickOutside, @@ -46,49 +55,31 @@ import { infiniteScroll, lowercase, selectOnFocus, - snEnter, + snEnter } from './directives/functional'; - import { ActionsMenu, EditorMenu, + HistoryMenu, InputModal, MenuRow, PanelResizer, PasswordWizard, PermissionsModal, RevisionPreviewModal, - HistoryMenu, - SyncResolutionMenu, + SyncResolutionMenu } from './directives/views'; - import { trusted } from './filters'; -import { isDev } from './utils'; +import { PreferencesDirective } from './preferences'; +import { PurchaseFlowDirective } from './purchaseFlow'; +import { configRoutes } from './routes'; +import { Bridge } from './services/bridge'; import { BrowserBridge } from './services/browserBridge'; import { startErrorReporting } from './services/errorReporting'; import { StartApplication } from './startApplication'; -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'; +import { ApplicationGroup } from './ui_models/application_group'; +import { isDev } from './utils'; +import { AccountSwitcher } from './views/account_switcher/account_switcher'; function reloadHiddenFirefoxTab(): boolean { /** @@ -143,7 +134,6 @@ 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 @@ -188,8 +178,7 @@ const startApplication: StartApplication = async function startApplication( .directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('tagsList', TagsListDirective) - .directive('tagsSection', TagsSectionDirective) + .directive('navigation', NavigationDirective) .directive('preferences', PreferencesDirective) .directive('purchaseFlow', PurchaseFlowDirective) .directive('notesView', NotesViewDirective) diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index bb82794cf..6d5b6d3bc 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -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, diff --git a/app/assets/javascripts/components/Navigation.tsx b/app/assets/javascripts/components/Navigation.tsx new file mode 100644 index 000000000..b3e5dcfd4 --- /dev/null +++ b/app/assets/javascripts/components/Navigation.tsx @@ -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 = observer( + ({ application }) => { + const appState = useMemo(() => application.getAppState(), [application]); + const componentViewer = appState.foldersComponentViewer; + const enableNativeSmartTagsFeature = + appState.features.enableNativeSmartTagsFeature; + const [panelRef, setPanelRef] = useState(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 ( + +
+ {componentViewer ? ( +
+
+ +
+
+ ) : ( +
+
+
+
+ Views +
+ {!enableNativeSmartTagsFeature && ( +
+
+ +
+
+ )} +
+
+
+
+ + +
+
+
+ )} + {panelRef && ( + + )} +
+
+ ); + } +); + +export const NavigationDirective = toDirective(Navigation); diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx index 91dc12aba..bd6ecd767 100644 --- a/app/assets/javascripts/components/NoteTag.tsx +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -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); } diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx index 6c1b8e0df..622bd31aa 100644 --- a/app/assets/javascripts/components/NotesView.tsx +++ b/app/assets/javascripts/components/NotesView.tsx @@ -124,9 +124,9 @@ const NotesView: FunctionComponent = observer( }; const panelResizeFinishCallback: ResizeFinishCallback = ( - _w, - _l, - _mw, + _lastWidth, + _lastLeft, + _isMaxWidth, isCollapsed ) => { appState.noteTags.reloadTagsContainerMaxWidth(); diff --git a/app/assets/javascripts/components/Premium/usePremiumModal.tsx b/app/assets/javascripts/components/Premium/usePremiumModal.tsx index d52b15823..a30e9dbf9 100644 --- a/app/assets/javascripts/components/Premium/usePremiumModal.tsx +++ b/app/assets/javascripts/components/Premium/usePremiumModal.tsx @@ -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); +interface Props { + state: FeaturesState; +} - const activate = setFeatureName; +export const PremiumModalProvider: FunctionalComponent = 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 && ( - - )} - - {children} - - - ); -}; + return ( + <> + {showModal && ( + + )} + + {children} + + + ); + } +); diff --git a/app/assets/javascripts/components/RootTagDropZone.tsx b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx similarity index 88% rename from app/assets/javascripts/components/RootTagDropZone.tsx rename to app/assets/javascripts/components/Tags/RootTagDropZone.tsx index 0e39ef5a8..1c6908a51 100644 --- a/app/assets/javascripts/components/RootTagDropZone.tsx +++ b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx @@ -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 = observer( ({ tagsState, featuresState }) => { const premiumModal = usePremiumModal(); const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature; - const hasFolders = tagsState.hasFolders; + const hasFolders = featuresState.hasFolders; const [{ isOver, canDrop }, dropRef] = useDrop( () => ({ diff --git a/app/assets/javascripts/components/Tags/SmartTagsList.tsx b/app/assets/javascripts/components/Tags/SmartTagsList.tsx new file mode 100644 index 000000000..77b06eec8 --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsList.tsx @@ -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 = observer( + ({ appState }) => { + const allTags = appState.tags.smartTags; + + return ( + <> + {allTags.map((tag) => { + return ( + + ); + })} + + ); + } +); diff --git a/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx new file mode 100644 index 000000000..0284b0ae9 --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx @@ -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 = observer( + ({ tag, tagsState, features }) => { + const [title, setTitle] = useState(tag.title || ''); + const inputRef = useRef(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 ( + <> +
+ {!tag.errorDecrypting ? ( +
+ {isSmartTagsEnabled && ( +
+ +
+ )} + +
+ {tag.isAllTag && tagsState.allNotesCount} +
+
+ ) : null} + {!tag.isSystemSmartTag && ( +
+ {tag.conflictOf && ( +
+ Conflicted Copy {tag.conflictOf} +
+ )} + {tag.errorDecrypting && !tag.waitingForKey && ( +
Missing Keys
+ )} + {tag.errorDecrypting && tag.waitingForKey && ( +
+ Waiting For Keys +
+ )} + {isSelected && ( +
+ {!isEditing && ( + + Rename + + )} + {isEditing && ( + + Save + + )} + + Delete + +
+ )} +
+ )} +
+ + ); + } +); diff --git a/app/assets/javascripts/components/Tags/SmartTagsSection.tsx b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx new file mode 100644 index 000000000..d647ca6ef --- /dev/null +++ b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx @@ -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 = observer( + ({ appState }) => { + return ( +
+ +
+ ); + } +); diff --git a/app/assets/javascripts/components/Tags/TagsList.tsx b/app/assets/javascripts/components/Tags/TagsList.tsx new file mode 100644 index 000000000..0d6ab0b15 --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsList.tsx @@ -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 = observer(({ appState }) => { + const tagsState = appState.tags; + const allTags = tagsState.allLocalRootTags; + + const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend; + + return ( + + {allTags.length === 0 ? ( +
+ No tags. Create one using the add button above. +
+ ) : ( + <> + {allTags.map((tag) => { + return ( + + ); + })} + + + )} +
+ ); +}); diff --git a/app/assets/javascripts/components/TagsListItem.tsx b/app/assets/javascripts/components/Tags/TagsListItem.tsx similarity index 81% rename from app/assets/javascripts/components/TagsListItem.tsx rename to app/assets/javascripts/components/Tags/TagsListItem.tsx index ed6e8cf5e..8dac77728 100644 --- a/app/assets/javascripts/components/TagsListItem.tsx +++ b/app/assets/javascripts/components/Tags/TagsListItem.tsx @@ -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 = observer( - ({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => { + ({ tag, features, tagsState, level }) => { const [title, setTitle] = useState(tag.title || ''); const inputRef = useRef(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 = 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) => { @@ -116,18 +95,16 @@ export const TagsListItem: FunctionComponent = 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 = observer( key={tag.uuid} tag={tag} tagsState={tagsState} - selectTag={selectTag} - saveTag={saveTag} - removeTag={removeTag} - appState={appState} + features={features} /> ); })} diff --git a/app/assets/javascripts/components/Tags/TagsSection.tsx b/app/assets/javascripts/components/Tags/TagsSection.tsx index 5cc7a452d..636206f73 100644 --- a/app/assets/javascripts/components/Tags/TagsSection.tsx +++ b/app/assets/javascripts/components/Tags/TagsSection.tsx @@ -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 ( - 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 ( - <> -
- Tags -
- - ); - } - - if (hasFolders) { - return ( - <> -
- Folders -
- - ); - } - - return ( - <> -
- Tags - - - -
- - ); -}); - export const TagsSection: FunctionComponent = observer( - ({ application, appState }) => { + ({ appState }) => { return ( - -
-
-
- - -
+
+
+
+ +
- -
- +
+ +
); } ); - -export const TagsSectionDirective = toDirective(TagsSection); diff --git a/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx new file mode 100644 index 000000000..9a748b7dc --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx @@ -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 = observer( + ({ tags, features }) => { + const isNativeFoldersEnabled = features.enableNativeFoldersFeature; + + if (!isNativeFoldersEnabled) { + return null; + } + + return ( + tags.createNewTemplate()} + /> + ); + } +); diff --git a/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx new file mode 100644 index 000000000..6298dbf1e --- /dev/null +++ b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx @@ -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 = 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 ( + <> +
+ Tags +
+ + ); + } + + if (hasFolders) { + return ( + <> +
+ Folders +
+ + ); + } + + return ( + <> +
+ Tags + + + +
+ + ); + } +); diff --git a/app/assets/javascripts/components/Tags/dragndrop.ts b/app/assets/javascripts/components/Tags/dragndrop.ts new file mode 100644 index 000000000..5a5033ad5 --- /dev/null +++ b/app/assets/javascripts/components/Tags/dragndrop.ts @@ -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 }; diff --git a/app/assets/javascripts/components/TagsList.tsx b/app/assets/javascripts/components/TagsList.tsx deleted file mode 100644 index 66925c2c1..000000000 --- a/app/assets/javascripts/components/TagsList.tsx +++ /dev/null @@ -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 = 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( - insertedTag.uuid, - (m) => { - m.title = newTitle; - } - ); - - selectTag(changedTag as SNTag); - await application.saveItem(insertedTag.uuid); - } else { - await application.changeAndSaveItem( - 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 ( - - - {allTags.length === 0 ? ( -
- No tags. Create one using the add button above. -
- ) : ( - <> - {allTags.map((tag) => { - return ( - - ); - })} - - - )} -
-
- ); - } -); - -export const TagsListDirective = toDirective(TagsList); diff --git a/app/assets/javascripts/strings.ts b/app/assets/javascripts/strings.ts index 1f6f647d8..75a099619 100644 --- a/app/assets/javascripts/strings.ts +++ b/app/assets/javascripts/strings.ts @@ -22,6 +22,8 @@ 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.'; diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts index 71d4440f0..124fa2e16 100644 --- a/app/assets/javascripts/ui_models/app_state/app_state.ts +++ b/app/assets/javascripts/ui_models/app_state/app_state.ts @@ -6,20 +6,26 @@ 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, - runInAction, + reaction, } from 'mobx'; import { ActionsMenuState } from './actions_menu_state'; import { FeaturesState } from './features_state'; @@ -72,11 +78,6 @@ 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(); @@ -92,10 +93,16 @@ 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, @@ -160,30 +167,27 @@ export class AppState { this.showBetaWarning = false; } - this.selectedTag = undefined; - this.previouslySelectedTag = undefined; - this.editingTag = undefined; - this._templateTag = undefined; + this.foldersComponentViewer = 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 { @@ -206,6 +210,8 @@ export class AppState { } document.removeEventListener('visibilitychange', this.onVisibilityChange); this.onVisibilityChange = undefined; + this.tagChangedDisposer(); + this.foldersComponentViewerDisposer(); } openSessionsModal(): void { @@ -234,16 +240,16 @@ export class AppState { if (!this.multiEditorSupport) { this.closeActiveNoteController(); } - const activeTagUuid = this.selectedTag - ? this.selectedTag.isSmartTag - ? undefined - : this.selectedTag.uuid - : undefined; + + const selectedTag = this.selectedTag; + + const activeRegularTagUuid = + selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined; await this.application.noteControllerGroup.createNoteView( undefined, title, - activeTagUuid + activeRegularTagUuid ); } @@ -275,10 +281,88 @@ export class AppState { } } + private tagChangedNotifier(): IReactionDisposer { + return reaction( + () => this.tags.selectedUuid, + () => { + const tag = this.tags.selected; + const previousTag = this.tags.previouslySelected; + + if (!tag) { + return; + } + + if (this.application.isTemplateItem(tag)) { + return; + } + + this.notifyEvent(AppStateEvent.TagChanged, { + tag, + previousTag, + }); + } + ); + } + + async setFoldersComponent(component?: SNComponent) { + const foldersComponentViewer = this.foldersComponentViewer; + + if (foldersComponentViewer) { + this.application.componentManager.destroyComponentViewer( + foldersComponentViewer + ); + this.foldersComponentViewer = undefined; + } + + if (component) { + this.foldersComponentViewer = + this.application.componentManager.createComponentViewer( + component, + undefined, + this.tags.onFoldersComponentMessage.bind(this.tags) + ); + } + } + + private subscribeToFoldersComponentChanges() { + return this.application.streamItems( + [ContentType.Component], + async (items, source) => { + if ( + isPayloadSourceInternalChange(source) || + source === PayloadSource.InitialObserverRegistrationPush + ) { + return; + } + const components = items as SNComponent[]; + const hasFoldersChange = !!components.find( + (component) => component.area === ComponentArea.TagsList + ); + if (hasFoldersChange) { + const componentViewer = this.application.componentManager + .componentsForArea(ComponentArea.TagsList) + .find((component) => component.active); + + this.setFoldersComponent(componentViewer); + } + } + ); + } + + public get selectedTag(): SNTag | SNSmartTag | undefined { + return this.tags.selected; + } + + public set selectedTag(tag: SNTag | SNSmartTag | undefined) { + this.tags.selected = tag; + } + streamNotesAndTags() { 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( @@ -293,13 +377,13 @@ export class AppState { this.closeNoteController(noteController); } else if ( note.trashed && - !this.selectedTag?.isTrashTag && + !selectedTag?.isTrashTag && !this.searchOptions.includeTrashed ) { this.closeNoteController(noteController); } else if ( note.archived && - !this.selectedTag?.isArchiveTag && + !selectedTag?.isArchiveTag && !this.searchOptions.includeArchived && !this.application.getPreference(PrefKey.NotesShowArchived, false) ) { @@ -307,17 +391,6 @@ export class AppState { } } } - if (this.selectedTag) { - const matchingTag = items.find( - (candidate) => - this.selectedTag && candidate.uuid === this.selectedTag.uuid - ); - if (matchingTag) { - runInAction(() => { - this.selectedTag = matchingTag as SNTag; - }); - } - } } ); } @@ -385,74 +458,6 @@ export class AppState { }); } - setSelectedTag(tag: SNTag) { - if (tag.conflictOf) { - this.application.changeAndSaveItem(tag.uuid, (mutator) => { - mutator.conflictOf = undefined; - }); - } - - if (this.selectedTag === tag) { - return; - } - - this.previouslySelectedTag = this.selectedTag; - this.selectedTag = tag; - - if (this.templateTag?.uuid === tag.uuid) { - return; - } - - this.notifyEvent(AppStateEvent.TagChanged, { - tag: tag, - previousTag: this.previouslySelectedTag, - }); - } - - public getSelectedTag() { - return this.selectedTag; - } - - public get templateTag(): SNTag | undefined { - return this._templateTag; - } - - public set templateTag(tag: SNTag | undefined) { - const previous = this._templateTag; - this._templateTag = tag; - - if (tag) { - this.setSelectedTag(tag); - this.editingTag = tag; - } else if (previous) { - this.selectedTag = - previous === this.selectedTag ? undefined : this.selectedTag; - this.editingTag = - previous === this.editingTag ? undefined : this.editingTag; - } - } - - public removeTag(tag: SNTag) { - this.application.deleteItem(tag); - this.setSelectedTag(this.tags.smartTags[0]); - } - - public async createNewTag() { - if (this.templateTag) { - return; - } - - const newTag = (await this.application.createTemplateItem( - ContentType.Tag - )) as SNTag; - this.templateTag = newTag; - } - - public async undoCreateNewTag() { - const previousTag = this.previouslySelectedTag || this.tags.smartTags[0]; - this.setSelectedTag(previousTag); - } - /** Returns the tags that are referncing this note */ public getNoteTags(note: SNNote) { return this.application.referencingForItem(note).filter((ref) => { diff --git a/app/assets/javascripts/ui_models/app_state/features_state.ts b/app/assets/javascripts/ui_models/app_state/features_state.ts index ca022d8b4..323f2931f 100644 --- a/app/assets/javascripts/ui_models/app_state/features_state.ts +++ b/app/assets/javascripts/ui_models/app_state/features_state.ts @@ -3,13 +3,22 @@ import { FeatureIdentifier, FeatureStatus, } from '@standardnotes/snjs'; -import { computed, makeObservable, observable, runInAction } from 'mobx'; +import { + action, + computed, + makeObservable, + observable, + runInAction, + when, +} 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). @@ -19,23 +28,37 @@ 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: @@ -52,25 +75,25 @@ export class FeaturesState { return this.enableUnfinishedFeatures; } + public get enableNativeSmartTagsFeature(): boolean { + return this.enableUnfinishedFeatures; + } + public get hasFolders(): boolean { return this._hasFolders; } - public set hasFolders(hasFolders: boolean) { - if (!hasFolders) { - this._hasFolders = false; - return; - } + public get hasSmartTags(): boolean { + return this._hasSmartTags; + } - if (!this.hasNativeFolders()) { - this.application.alertService?.alert( - `${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.` - ); - this._hasFolders = false; - return; - } + public async showPremiumAlert(featureName: string): Promise { + this._premiumAlertFeatureName = featureName; + return when(() => this._premiumAlertFeatureName === undefined); + } - this._hasFolders = hasFolders; + public async closePremiumAlert(): Promise { + this._premiumAlertFeatureName = undefined; } private hasNativeFolders(): boolean { @@ -84,4 +107,16 @@ 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; + } } diff --git a/app/assets/javascripts/ui_models/app_state/notes_state.ts b/app/assets/javascripts/ui_models/app_state/notes_state.ts index 44dda28e1..8bfbae2c2 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts @@ -115,12 +115,12 @@ export class NotesState { async selectNote(uuid: UuidString, userTriggered?: boolean): Promise { 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 && - (this.io.activeModifiers.has(KeyboardModifier.Meta) || - this.io.activeModifiers.has(KeyboardModifier.Ctrl)) - ) { + if (userTriggered && (hasMeta || hasCtrl)) { if (this.selectedNotes[uuid]) { delete this.selectedNotes[uuid]; } else if (await this.application.authorizeNoteAccess(note)) { @@ -129,10 +129,7 @@ export class NotesState { this.lastSelectedNote = note; }); } - } else if ( - userTriggered && - this.io.activeModifiers.has(KeyboardModifier.Shift) - ) { + } else if (userTriggered && hasShift) { await this.selectNotesRange(note); } else { const shouldSelectNote = diff --git a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts index e0be2a0e0..a7ad404bb 100644 --- a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts +++ b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts @@ -495,7 +495,9 @@ export class NotesViewState { this.reloadNotesDisplayOptions(); this.reloadNotes(); - if (this.notes.length > 0) { + const hasSomeNotes = this.notes.length > 0; + + if (hasSomeNotes) { this.selectFirstNote(); } else if (dbLoaded) { if ( diff --git a/app/assets/javascripts/ui_models/app_state/tags_state.ts b/app/assets/javascripts/ui_models/app_state/tags_state.ts index 13e698d89..8a19847e6 100644 --- a/app/assets/javascripts/ui_models/app_state/tags_state.ts +++ b/app/assets/javascripts/ui_models/app_state/tags_state.ts @@ -1,8 +1,13 @@ +import { confirmDialog } from '@/services/alertService'; +import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings'; import { + ComponentAction, ContentType, + MessageData, SNSmartTag, SNTag, - UuidString, + TagMutator, + UuidString } from '@standardnotes/snjs'; import { action, @@ -10,14 +15,21 @@ import { makeAutoObservable, makeObservable, observable, - runInAction, + runInAction } from 'mobx'; import { WebApplication } from '../application'; -import { FeaturesState } from './features_state'; +import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state'; + +type AnyTag = SNTag | SNSmartTag; export class TagsState { tags: SNTag[] = []; smartTags: SNSmartTag[] = []; + allNotesCount_ = 0; + selected_: AnyTag | undefined; + previouslySelected_: AnyTag | undefined; + editing_: SNTag | undefined; + private readonly tagsCountsState: TagsCountsState; constructor( @@ -27,22 +39,40 @@ 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 @@ -50,18 +80,42 @@ 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.hasFolders) { + if (!this.features.hasFolders) { return []; } @@ -69,7 +123,10 @@ export class TagsState { return []; } - const children = this.application.getTagChildren(tag); + const children = this.application + .getTagChildren(tag) + .filter((tag) => !tag.isSmartTag); + const childrenUuids = children.map((childTag) => childTag.uuid); const childrenTags = this.tags.filter((tag) => childrenUuids.includes(tag.uuid) @@ -100,7 +157,7 @@ export class TagsState { } get rootTags(): SNTag[] { - if (!this.hasFolders) { + if (!this.features.hasFolders) { return this.tags; } @@ -111,12 +168,192 @@ export class TagsState { return this.tags.length; } - public get hasFolders(): boolean { - return this.features.hasFolders; + public get allNotesCount(): number { + return this.allNotesCount_; } - public set hasFolders(hasFolders: boolean) { - this.features.hasFolders = 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( + insertedTag.uuid, + (m) => { + m.title = newTitle; + } + ); + this.selected = changedTag as SNTag; + await this.application.saveItem(insertedTag.uuid); + } + } else { + await this.application.changeAndSaveItem( + tag.uuid, + (mutator) => { + mutator.title = newTitle; + } + ); + } + } + + private countAllNotes(): number { + const allTag = this.application.getSmartTags().find((tag) => tag.isAllTag); + + if (!allTag) { + console.error(STRING_MISSING_SYSTEM_TAG); + return -1; + } + + const notes = this.application + .notesMatchingSmartTag(allTag) + .filter((note) => { + return !note.archived && !note.trashed; + }); + + return notes.length; + } + + public onFoldersComponentMessage( + action: ComponentAction, + data: MessageData + ): void { + if (action === ComponentAction.SelectItem) { + const item = data.item; + + if (!item) { + return; + } + + if ( + item.content_type === ContentType.Tag || + item.content_type === ContentType.SmartTag + ) { + const matchingTag = this.application.findItem(item.uuid); + + if (matchingTag) { + this.selected = matchingTag as AnyTag; + return; + } + } + } else if (action === ComponentAction.ClearSelection) { + this.selected = this.smartTags[0]; + } } public get hasAtLeastOneFolder(): boolean { diff --git a/app/assets/javascripts/ui_models/panel_resizer.ts b/app/assets/javascripts/ui_models/panel_resizer.ts index 66ce0f9e9..c53b49c9d 100644 --- a/app/assets/javascripts/ui_models/panel_resizer.ts +++ b/app/assets/javascripts/ui_models/panel_resizer.ts @@ -56,6 +56,12 @@ 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; @@ -66,16 +72,15 @@ 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); diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug index 01630f2b5..58466fcf4 100644 --- a/app/assets/javascripts/views/application/application-view.pug +++ b/app/assets/javascripts/views/application/application-view.pug @@ -5,7 +5,7 @@ ng-class='self.state.appClass', ng-if='!self.state.needsUnlock && self.state.launched' ) - tags-view(application='self.application') + navigation(application='self.application', appState='self.appState') notes-view( application='self.application' app-state='self.appState' diff --git a/app/assets/javascripts/views/application/application_view.ts b/app/assets/javascripts/views/application/application_view.ts index cc861404b..c2c41eede 100644 --- a/app/assets/javascripts/views/application/application_view.ts +++ b/app/assets/javascripts/views/application/application_view.ts @@ -8,7 +8,7 @@ import { Challenge, removeFromArray, } from '@standardnotes/snjs'; -import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants'; +import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants'; import { STRING_DEFAULT_FILE_ERROR } from '@/strings'; import { 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 tagsCollapsed = false; + private navigationCollapsed = 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_TAGS) { - this.tagsCollapsed = collapsed; + if (panel === PANEL_NAME_NAVIGATION) { + this.navigationCollapsed = collapsed; } let appClass = ''; if (this.notesCollapsed) { appClass += 'collapsed-notes'; } - if (this.tagsCollapsed) { - appClass += ' collapsed-tags'; + if (this.navigationCollapsed) { + appClass += ' collapsed-navigation'; } this.setState({ appClass }); } else if (eventName === AppStateEvent.WindowDidFocus) { diff --git a/app/assets/javascripts/views/constants.ts b/app/assets/javascripts/views/constants.ts index 57c83d9af..a84c79f91 100644 --- a/app/assets/javascripts/views/constants.ts +++ b/app/assets/javascripts/views/constants.ts @@ -1,4 +1,4 @@ export const PANEL_NAME_NOTES = 'notes'; -export const PANEL_NAME_TAGS = 'tags'; +export const PANEL_NAME_NAVIGATION = 'navigation'; 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])?)$/; diff --git a/app/assets/javascripts/views/index.ts b/app/assets/javascripts/views/index.ts index 82223ae4c..81f8920df 100644 --- a/app/assets/javascripts/views/index.ts +++ b/app/assets/javascripts/views/index.ts @@ -4,5 +4,4 @@ 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'; diff --git a/app/assets/javascripts/views/tags/tags-view.pug b/app/assets/javascripts/views/tags/tags-view.pug deleted file mode 100644 index 4256b0181..000000000 --- a/app/assets/javascripts/views/tags/tags-view.pug +++ /dev/null @@ -1,43 +0,0 @@ -#tags-column.sn-component.section.tags(aria-label='Tags') - .component-view-container(ng-if='self.state.componentViewer') - component-view.component-view( - component-viewer='self.state.componentViewer', - application='self.application' - app-state='self.appState' - ) - #tags-content.content(ng-if='!(self.state.componentViewer)') - .tags-title-section.section-title-bar - .section-title-bar-header - .sk-h3.title - span.sk-bold Views - .sk-button.sk-secondary-contrast.wide( - ng-click='self.clickedAddNewTag()', - title='Create a new tag' - ) - .sk-label - i.icon.ion-plus.add-button - .scrollable - .infinite-scroll - .tag( - ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}", - ng-click='self.selectTag(tag)', - ng-repeat='tag in self.state.smartTags track by tag.uuid' - ) - .tag-info - .title(ng-if="!tag.errorDecrypting") {{tag.title}} - .count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}} - .danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy - .danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys - .info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys - tags-section( - application='self.application', - app-state='self.appState' - ) - panel-resizer( - collapsable='true', - control='self.panelPuppet', - default-width='150', - hoverable='true', - on-resize-finish='self.onPanelResize', - panel-id="'tags-column'" - ) diff --git a/app/assets/javascripts/views/tags/tags_view.ts b/app/assets/javascripts/views/tags/tags_view.ts deleted file mode 100644 index 69b94db50..000000000 --- a/app/assets/javascripts/views/tags/tags_view.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { PanelPuppet, WebDirective } from '@/types'; -import { WebApplication } from '@/ui_models/application'; -import { AppStateEvent } from '@/ui_models/app_state'; -import { PANEL_NAME_TAGS } from '@/views/constants'; -import { - ApplicationEvent, - ComponentAction, - ComponentArea, - ComponentViewer, - ContentType, - isPayloadSourceInternalChange, - MessageData, - PayloadSource, - PrefKey, - SNComponent, - SNSmartTag, - SNTag, - UuidString, -} from '@standardnotes/snjs'; -import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl'; -import template from './tags-view.pug'; - -type NoteCounts = Partial>; - -type TagState = { - smartTags: SNSmartTag[]; - noteCounts: NoteCounts; - selectedTag?: SNTag; - componentViewer?: ComponentViewer; -}; - -class TagsViewCtrl extends PureViewCtrl { - /** 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> = {}; - 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; - - 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; - } -} diff --git a/app/assets/stylesheets/_focused.scss b/app/assets/stylesheets/_focused.scss index d16d4866a..d6e27c824 100644 --- a/app/assets/stylesheets/_focused.scss +++ b/app/assets/stylesheets/_focused.scss @@ -35,7 +35,7 @@ opacity: 1; } - .section.tags, + navigation, notes-view { will-change: opacity; animation: fade-out 1.25s forwards; @@ -45,7 +45,7 @@ flex: none !important; } - .section.tags:hover { + navigation:hover { flex: initial; width: 0px !important; } @@ -57,7 +57,7 @@ } .disable-focus-mode { - .section.tags, + navigation, notes-view { transition: width 1.25s; will-change: opacity; diff --git a/app/assets/stylesheets/_tags.scss b/app/assets/stylesheets/_tags.scss index e44610a44..830a2d818 100644 --- a/app/assets/stylesheets/_tags.scss +++ b/app/assets/stylesheets/_tags.scss @@ -1,3 +1,7 @@ +#tags-column { + width: 100%; +} + .tags { width: 180px; flex-grow: 0;