feat: add 'add tags' option to menu
This commit is contained in:
3
app/assets/icons/ic-chevron-right.svg
Normal file
3
app/assets/icons/ic-chevron-right.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6.90918 14.0667L10.7342 10.2417L6.90918 6.4167L8.09251 5.2417L13.0925 10.2417L8.09251 15.2417L6.90918 14.0667Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 228 B |
3
app/assets/icons/ic-hashtag.svg
Normal file
3
app/assets/icons/ic-hashtag.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill-rule="evenodd" clip-rule="evenodd" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8.6 3.5H7V6.9L3.5 6.9V8.5H7V11.4H3.5V13H7V16.5H8.6V13L11.5 13V16.5H13.1V13H16.5V11.4H13.1V8.5H16.5V6.9L13.1 6.9V3.5H11.5V6.9L8.6 6.9V3.5ZM8.6 8.5V11.4L11.5 11.4V8.5L8.6 8.5Z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 331 B |
@@ -5,6 +5,8 @@ import PinIcon from '../../icons/ic-pin.svg';
|
|||||||
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
import UnpinIcon from '../../icons/ic-pin-off.svg';
|
||||||
import ArchiveIcon from '../../icons/ic-archive.svg';
|
import ArchiveIcon from '../../icons/ic-archive.svg';
|
||||||
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
import UnarchiveIcon from '../../icons/ic-unarchive.svg';
|
||||||
|
import HashtagIcon from '../../icons/ic-hashtag.svg';
|
||||||
|
import ChevronRightIcon from '../../icons/ic-chevron-right.svg';
|
||||||
import { toDirective } from './utils';
|
import { toDirective } from './utils';
|
||||||
|
|
||||||
export enum IconType {
|
export enum IconType {
|
||||||
@@ -14,7 +16,9 @@ export enum IconType {
|
|||||||
Pin = 'pin',
|
Pin = 'pin',
|
||||||
Unpin = 'unpin',
|
Unpin = 'unpin',
|
||||||
Archive = 'archive',
|
Archive = 'archive',
|
||||||
Unarchive = 'unarchive'
|
Unarchive = 'unarchive',
|
||||||
|
Hashtag = 'hashtag',
|
||||||
|
ChevronRight = 'chevron-right',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -24,7 +28,9 @@ const ICONS = {
|
|||||||
[IconType.Pin]: PinIcon,
|
[IconType.Pin]: PinIcon,
|
||||||
[IconType.Unpin]: UnpinIcon,
|
[IconType.Unpin]: UnpinIcon,
|
||||||
[IconType.Archive]: ArchiveIcon,
|
[IconType.Archive]: ArchiveIcon,
|
||||||
[IconType.Unarchive]: UnarchiveIcon
|
[IconType.Unarchive]: UnarchiveIcon,
|
||||||
|
[IconType.Hashtag]: HashtagIcon,
|
||||||
|
[IconType.ChevronRight]: ChevronRightIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { AppState } from '@/ui_models/app_state';
|
|||||||
import { Icon, IconType } from './Icon';
|
import { Icon, IconType } from './Icon';
|
||||||
import { Switch } from './Switch';
|
import { Switch } from './Switch';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useRef } from 'preact/hooks';
|
import { useRef, useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
Disclosure,
|
||||||
|
DisclosureButton,
|
||||||
|
DisclosurePanel,
|
||||||
|
} from '@reach/disclosure';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
appState: AppState;
|
appState: AppState;
|
||||||
@@ -12,6 +17,12 @@ type Props = {
|
|||||||
|
|
||||||
export const NotesOptions = observer(
|
export const NotesOptions = observer(
|
||||||
({ appState, closeOnBlur, setLockCloseOnBlur }: Props) => {
|
({ appState, closeOnBlur, setLockCloseOnBlur }: Props) => {
|
||||||
|
const [tagsMenuOpen, setTagsMenuOpen] = useState(false);
|
||||||
|
const [tagsMenuPosition, setTagsMenuPosition] = useState({
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const notes = Object.values(appState.notes.selectedNotes);
|
const notes = Object.values(appState.notes.selectedNotes);
|
||||||
const hidePreviews = !notes.some((note) => !note.hidePreview);
|
const hidePreviews = !notes.some((note) => !note.hidePreview);
|
||||||
const locked = !notes.some((note) => !note.locked);
|
const locked = !notes.some((note) => !note.locked);
|
||||||
@@ -20,6 +31,7 @@ export const NotesOptions = observer(
|
|||||||
const pinned = !notes.some((note) => !note.pinned);
|
const pinned = !notes.some((note) => !note.pinned);
|
||||||
|
|
||||||
const trashButtonRef = useRef<HTMLButtonElement>();
|
const trashButtonRef = useRef<HTMLButtonElement>();
|
||||||
|
const tagsButtonRef = useRef<HTMLButtonElement>();
|
||||||
|
|
||||||
const iconClass = 'fill-current color-neutral mr-2';
|
const iconClass = 'fill-current color-neutral mr-2';
|
||||||
const buttonClass =
|
const buttonClass =
|
||||||
@@ -56,6 +68,60 @@ export const NotesOptions = observer(
|
|||||||
</span>
|
</span>
|
||||||
</Switch>
|
</Switch>
|
||||||
<div className="h-1px my-2 bg-secondary-contrast"></div>
|
<div className="h-1px my-2 bg-secondary-contrast"></div>
|
||||||
|
<Disclosure
|
||||||
|
open={tagsMenuOpen}
|
||||||
|
onChange={() => {
|
||||||
|
const buttonRect = tagsButtonRef.current.getBoundingClientRect();
|
||||||
|
const { offsetTop, offsetWidth } = tagsButtonRef.current;
|
||||||
|
setTagsMenuPosition({
|
||||||
|
top: offsetTop,
|
||||||
|
right: ((buttonRect.right + 265) > document.body.clientWidth) ? offsetWidth : -offsetWidth,
|
||||||
|
});
|
||||||
|
setTagsMenuOpen(!tagsMenuOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DisclosureButton
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setTagsMenuOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
ref={tagsButtonRef}
|
||||||
|
className={`${buttonClass} justify-between`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Icon type={IconType.Hashtag} className={iconClass} />
|
||||||
|
{"Add tag"}
|
||||||
|
</div>
|
||||||
|
<Icon type={IconType.ChevronRight} className="fill-current color-neutral" />
|
||||||
|
</DisclosureButton>
|
||||||
|
<DisclosurePanel
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setTagsMenuOpen(false);
|
||||||
|
tagsButtonRef.current.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
...tagsMenuPosition
|
||||||
|
}}
|
||||||
|
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 max-w-265"
|
||||||
|
>
|
||||||
|
{appState.tags.tags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag.title}
|
||||||
|
className={buttonClass}
|
||||||
|
onBlur={closeOnBlur}
|
||||||
|
onClick={() => {
|
||||||
|
appState.tags.addTagToSelectedNotes(tag);
|
||||||
|
}
|
||||||
|
}>
|
||||||
|
{tag.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</Disclosure>
|
||||||
<button
|
<button
|
||||||
onBlur={closeOnBlur}
|
onBlur={closeOnBlur}
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const NotesOptionsPanel = observer(({ appState }: Props) => {
|
|||||||
style={{
|
style={{
|
||||||
...position,
|
...position,
|
||||||
}}
|
}}
|
||||||
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2"
|
className="sn-dropdown sn-dropdown-anchor-right flex flex-col py-2 max-w-265"
|
||||||
>
|
>
|
||||||
<NotesOptions
|
<NotesOptions
|
||||||
appState={appState}
|
appState={appState}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { NoAccountWarningState } from './no_account_warning_state';
|
|||||||
import { SyncState } from './sync_state';
|
import { SyncState } from './sync_state';
|
||||||
import { SearchOptionsState } from './search_options_state';
|
import { SearchOptionsState } from './search_options_state';
|
||||||
import { NotesState } from './notes_state';
|
import { NotesState } from './notes_state';
|
||||||
|
import { TagsState } from './tags_state';
|
||||||
|
|
||||||
export enum AppStateEvent {
|
export enum AppStateEvent {
|
||||||
TagChanged,
|
TagChanged,
|
||||||
@@ -65,6 +66,7 @@ export class AppState {
|
|||||||
readonly sync = new SyncState();
|
readonly sync = new SyncState();
|
||||||
readonly searchOptions: SearchOptionsState;
|
readonly searchOptions: SearchOptionsState;
|
||||||
readonly notes: NotesState;
|
readonly notes: NotesState;
|
||||||
|
readonly tags: TagsState;
|
||||||
isSessionsModalVisible = false;
|
isSessionsModalVisible = false;
|
||||||
|
|
||||||
private appEventObserverRemovers: (() => void)[] = [];
|
private appEventObserverRemovers: (() => void)[] = [];
|
||||||
@@ -86,6 +88,10 @@ export class AppState {
|
|||||||
},
|
},
|
||||||
this.appEventObserverRemovers,
|
this.appEventObserverRemovers,
|
||||||
);
|
);
|
||||||
|
this.tags = new TagsState(
|
||||||
|
application,
|
||||||
|
this.appEventObserverRemovers,
|
||||||
|
),
|
||||||
this.noAccountWarning = new NoAccountWarningState(
|
this.noAccountWarning = new NoAccountWarningState(
|
||||||
application,
|
application,
|
||||||
this.appEventObserverRemovers
|
this.appEventObserverRemovers
|
||||||
|
|||||||
49
app/assets/javascripts/ui_models/app_state/tags_state.ts
Normal file
49
app/assets/javascripts/ui_models/app_state/tags_state.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
ContentType,
|
||||||
|
SNSmartTag,
|
||||||
|
SNTag,
|
||||||
|
} from '@standardnotes/snjs';
|
||||||
|
import {
|
||||||
|
action,
|
||||||
|
makeObservable,
|
||||||
|
observable,
|
||||||
|
} from 'mobx';
|
||||||
|
import { WebApplication } from '../application';
|
||||||
|
|
||||||
|
export class TagsState {
|
||||||
|
tags: SNTag[] = [];
|
||||||
|
smartTags: SNSmartTag[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private application: WebApplication,
|
||||||
|
appEventListeners: (() => void)[]
|
||||||
|
) {
|
||||||
|
makeObservable(this, {
|
||||||
|
tags: observable,
|
||||||
|
smartTags: observable,
|
||||||
|
addTagToSelectedNotes: action,
|
||||||
|
});
|
||||||
|
|
||||||
|
appEventListeners.push(
|
||||||
|
this.application.streamItems(
|
||||||
|
[ContentType.Tag, ContentType.SmartTag],
|
||||||
|
async () => {
|
||||||
|
this.tags = this.application.getDisplayableItems(ContentType.Tag) as SNTag[];
|
||||||
|
this.smartTags = this.application.getSmartTags();
|
||||||
|
}
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTagToSelectedNotes(tag: SNTag): Promise<void> {
|
||||||
|
const selectedNotes = Object.values(this.application.getAppState().notes.selectedNotes);
|
||||||
|
await Promise.all(
|
||||||
|
selectedNotes.map(async note =>
|
||||||
|
await this.application.changeItem(tag.uuid, (mutator) => {
|
||||||
|
mutator.addItemAsRelationship(note);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.application.sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user