feat: add tag context menu (#890)

This commit is contained in:
Aman Harwara
2022-02-22 13:05:01 +05:30
committed by GitHub
parent a5da191034
commit 22718d8a9f
10 changed files with 411 additions and 62 deletions

View File

@@ -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<Props, State> {
appState={this.appState}
/>
<TagsContextMenu appState={this.appState} />
<PurchaseFlowWrapper
application={this.application}
appState={this.appState}

View File

@@ -44,6 +44,8 @@ import {
NotesIcon,
PasswordIcon,
PencilOffIcon,
PencilFilledIcon,
PencilIcon,
PinFilledIcon,
PinIcon,
PlainTextIcon,
@@ -91,6 +93,7 @@ const ICONS = {
'menu-arrow-right': MenuArrowRight,
'menu-close': MenuCloseIcon,
'pencil-off': PencilOffIcon,
'pencil-filled': PencilFilledIcon,
'pin-filled': PinFilledIcon,
'plain-text': PlainTextIcon,
'premium-feature': PremiumFeatureIcon,
@@ -122,6 +125,7 @@ const ICONS = {
more: MoreIcon,
notes: NotesIcon,
password: PasswordIcon,
pencil: PencilIcon,
pin: PinIcon,
restore: RestoreIcon,
security: SecurityIcon,

View File

@@ -84,7 +84,7 @@ export const SmartTagsListItem: FunctionComponent<Props> = 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<Props> = observer(
</div>
<input
className={`title ${isEditing ? 'editing' : ''}`}
disabled={!isEditing}
id={`react-tag-${tag.uuid}`}
onBlur={onBlur}
onInput={onInput}

View File

@@ -0,0 +1,102 @@
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useCallback, useEffect, useRef } from 'preact/hooks';
import { Icon } from '../Icon';
import { Menu } from '../menu/Menu';
import { MenuItem, MenuItemType } from '../menu/MenuItem';
import { useCloseOnBlur } from '../utils';
type Props = {
appState: AppState;
};
export const TagsContextMenu: FunctionComponent<Props> = observer(
({ appState }) => {
const selectedTag = appState.tags.selected;
if (!selectedTag) {
return null;
}
const { contextMenuOpen, contextMenuPosition, contextMenuMaxHeight } =
appState.tags;
const contextMenuRef = useRef<HTMLDivElement>(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 ? (
<div
ref={contextMenuRef}
className="sn-dropdown min-w-60 max-h-120 max-w-xs flex flex-col py-2 overflow-y-auto fixed"
style={{
...contextMenuPosition,
maxHeight: contextMenuMaxHeight,
}}
>
<Menu
a11yLabel="Tag context menu"
isOpen={contextMenuOpen}
closeMenu={() => {
appState.tags.setContextMenuOpen(false);
}}
>
<MenuItem
type={MenuItemType.IconButton}
onBlur={closeOnBlur}
className={`py-1.5`}
onClick={onClickAddSubtag}
>
<Icon type="add" className="color-neutral mr-2" />
Add subtag
</MenuItem>
<MenuItem
type={MenuItemType.IconButton}
onBlur={closeOnBlur}
className={`py-1.5`}
onClick={onClickRename}
>
<Icon type="pencil-filled" className="color-neutral mr-2" />
Rename
</MenuItem>
<MenuItem
type={MenuItemType.IconButton}
onBlur={closeOnBlur}
className={`py-1.5`}
onClick={onClickDelete}
>
<Icon type="trash" className="mr-2 color-danger" />
<span className="color-danger">Delete</span>
</MenuItem>
</Menu>
</div>
) : null;
}
);

View File

@@ -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<Props> = 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 (
<DndProvider backend={backend}>
{allTags.length === 0 ? (
@@ -34,6 +49,7 @@ export const TagsList: FunctionComponent<Props> = observer(({ appState }) => {
tag={tag}
tagsState={tagsState}
features={appState.features}
onContextMenu={onContextMenu}
/>
);
})}

View File

@@ -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<Props> = observer(
({ tag, features, tagsState, level }) => {
({ tag, features, tagsState, level, onContextMenu }) => {
const [title, setTitle] = useState(tag.title || '');
const [subtagTitle, setSubtagTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const subtagInputRef = useRef<HTMLInputElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(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<Props> = 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<Props> = observer(
}
}, [inputRef, isEditing]);
const onClickRename = useCallback(() => {
tagsState.editingTag = tag;
}, [tagsState, tag]);
const onSubtagInput = useCallback(
(e: JSX.TargetedEvent<HTMLInputElement>) => {
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<Props> = 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 (
<>
<div
className={`tag ${isSelected ? 'selected' : ''} ${
<button
className={`tag focus:shadow-inner ${isSelected ? 'selected' : ''} ${
readyToDrop ? 'is-drag-over' : ''
}`}
onClick={selectCurrentTag}
@@ -159,23 +197,33 @@ export const TagsListItem: FunctionComponent<Props> = 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 ? (
<div className="tag-info" title={title} ref={dropRef}>
{hasAtLeastOneFolder && (
<div
className={`tag-fold ${showChildren ? 'opened' : 'closed'}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon
className={`color-neutral ${!hasChildren ? 'hidden' : ''}`}
type={
showChildren ? 'menu-arrow-down-alt' : 'menu-arrow-right'
}
/>
<div className="tag-fold-container">
<button
className={`tag-fold focus:shadow-inner ${
showChildren ? 'opened' : 'closed'
} ${!hasChildren ? 'invisible' : ''}`}
onClick={hasChildren ? toggleChildren : undefined}
>
<Icon
className={`color-neutral`}
type={
showChildren
? 'menu-arrow-down-alt'
: 'menu-arrow-right'
}
/>
</button>
</div>
)}
<div className={`tag-icon ${'draggable'} mr-1`} ref={dragRef}>
<div className={`tag-icon draggable mr-1`} ref={dragRef}>
<Icon
type="hashtag"
className={`${isSelected ? 'color-info' : 'color-neutral'}`}
@@ -184,14 +232,26 @@ export const TagsListItem: FunctionComponent<Props> = observer(
<input
className={`title ${isEditing ? 'editing' : ''}`}
id={`react-tag-${tag.uuid}`}
disabled={!isEditing}
onBlur={onBlur}
onInput={onInput}
value={title}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
spellCheck={false}
ref={inputRef}
/>
<div className="count">{noteCounts.get()}</div>
<div className="flex items-center">
<button
className={`border-0 mr-2 bg-transparent hover:bg-contrast focus:shadow-inner cursor-pointer ${
isSelected ? 'visible' : 'invisible'
}`}
onClick={toggleContextMenu}
ref={menuButtonRef}
>
<Icon type="more" className="color-neutral" />
</button>
<div className="count">{noteCounts.get()}</div>
</div>
</div>
) : null}
<div className={`meta ${hasAtLeastOneFolder ? 'with-folders' : ''}`}>
@@ -206,25 +266,34 @@ export const TagsListItem: FunctionComponent<Props> = observer(
{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>
</button>
{isAddingSubtag && (
<div
className="tag overflow-hidden"
style={{
paddingLeft: `${
(level + 1) * PADDING_PER_LEVEL_PX + PADDING_BASE_PX
}px`,
}}
>
<div className="tag-info">
<div className="tag-fold" />
<div className="tag-icon mr-1">
<Icon type="hashtag" className="color-neutral mr-1" />
</div>
<input
className="title w-full focus:shadow-none"
type="text"
ref={subtagInputRef}
onBlur={onSubtagInputBlur}
onKeyDown={onSubtagKeyDown}
value={subtagTitle}
onInput={onSubtagInput}
/>
</div>
</div>
)}
{showChildren && (
<>
{childrenTags.map((tag) => {
@@ -235,6 +304,7 @@ export const TagsListItem: FunctionComponent<Props> = observer(
tag={tag}
tagsState={tagsState}
features={features}
onContextMenu={onContextMenu}
/>
);
})}

View File

@@ -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.';

View File

@@ -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<TagMutator>(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];
}

View File

@@ -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;
}
}

View File

@@ -958,3 +958,11 @@
.resize-none {
resize: none;
}
.visible {
visibility: visible;
}
.invisible {
visibility: hidden;
}