feat: Added ability to change the note type of multiple notes at once (#2180)
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { NotesController } from '@/Controllers/NotesController/NotesController'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import RoundIconButton from '../Button/RoundIconButton'
|
||||||
|
import Popover from '../Popover/Popover'
|
||||||
|
import ChangeMultipleMenu from './ChangeMultipleMenu'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
notesController: NotesController
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeMultipleButton = ({ application, notesController }: Props) => {
|
||||||
|
const changeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const [isChangeMenuOpen, setIsChangeMenuOpen] = useState(false)
|
||||||
|
const toggleMenu = () => setIsChangeMenuOpen((open) => !open)
|
||||||
|
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RoundIconButton label={'Change note type'} onClick={toggleMenu} ref={changeButtonRef} icon="plain-text" />
|
||||||
|
<Popover
|
||||||
|
title="Change note type"
|
||||||
|
togglePopover={toggleMenu}
|
||||||
|
disableClickOutside={disableClickOutside}
|
||||||
|
anchorElement={changeButtonRef.current}
|
||||||
|
open={isChangeMenuOpen}
|
||||||
|
className="pt-2 md:pt-0"
|
||||||
|
>
|
||||||
|
<ChangeMultipleMenu
|
||||||
|
application={application}
|
||||||
|
notes={notesController.selectedNotes}
|
||||||
|
setDisableClickOutside={setDisableClickOutside}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangeMultipleButton
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { WebApplication } from '@/Application/Application'
|
||||||
|
import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings'
|
||||||
|
import { usePremiumModal } from '@/Hooks/usePremiumModal'
|
||||||
|
import { createEditorMenuGroups } from '@/Utils/createEditorMenuGroups'
|
||||||
|
import { ComponentArea, NoteMutator, NoteType, SNComponent, SNNote } from '@standardnotes/snjs'
|
||||||
|
import { Fragment, useCallback, useMemo, useState } from 'react'
|
||||||
|
import Icon from '../Icon/Icon'
|
||||||
|
import { PremiumFeatureIconName, PremiumFeatureIconClass } from '../Icon/PremiumFeatureIcon'
|
||||||
|
import Menu from '../Menu/Menu'
|
||||||
|
import MenuItem from '../Menu/MenuItem'
|
||||||
|
import { EditorMenuGroup } from '../NotesOptions/EditorMenuGroup'
|
||||||
|
import { EditorMenuItem } from '../NotesOptions/EditorMenuItem'
|
||||||
|
import { SuperNoteImporter } from '../NoteView/SuperEditor/SuperNoteImporter'
|
||||||
|
import { Pill } from '../Preferences/PreferencesComponents/Content'
|
||||||
|
import ModalOverlay from '../Shared/ModalOverlay'
|
||||||
|
|
||||||
|
const getGroupId = (group: EditorMenuGroup) => group.title.toLowerCase().replace(/\s/, '-')
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
application: WebApplication
|
||||||
|
notes: SNNote[]
|
||||||
|
setDisableClickOutside: (value: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Props) => {
|
||||||
|
const premiumModal = usePremiumModal()
|
||||||
|
|
||||||
|
const [itemToBeSelected, setItemToBeSelected] = useState<EditorMenuItem | undefined>()
|
||||||
|
const [confirmationQueue, setConfirmationQueue] = useState<SNNote[]>([])
|
||||||
|
|
||||||
|
const hasSelectedLockedNotes = useMemo(() => notes.some((note) => note.locked), [notes])
|
||||||
|
|
||||||
|
const editors = useMemo(
|
||||||
|
() =>
|
||||||
|
application.componentManager.componentsForArea(ComponentArea.Editor).sort((a, b) => {
|
||||||
|
return a.displayName.toLowerCase() < b.displayName.toLowerCase() ? -1 : 1
|
||||||
|
}),
|
||||||
|
[application.componentManager],
|
||||||
|
)
|
||||||
|
const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors])
|
||||||
|
|
||||||
|
const selectComponent = useCallback(
|
||||||
|
async (component: SNComponent, note: SNNote) => {
|
||||||
|
if (component.conflictOf) {
|
||||||
|
void application.mutator.changeAndSaveItem(component, (mutator) => {
|
||||||
|
mutator.conflictOf = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await application.mutator.changeAndSaveItem(note, (mutator) => {
|
||||||
|
const noteMutator = mutator as NoteMutator
|
||||||
|
noteMutator.noteType = component.noteType
|
||||||
|
noteMutator.editorIdentifier = component.identifier
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectNonComponent = useCallback(
|
||||||
|
async (item: EditorMenuItem, note: SNNote) => {
|
||||||
|
await application.mutator.changeAndSaveItem(note, (mutator) => {
|
||||||
|
const noteMutator = mutator as NoteMutator
|
||||||
|
noteMutator.noteType = item.noteType
|
||||||
|
noteMutator.editorIdentifier = undefined
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[application],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
async (itemToBeSelected: EditorMenuItem) => {
|
||||||
|
if (!itemToBeSelected.isEntitled) {
|
||||||
|
premiumModal.activate(itemToBeSelected.name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSelectedLockedNotes) {
|
||||||
|
void application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemToBeSelected.noteType === NoteType.Super) {
|
||||||
|
setDisableClickOutside(true)
|
||||||
|
setItemToBeSelected(itemToBeSelected)
|
||||||
|
setConfirmationQueue(notes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemToBeSelected.component) {
|
||||||
|
const changeRequiresConfirmation = notes.some((note) => {
|
||||||
|
const editorForNote = application.componentManager.editorForNote(note)
|
||||||
|
return application.componentManager.doesEditorChangeRequireAlert(editorForNote, itemToBeSelected.component)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (changeRequiresConfirmation) {
|
||||||
|
const canChange = await application.componentManager.showEditorChangeAlert()
|
||||||
|
if (!canChange) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
void selectComponent(itemToBeSelected.component, note)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const note of notes) {
|
||||||
|
void selectNonComponent(itemToBeSelected, note)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
application.alertService,
|
||||||
|
application.componentManager,
|
||||||
|
hasSelectedLockedNotes,
|
||||||
|
notes,
|
||||||
|
premiumModal,
|
||||||
|
selectComponent,
|
||||||
|
selectNonComponent,
|
||||||
|
setDisableClickOutside,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupsWithItems = groups.filter((group) => group.items && group.items.length)
|
||||||
|
|
||||||
|
const showSuperImporter = itemToBeSelected?.noteType === NoteType.Super && confirmationQueue.length > 0
|
||||||
|
|
||||||
|
const closeCurrentSuperNoteImporter = useCallback(() => {
|
||||||
|
const remainingNotes = confirmationQueue.slice(1)
|
||||||
|
|
||||||
|
if (remainingNotes.length === 0) {
|
||||||
|
setItemToBeSelected(undefined)
|
||||||
|
setConfirmationQueue([])
|
||||||
|
setDisableClickOutside(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setConfirmationQueue(remainingNotes)
|
||||||
|
}, [confirmationQueue, setDisableClickOutside])
|
||||||
|
|
||||||
|
const handleSuperNoteConversionCompletion = useCallback(() => {
|
||||||
|
if (!itemToBeSelected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectNonComponent(itemToBeSelected, confirmationQueue[0])
|
||||||
|
|
||||||
|
closeCurrentSuperNoteImporter()
|
||||||
|
}, [closeCurrentSuperNoteImporter, confirmationQueue, itemToBeSelected, selectNonComponent])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu isOpen={true} a11yLabel="Change note type">
|
||||||
|
{groupsWithItems.map((group, index) => (
|
||||||
|
<Fragment key={getGroupId(group)}>
|
||||||
|
<div className={`border-0 border-t border-solid border-border py-1 ${index === 0 ? 'border-t-0' : ''}`}>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const onClickEditorItem = () => {
|
||||||
|
selectItem(item).catch(console.error)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MenuItem key={item.name} onClick={onClickEditorItem} className={'flex-row-reversed py-2'}>
|
||||||
|
<div className="flex flex-grow items-center justify-between">
|
||||||
|
<div className={'flex items-center'}>
|
||||||
|
{group.icon && <Icon type={group.icon} className={`mr-2 ${group.iconClassName}`} />}
|
||||||
|
{item.name}
|
||||||
|
{item.isLabs && (
|
||||||
|
<Pill className="py-0.5 px-1.5" style="success">
|
||||||
|
Labs
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!item.isEntitled && <Icon type={PremiumFeatureIconName} className={PremiumFeatureIconClass} />}
|
||||||
|
</div>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
<ModalOverlay isOpen={showSuperImporter} onDismiss={closeCurrentSuperNoteImporter}>
|
||||||
|
{confirmationQueue[0] && (
|
||||||
|
<SuperNoteImporter
|
||||||
|
note={confirmationQueue[0]}
|
||||||
|
application={application}
|
||||||
|
onConvertComplete={handleSuperNoteConversionCompletion}
|
||||||
|
closeDialog={closeCurrentSuperNoteImporter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ModalOverlay>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChangeMultipleMenu
|
||||||
@@ -10,6 +10,7 @@ import { NotesController } from '@/Controllers/NotesController/NotesController'
|
|||||||
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
import { SelectedItemsController } from '@/Controllers/SelectedItemsController'
|
||||||
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
|
import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalController'
|
||||||
import { LinkingController } from '@/Controllers/LinkingController'
|
import { LinkingController } from '@/Controllers/LinkingController'
|
||||||
|
import ChangeMultipleButton from '../ChangeEditor/ChangeMultipleButton'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
application: WebApplication
|
application: WebApplication
|
||||||
@@ -39,6 +40,9 @@ const MultipleSelectedNotes = ({
|
|||||||
<div className="flex w-full items-center justify-between p-4">
|
<div className="flex w-full items-center justify-between p-4">
|
||||||
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
|
<h1 className="m-0 text-lg font-bold">{count} selected notes</h1>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
<div className="mr-3">
|
||||||
|
<ChangeMultipleButton application={application} notesController={notesController} />
|
||||||
|
</div>
|
||||||
<div className="mr-3">
|
<div className="mr-3">
|
||||||
<PinNoteButton notesController={notesController} />
|
<PinNoteButton notesController={notesController} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user