feat: add tag context menu (#890)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
102
app/assets/javascripts/components/Tags/TagContextMenu.tsx
Normal file
102
app/assets/javascripts/components/Tags/TagContextMenu.tsx
Normal 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;
|
||||
}
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user