diff --git a/app/assets/javascripts/components/ApplicationView.tsx b/app/assets/javascripts/components/ApplicationView.tsx index 556da59ac..36b95f40e 100644 --- a/app/assets/javascripts/components/ApplicationView.tsx +++ b/app/assets/javascripts/components/ApplicationView.tsx @@ -26,6 +26,7 @@ import { PermissionsModal } from './PermissionsModal'; import { RevisionHistoryModalWrapper } from './RevisionHistoryModal/RevisionHistoryModalWrapper'; import { PremiumModalProvider } from './Premium'; import { ConfirmSignoutContainer } from './ConfirmSignoutModal'; +import { TagsContextMenu } from './Tags/TagContextMenu'; type Props = { application: WebApplication; @@ -268,6 +269,8 @@ export class ApplicationView extends PureComponent { appState={this.appState} /> + + = observer( }, [inputRef]); const onClickDelete = useCallback(() => { - tagsState.remove(tag); + tagsState.remove(tag, true); }, [tagsState, tag]); const isFaded = !tag.isAllTag; @@ -111,6 +111,7 @@ export const SmartTagsListItem: FunctionComponent = observer( = observer( + ({ appState }) => { + const selectedTag = appState.tags.selected; + + if (!selectedTag) { + return null; + } + + const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } = + appState.tags; + + const contextMenuRef = useRef(null); + const [closeOnBlur] = useCloseOnBlur(contextMenuRef, (open: boolean) => + appState.tags.setContextMenuOpen(open) + ); + + const reloadContextMenuLayout = useCallback(() => { + appState.tags.reloadContextMenuLayout(); + }, [appState.tags]); + + useEffect(() => { + window.addEventListener('resize', reloadContextMenuLayout); + return () => { + window.removeEventListener('resize', reloadContextMenuLayout); + }; + }, [reloadContextMenuLayout]); + + const onClickAddSubtag = useCallback(() => { + appState.tags.setContextMenuOpen(false); + appState.tags.setAddingSubtagTo(selectedTag); + }, [appState.tags, selectedTag]); + + const onClickRename = useCallback(() => { + appState.tags.setContextMenuOpen(false); + appState.tags.editingTag = selectedTag; + }, [appState.tags, selectedTag]); + + const onClickDelete = useCallback(() => { + appState.tags.remove(selectedTag, true); + }, [appState.tags, selectedTag]); + + return contextMenuOpen ? ( +
+ { + appState.tags.setContextMenuOpen(false); + }} + > + + + Add subtag + + + + Rename + + + + Delete + + +
+ ) : null; + } +); diff --git a/app/assets/javascripts/components/Tags/TagsList.tsx b/app/assets/javascripts/components/Tags/TagsList.tsx index 42eafd822..f5a0d3b8a 100644 --- a/app/assets/javascripts/components/Tags/TagsList.tsx +++ b/app/assets/javascripts/components/Tags/TagsList.tsx @@ -1,5 +1,6 @@ import { AppState } from '@/ui_models/app_state'; import { isMobile } from '@/utils'; +import { SNTag } from '@standardnotes/snjs'; import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; import { DndProvider } from 'react-dnd'; @@ -18,6 +19,20 @@ export const TagsList: FunctionComponent = observer(({ appState }) => { const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend; + const openTagContextMenu = (posX: number, posY: number) => { + appState.tags.setContextMenuClickLocation({ + x: posX, + y: posY, + }); + appState.tags.reloadContextMenuLayout(); + appState.tags.setContextMenuOpen(true); + }; + + const onContextMenu = (tag: SNTag, posX: number, posY: number) => { + appState.tags.selected = tag; + openTagContextMenu(posX, posY); + }; + return ( {allTags.length === 0 ? ( @@ -34,6 +49,7 @@ export const TagsList: FunctionComponent = observer(({ appState }) => { tag={tag} tagsState={tagsState} features={appState.features} + onContextMenu={onContextMenu} /> ); })} diff --git a/app/assets/javascripts/components/Tags/TagsListItem.tsx b/app/assets/javascripts/components/Tags/TagsListItem.tsx index 7c10a3d56..5e3cf1018 100644 --- a/app/assets/javascripts/components/Tags/TagsListItem.tsx +++ b/app/assets/javascripts/components/Tags/TagsListItem.tsx @@ -1,5 +1,6 @@ import { Icon } from '@/components/Icon'; import { usePremiumModal } from '@/components/Premium'; +import { KeyboardKey } from '@/services/ioService'; import { FeaturesState, TAG_FOLDERS_FEATURE_NAME, @@ -19,18 +20,23 @@ type Props = { tagsState: TagsState; features: FeaturesState; level: number; + onContextMenu: (tag: SNTag, posX: number, posY: number) => void; }; const PADDING_BASE_PX = 14; const PADDING_PER_LEVEL_PX = 21; export const TagsListItem: FunctionComponent = observer( - ({ tag, features, tagsState, level }) => { + ({ tag, features, tagsState, level, onContextMenu }) => { const [title, setTitle] = useState(tag.title || ''); + const [subtagTitle, setSubtagTitle] = useState(''); const inputRef = useRef(null); + const subtagInputRef = useRef(null); + const menuButtonRef = useRef(null); const isSelected = tagsState.selected === tag; const isEditing = tagsState.editingTag === tag; + const isAddingSubtag = tagsState.addingSubtagTo === tag; const noteCounts = computed(() => tagsState.getNotesCount(tag)); const childrenTags = computed(() => tagsState.getChildren(tag)).get(); @@ -83,9 +89,9 @@ export const TagsListItem: FunctionComponent = observer( [setTitle] ); - const onKeyUp = useCallback( + const onKeyDown = useCallback( (e: KeyboardEvent) => { - if (e.code === 'Enter') { + if (e.key === KeyboardKey.Enter) { inputRef.current?.blur(); e.preventDefault(); } @@ -99,17 +105,34 @@ export const TagsListItem: FunctionComponent = observer( } }, [inputRef, isEditing]); - const onClickRename = useCallback(() => { - tagsState.editingTag = tag; - }, [tagsState, tag]); + const onSubtagInput = useCallback( + (e: JSX.TargetedEvent) => { + const value = (e.target as HTMLInputElement).value; + setSubtagTitle(value); + }, + [] + ); - const onClickSave = useCallback(() => { - inputRef.current?.blur(); - }, [inputRef]); + const onSubtagInputBlur = useCallback(() => { + tagsState.createSubtagAndAssignParent(tag, subtagTitle); + setSubtagTitle(''); + }, [subtagTitle, tag, tagsState]); - const onClickDelete = useCallback(() => { - tagsState.remove(tag); - }, [tagsState, tag]); + const onSubtagKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === KeyboardKey.Enter) { + e.preventDefault(); + subtagInputRef.current?.blur(); + } + }, + [subtagInputRef] + ); + + useEffect(() => { + if (isAddingSubtag) { + subtagInputRef.current?.focus(); + } + }, [subtagInputRef, isAddingSubtag]); const [, dragRef] = useDrag( () => ({ @@ -148,10 +171,25 @@ export const TagsListItem: FunctionComponent = observer( const readyToDrop = isOver && canDrop; + const toggleContextMenu = () => { + if (!menuButtonRef.current) { + return; + } + + const contextMenuOpen = tagsState.contextMenuOpen; + const menuButtonRect = menuButtonRef.current?.getBoundingClientRect(); + + if (contextMenuOpen) { + tagsState.setContextMenuOpen(false); + } else { + onContextMenu(tag, menuButtonRect.right, menuButtonRect.top); + } + }; + return ( <> -
= observer( style={{ paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`, }} + onContextMenu={(e: MouseEvent) => { + e.preventDefault(); + onContextMenu(tag, e.clientX, e.clientY); + }} > {!tag.errorDecrypting ? (
{hasAtLeastOneFolder && ( -
- +
+
)} -
+
= observer( -
{noteCounts.get()}
+
+ +
{noteCounts.get()}
+
) : null}
@@ -206,25 +266,34 @@ export const TagsListItem: FunctionComponent = observer( {tag.errorDecrypting && tag.waitingForKey && (
Waiting For Keys
)} - {isSelected && ( -
- {!isEditing && ( - - Rename - - )} - {isEditing && ( - - Save - - )} - - Delete - -
- )}
-
+ + {isAddingSubtag && ( +
+
+
+
+ +
+ +
+
+ )} {showChildren && ( <> {childrenTags.map((tag) => { @@ -235,6 +304,7 @@ export const TagsListItem: FunctionComponent = observer( tag={tag} tagsState={tagsState} features={features} + onContextMenu={onContextMenu} /> ); })} diff --git a/app/assets/javascripts/strings.ts b/app/assets/javascripts/strings.ts index 45c7362cd..36e810f7d 100644 --- a/app/assets/javascripts/strings.ts +++ b/app/assets/javascripts/strings.ts @@ -20,7 +20,7 @@ export const STRING_NEW_UPDATE_READY = /** @tags */ export const STRING_DELETE_TAG = - 'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.'; + 'Are you sure you want to delete this tag? Deleting a tag will not delete its subtags or its notes.'; export const STRING_MISSING_SYSTEM_TAG = 'We are missing a System Tag.'; 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 4825f180a..4ba4c12b6 100644 --- a/app/assets/javascripts/ui_models/app_state/tags_state.ts +++ b/app/assets/javascripts/ui_models/app_state/tags_state.ts @@ -1,5 +1,9 @@ import { confirmDialog } from '@/services/alertService'; import { STRING_DELETE_TAG } from '@/strings'; +import { + MAX_MENU_SIZE_MULTIPLIER, + MENU_MARGIN_FROM_APP_BORDER, +} from '@/constants'; import { ComponentAction, ContentType, @@ -72,6 +76,15 @@ export class TagsState { selected_: AnyTag | undefined; previouslySelected_: AnyTag | undefined; editing_: SNTag | undefined; + addingSubtagTo: SNTag | undefined; + + contextMenuOpen = false; + contextMenuPosition: { top?: number; left: number; bottom?: number } = { + top: 0, + left: 0, + }; + contextMenuClickLocation: { x: number; y: number } = { x: 0, y: 0 }; + contextMenuMaxHeight: number | 'auto' = 'auto'; private readonly tagsCountsState: TagsCountsState; @@ -85,6 +98,7 @@ export class TagsState { this.selected_ = undefined; this.previouslySelected_ = undefined; this.editing_ = undefined; + this.addingSubtagTo = undefined; this.smartTags = this.application.getSmartTags(); this.selected_ = this.smartTags[0]; @@ -105,6 +119,9 @@ export class TagsState { selectedUuid: computed, editingTag: computed, + addingSubtagTo: observable, + setAddingSubtagTo: action, + assignParent: action, rootTags: computed, @@ -114,6 +131,15 @@ export class TagsState { undoCreateNewTag: action, save: action, remove: action, + + contextMenuOpen: observable, + contextMenuPosition: observable, + contextMenuMaxHeight: observable, + contextMenuClickLocation: observable, + setContextMenuOpen: action, + setContextMenuClickLocation: action, + setContextMenuPosition: action, + setContextMenuMaxHeight: action, }); appEventListeners.push( @@ -159,6 +185,114 @@ export class TagsState { ); } + async createSubtagAndAssignParent(parent: SNTag, title: string) { + const hasEmptyTitle = title.length === 0; + + if (hasEmptyTitle) { + this.setAddingSubtagTo(undefined); + return; + } + + const createdTag = await this.application.createTagOrSmartTag(title); + + const futureSiblings = this.application.getTagChildren(parent); + + if (!isValidFutureSiblings(this.application, futureSiblings, createdTag)) { + this.setAddingSubtagTo(undefined); + this.remove(createdTag, false); + return; + } + + this.assignParent(createdTag.uuid, parent.uuid); + + this.application.sync(); + + runInAction(() => { + this.selected = createdTag as SNTag; + }); + + this.setAddingSubtagTo(undefined); + } + + setAddingSubtagTo(tag: SNTag | undefined): void { + this.addingSubtagTo = tag; + } + + setContextMenuOpen(open: boolean): void { + this.contextMenuOpen = open; + } + + setContextMenuClickLocation(location: { x: number; y: number }): void { + this.contextMenuClickLocation = location; + } + + setContextMenuPosition(position: { + top?: number; + left: number; + bottom?: number; + }): void { + this.contextMenuPosition = position; + } + + setContextMenuMaxHeight(maxHeight: number | 'auto'): void { + this.contextMenuMaxHeight = maxHeight; + } + + reloadContextMenuLayout(): void { + const { clientHeight } = document.documentElement; + const defaultFontSize = window.getComputedStyle( + document.documentElement + ).fontSize; + const maxContextMenuHeight = + parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER; + const footerElementRect = document + .getElementById('footer-bar') + ?.getBoundingClientRect(); + const footerHeightInPx = footerElementRect?.height; + + // Open up-bottom is default behavior + let openUpBottom = true; + + if (footerHeightInPx) { + const bottomSpace = + clientHeight - footerHeightInPx - this.contextMenuClickLocation.y; + const upSpace = this.contextMenuClickLocation.y; + + // If not enough space to open up-bottom + if (maxContextMenuHeight > bottomSpace) { + // If there's enough space, open bottom-up + if (upSpace > maxContextMenuHeight) { + openUpBottom = false; + this.setContextMenuMaxHeight('auto'); + // Else, reduce max height (menu will be scrollable) and open in whichever direction there's more space + } else { + if (upSpace > bottomSpace) { + this.setContextMenuMaxHeight(upSpace - MENU_MARGIN_FROM_APP_BORDER); + openUpBottom = false; + } else { + this.setContextMenuMaxHeight( + bottomSpace - MENU_MARGIN_FROM_APP_BORDER + ); + } + } + } else { + this.setContextMenuMaxHeight('auto'); + } + } + + if (openUpBottom) { + this.setContextMenuPosition({ + top: this.contextMenuClickLocation.y, + left: this.contextMenuClickLocation.x, + }); + } else { + this.setContextMenuPosition({ + bottom: clientHeight - this.contextMenuClickLocation.y, + left: this.contextMenuClickLocation.x, + }); + } + } + public get allLocalRootTags(): SNTag[] { if (this.editing_ && this.application.isTemplateItem(this.editing_)) { return [this.editing_, ...this.rootTags]; @@ -270,9 +404,9 @@ export class TagsState { this.selected_ = tag; } - public setExpanded(tag: SNTag, exapnded: boolean) { + public setExpanded(tag: SNTag, expanded: boolean) { this.application.changeAndSaveItem(tag.uuid, (mutator) => { - mutator.expanded = exapnded; + mutator.expanded = expanded; }); } @@ -312,13 +446,15 @@ export class TagsState { this.selected = previousTag; } - public async remove(tag: SNTag) { - if ( - await confirmDialog({ + public async remove(tag: SNTag, userTriggered: boolean) { + let shouldDelete = !userTriggered; + if (userTriggered) { + shouldDelete = await confirmDialog({ text: STRING_DELETE_TAG, confirmButtonStyle: 'danger', - }) - ) { + }); + } + if (shouldDelete) { this.application.deleteItem(tag); this.selected = this.smartTags[0]; } diff --git a/app/assets/stylesheets/_navigation.scss b/app/assets/stylesheets/_navigation.scss index e2b7c9437..2b5f461ea 100644 --- a/app/assets/stylesheets/_navigation.scss +++ b/app/assets/stylesheets/_navigation.scss @@ -61,6 +61,15 @@ $content-horizontal-padding: 16px; } } + .tag { + border: 0; + background-color: transparent; + + &:focus { + background-color: var(--sn-stylekit-secondary-contrast-background-color); + } + } + .tag, .root-drop { font-size: 14px; @@ -81,17 +90,17 @@ $content-horizontal-padding: 16px; .sn-icon { display: block; margin: 0 auto; - - &.hidden { - visibility: hidden; - } } - > .tag-fold { - width: 22px; + .tag-fold { + min-width: 22px; display: flex; align-items: center; height: 100%; + + @extend .border-0; + @extend .bg-transparent; + @extend .p-0; } > .tag-icon { @@ -147,12 +156,12 @@ $content-horizontal-padding: 16px; } } - > .count { + .count { padding-right: 4px; padding-top: 1px; font-weight: bold; color: var(--sn-stylekit-neutral-color); - min-width: 35px; + min-width: 15px; text-align: right; } } diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss index 09514d55f..10b7171b4 100644 --- a/app/assets/stylesheets/_sn.scss +++ b/app/assets/stylesheets/_sn.scss @@ -958,3 +958,11 @@ .resize-none { resize: none; } + +.visible { + visibility: visible; +} + +.invisible { + visibility: hidden; +}