import { AppState } from '@/ui_models/app_state'; import { Icon } from '../Icon'; import { Switch } from '../Switch'; import { observer } from 'mobx-react-lite'; import { useRef, useState, useEffect, useMemo } from 'preact/hooks'; import { Disclosure, DisclosureButton, DisclosurePanel, } from '@reach/disclosure'; import { SNApplication, SNNote } from '@standardnotes/snjs'; import { WebApplication } from '@/ui_models/application'; import { KeyboardModifier } from '@/services/ioService'; import { FunctionComponent } from 'preact'; import { ChangeEditorOption } from './ChangeEditorOption'; import { MENU_MARGIN_FROM_APP_BORDER, MAX_MENU_SIZE_MULTIPLIER, BYTES_IN_ONE_MEGABYTE, } from '@/constants'; import { ListedActionsOption } from './ListedActionsOption'; export type NotesOptionsProps = { application: WebApplication; appState: AppState; closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void; onSubmenuChange?: (submenuOpen: boolean) => void; }; type DeletePermanentlyButtonProps = { closeOnBlur: NotesOptionsProps['closeOnBlur']; onClick: () => void; }; const DeletePermanentlyButton = ({ closeOnBlur, onClick, }: DeletePermanentlyButtonProps) => ( ); const iconClass = 'color-neutral mr-2'; const getWordCount = (text: string) => { if (text.trim().length === 0) { return 0; } return text.split(/\s+/).length; }; const getParagraphCount = (text: string) => { if (text.trim().length === 0) { return 0; } return text.replace(/\n$/gm, '').split(/\n/).length; }; const countNoteAttributes = (text: string) => { try { JSON.parse(text); return { characters: 'N/A', words: 'N/A', paragraphs: 'N/A', }; } catch { const characters = text.length; const words = getWordCount(text); const paragraphs = getParagraphCount(text); return { characters, words, paragraphs, }; } }; const calculateReadTime = (words: number) => { const timeToRead = Math.round(words / 200); if (timeToRead === 0) { return '< 1 minute'; } else { return `${timeToRead} ${timeToRead > 1 ? 'minutes' : 'minute'}`; } }; const formatDate = (date: Date | undefined) => { if (!date) return; return `${date.toDateString()} ${date.toLocaleTimeString()}`; }; const NoteAttributes: FunctionComponent<{ application: SNApplication; note: SNNote; }> = ({ application, note }) => { const { words, characters, paragraphs } = useMemo( () => countNoteAttributes(note.text), [note.text] ); const readTime = useMemo( () => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words] ); const dateLastModified = useMemo( () => formatDate(note.userModifiedDate), [note.userModifiedDate] ); const dateCreated = useMemo( () => formatDate(note.created_at), [note.created_at] ); const editor = application.componentManager.editorForNote(note); const format = editor?.package_info?.file_type || 'txt'; return (
{typeof words === 'number' && (format === 'txt' || format === 'md') ? ( <>
{words} words · {characters} characters · {paragraphs} paragraphs
Read time: {readTime}
) : null}
Last modified: {dateLastModified}
Created: {dateCreated}
Note ID: {note.uuid}
); }; const SpellcheckOptions: FunctionComponent<{ appState: AppState; note: SNNote; }> = ({ appState, note }) => { const editor = appState.application.componentManager.editorForNote(note); const spellcheckControllable = Boolean( !editor || editor.package_info.spellcheckControl ); const noteSpellcheck = !spellcheckControllable ? true : note ? appState.notes.getSpellcheckStateForNote(note) : undefined; return (
{!spellcheckControllable && (

Spellcheck cannot be controlled for this editor.

)}
); }; const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE; const NoteSizeWarning: FunctionComponent<{ note: SNNote; }> = ({ note }) => new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
This note may have trouble syncing to the mobile application due to its size.
) : null; export const NotesOptions = observer( ({ application, appState, closeOnBlur, onSubmenuChange, }: NotesOptionsProps) => { const [tagsMenuOpen, setTagsMenuOpen] = useState(false); const [tagsMenuPosition, setTagsMenuPosition] = useState<{ top: number; right?: number; left?: number; }>({ top: 0, right: 0, }); const [tagsMenuMaxHeight, setTagsMenuMaxHeight] = useState( 'auto' ); const [altKeyDown, setAltKeyDown] = useState(false); const toggleOn = (condition: (note: SNNote) => boolean) => { const notesMatchingAttribute = notes.filter(condition); const notesNotMatchingAttribute = notes.filter( (note) => !condition(note) ); return notesMatchingAttribute.length > notesNotMatchingAttribute.length; }; const notes = Object.values(appState.notes.selectedNotes); const hidePreviews = toggleOn((note) => note.hidePreview); const locked = toggleOn((note) => note.locked); const protect = toggleOn((note) => note.protected); const archived = notes.some((note) => note.archived); const unarchived = notes.some((note) => !note.archived); const trashed = notes.some((note) => note.trashed); const notTrashed = notes.some((note) => !note.trashed); const pinned = notes.some((note) => note.pinned); const unpinned = notes.some((note) => !note.pinned); const errored = notes.some((note) => note.errorDecrypting); const tagsButtonRef = useRef(null); useEffect(() => { if (onSubmenuChange) { onSubmenuChange(tagsMenuOpen); } }, [tagsMenuOpen, onSubmenuChange]); useEffect(() => { const removeAltKeyObserver = application.io.addKeyObserver({ modifiers: [KeyboardModifier.Alt], onKeyDown: () => { setAltKeyDown(true); }, onKeyUp: () => { setAltKeyDown(false); }, }); return () => { removeAltKeyObserver(); }; }, [application]); const openTagsMenu = () => { const defaultFontSize = window.getComputedStyle( document.documentElement ).fontSize; const maxTagsMenuSize = parseFloat(defaultFontSize) * MAX_MENU_SIZE_MULTIPLIER; const { clientWidth, clientHeight } = document.documentElement; const buttonRect = tagsButtonRef.current?.getBoundingClientRect(); const footerElementRect = document .getElementById('footer-bar') ?.getBoundingClientRect(); const footerHeightInPx = footerElementRect?.height; if (buttonRect && footerHeightInPx) { if ( buttonRect.top + maxTagsMenuSize > clientHeight - footerHeightInPx ) { setTagsMenuMaxHeight( clientHeight - buttonRect.top - footerHeightInPx - MENU_MARGIN_FROM_APP_BORDER ); } if (buttonRect.right + maxTagsMenuSize > clientWidth) { setTagsMenuPosition({ top: buttonRect.top, right: clientWidth - buttonRect.left, }); } else { setTagsMenuPosition({ top: buttonRect.top, left: buttonRect.right, }); } } setTagsMenuOpen(!tagsMenuOpen); }; const downloadSelectedItems = () => { notes.forEach((note) => { const editor = application.componentManager.editorForNote(note); const format = editor?.package_info?.file_type || 'txt'; const downloadAnchor = document.createElement('a'); downloadAnchor.setAttribute( 'href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(note.text) ); downloadAnchor.setAttribute('download', `${note.title}.${format}`); downloadAnchor.click(); }); }; const duplicateSelectedItems = () => { notes.forEach((note) => { application.duplicateItem(note); }); }; if (errored) { return ( <> {notes.length === 1 ? (
Note ID: {notes[0].uuid}
) : null} { await appState.notes.deleteNotesPermanently(); }} /> ); } const openRevisionHistoryModal = () => { appState.notes.setShowRevisionHistoryModal(true); }; return ( <> {notes.length === 1 && ( <>
)} {notes.length === 1 && ( <>
)}
{appState.tags.tagsCount > 0 && ( { if (event.key === 'Escape') { setTagsMenuOpen(false); } }} onBlur={closeOnBlur} ref={tagsButtonRef} className="sn-dropdown-item justify-between" >
{'Add tag'}
{ if (event.key === 'Escape') { setTagsMenuOpen(false); tagsButtonRef.current?.focus(); } }} style={{ ...tagsMenuPosition, maxHeight: tagsMenuMaxHeight, position: 'fixed', }} className="sn-dropdown min-w-80 flex flex-col py-2 max-h-120 max-w-xs fixed overflow-y-auto" > {appState.tags.tags.map((tag) => ( ))}
)} {unpinned && ( )} {pinned && ( )} {unarchived && ( )} {archived && ( )} {notTrashed && (altKeyDown ? ( { await appState.notes.deleteNotesPermanently(); }} /> ) : ( ))} {trashed && ( <> { await appState.notes.deleteNotesPermanently(); }} /> )} {notes.length === 1 ? ( <>
) : null} ); } );