399 lines
15 KiB
TypeScript
399 lines
15 KiB
TypeScript
import { NoteType, SNNote, classNames } from '@standardnotes/snjs'
|
|
import Modal, { ModalAction } from '../../Modal/Modal'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
|
import { useApplication } from '../../ApplicationProvider'
|
|
import { confirmDialog } from '@standardnotes/ui-services'
|
|
import { useListKeyboardNavigation } from '@/Hooks/useListKeyboardNavigation'
|
|
import ModalDialogButtons from '../../Modal/ModalDialogButtons'
|
|
import {
|
|
Checkbox,
|
|
Select,
|
|
SelectArrow,
|
|
SelectItem,
|
|
SelectList,
|
|
Toolbar,
|
|
ToolbarItem,
|
|
useSelectStore,
|
|
useToolbarStore,
|
|
} from '@ariakit/react'
|
|
import Popover from '../../Popover/Popover'
|
|
import Icon from '../../Icon/Icon'
|
|
import Button from '../../Button/Button'
|
|
import Spinner from '../../Spinner/Spinner'
|
|
import Switch from '../../Switch/Switch'
|
|
import StyledTooltip from '../../StyledTooltip/StyledTooltip'
|
|
import { DiffView } from './DiffView'
|
|
import { ReadonlyNoteContent } from '../ReadonlyNoteContent'
|
|
import { ConflictListItem } from './ConflictListItem'
|
|
|
|
type ConflictAction = 'move-to-trash' | 'delete-permanently'
|
|
type MultipleSelectionMode = 'preview' | 'diff'
|
|
|
|
const NoteConflictResolutionModal = ({
|
|
currentNote,
|
|
conflictedNotes,
|
|
close,
|
|
}: {
|
|
currentNote: SNNote
|
|
conflictedNotes: SNNote[]
|
|
close: () => void
|
|
}) => {
|
|
const allVersions = useMemo(() => [currentNote].concat(conflictedNotes), [conflictedNotes, currentNote])
|
|
|
|
const application = useApplication()
|
|
const [selectedVersions, setSelectedVersions] = useState([currentNote.uuid])
|
|
|
|
const selectedNotes = useMemo(() => {
|
|
return allVersions.filter((note) => selectedVersions.includes(note.uuid))
|
|
}, [allVersions, selectedVersions])
|
|
|
|
const trashNote = useCallback(
|
|
async (note: SNNote) => {
|
|
await application.mutator
|
|
.changeItem(note, (mutator) => {
|
|
mutator.trashed = true
|
|
mutator.conflictOf = undefined
|
|
})
|
|
.catch(console.error)
|
|
setSelectedVersions([allVersions[0].uuid])
|
|
},
|
|
[allVersions, application.mutator],
|
|
)
|
|
|
|
const deleteNotePermanently = useCallback(
|
|
async (note: SNNote) => {
|
|
await application.mutator
|
|
.deleteItem(note)
|
|
.catch(console.error)
|
|
.then(() => {
|
|
setSelectedVersions([allVersions[0].uuid])
|
|
})
|
|
},
|
|
[allVersions, application.mutator],
|
|
)
|
|
|
|
const [selectedAction, setSelectionAction] = useState<ConflictAction>('move-to-trash')
|
|
const selectStore = useSelectStore({
|
|
value: selectedAction,
|
|
setValue: (value) => setSelectionAction(value as ConflictAction),
|
|
})
|
|
const [isPerformingAction, setIsPerformingAction] = useState(false)
|
|
|
|
const keepOnlySelected = useCallback(async () => {
|
|
const shouldDeletePermanently = selectedAction === 'delete-permanently'
|
|
|
|
const confirmDialogText = `This will keep only the selected versions and ${
|
|
shouldDeletePermanently ? 'delete the other versions permanently.' : 'move the other versions to the trash.'
|
|
} Are you sure?`
|
|
|
|
if (
|
|
await confirmDialog({
|
|
title: 'Keep only selected versions?',
|
|
text: confirmDialogText,
|
|
confirmButtonStyle: 'danger',
|
|
})
|
|
) {
|
|
const nonSelectedNotes = allVersions.filter((note) => !selectedVersions.includes(note.uuid))
|
|
selectStore.hide()
|
|
setIsPerformingAction(true)
|
|
await Promise.all(
|
|
nonSelectedNotes.map((note) => (shouldDeletePermanently ? deleteNotePermanently(note) : trashNote(note))),
|
|
)
|
|
await application.mutator.changeItems(selectedNotes, (mutator) => {
|
|
mutator.conflictOf = undefined
|
|
})
|
|
setIsPerformingAction(false)
|
|
void application.getViewControllerManager().selectionController.selectItem(selectedNotes[0].uuid, true)
|
|
void application.sync.sync()
|
|
close()
|
|
}
|
|
}, [
|
|
allVersions,
|
|
application,
|
|
close,
|
|
deleteNotePermanently,
|
|
selectStore,
|
|
selectedAction,
|
|
selectedNotes,
|
|
selectedVersions,
|
|
trashNote,
|
|
])
|
|
|
|
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
|
const actions = useMemo(
|
|
(): ModalAction[] => [
|
|
{
|
|
label: 'Cancel',
|
|
onClick: close,
|
|
type: 'cancel',
|
|
mobileSlot: 'left',
|
|
},
|
|
],
|
|
[close],
|
|
)
|
|
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
useListKeyboardNavigation(listRef)
|
|
|
|
const [selectedMobileTab, setSelectedMobileTab] = useState<'list' | 'preview'>('list')
|
|
|
|
const toolbarStore = useToolbarStore()
|
|
const isSelectOpen = selectStore.useState('open')
|
|
const [selectAnchor, setSelectAnchor] = useState<HTMLButtonElement | null>(null)
|
|
|
|
const [multipleSelectionMode, setMultipleSelectionMode] = useState<MultipleSelectionMode>(
|
|
isMobileScreen ? 'diff' : 'preview',
|
|
)
|
|
const isPreviewMode = multipleSelectionMode === 'preview'
|
|
useEffect(() => {
|
|
if (selectedNotes.length !== 2) {
|
|
setMultipleSelectionMode('preview')
|
|
}
|
|
|
|
if (isMobileScreen && selectedNotes.length === 2) {
|
|
setMultipleSelectionMode('diff')
|
|
}
|
|
}, [isMobileScreen, selectedNotes.length])
|
|
const showSuperConversionInfo = selectedNotes.some((note) => note.noteType === NoteType.Super) && !isPreviewMode
|
|
const [compareSuperMarkdown, setCompareSuperMarkdown] = useState(true)
|
|
|
|
const [comparisonScrollPos, setComparisonScrollPos] = useState(0)
|
|
const [shouldSyncComparisonScroll, setShouldSyncComparisonScroll] = useState(true)
|
|
|
|
return (
|
|
<Modal
|
|
title="Resolve conflicts"
|
|
className={{
|
|
content: 'md:h-full md:w-[70vw]',
|
|
description: 'flex flex-col overflow-x-hidden md:flex-row',
|
|
}}
|
|
actions={actions}
|
|
close={close}
|
|
customFooter={
|
|
<ModalDialogButtons className={selectedNotes.length > 1 ? 'hidden md:flex' : ''}>
|
|
<Button className="mr-auto hidden md:inline-block" onClick={close} disabled={isPerformingAction}>
|
|
Cancel
|
|
</Button>
|
|
<Toolbar className="flex w-full items-stretch text-info-contrast md:w-auto" store={toolbarStore}>
|
|
<ToolbarItem
|
|
onClick={keepOnlySelected}
|
|
className="flex-grow rounded rounded-r-none bg-info px-3 py-1.5 text-base font-bold ring-info ring-offset-2 ring-offset-default hover:brightness-110 focus:ring-0 focus-visible:ring-2 focus-visible:brightness-110 lg:text-sm"
|
|
disabled={isPerformingAction}
|
|
>
|
|
{isPerformingAction ? (
|
|
<>
|
|
<Spinner className="h-4 w-4 border-info-contrast" />
|
|
</>
|
|
) : (
|
|
<>Keep selected, {selectedAction === 'move-to-trash' ? 'trash others' : 'delete others'}</>
|
|
)}
|
|
</ToolbarItem>
|
|
<Select
|
|
ref={setSelectAnchor}
|
|
render={
|
|
<ToolbarItem
|
|
className="relative rounded rounded-l-none bg-info py-1.5 px-3 ring-info hover:brightness-110 focus:ring-0 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-default focus-visible:brightness-110"
|
|
disabled={isPerformingAction}
|
|
>
|
|
<SelectArrow className="block rotate-180" />
|
|
<div className="absolute top-0 left-0 h-full w-[2px] bg-info brightness-[.85]" />
|
|
</ToolbarItem>
|
|
}
|
|
store={selectStore}
|
|
/>
|
|
<Popover
|
|
title="Conflict options"
|
|
open={isSelectOpen}
|
|
togglePopover={selectStore.toggle}
|
|
anchorElement={selectAnchor}
|
|
className="!fixed z-modal border border-border py-1"
|
|
side="top"
|
|
align="end"
|
|
offset={4}
|
|
hideOnClickInModal
|
|
>
|
|
<SelectList
|
|
className="cursor-pointer divide-y divide-border [&>[data-active-item]]:bg-passive-5"
|
|
store={selectStore}
|
|
>
|
|
<SelectItem className="px-2.5 py-2 hover:bg-passive-5" value="move-to-trash">
|
|
<div className="flex items-center gap-1 text-sm font-bold text-text">
|
|
{selectedAction === 'move-to-trash' ? (
|
|
<Icon type="check-bold" size="small" />
|
|
) : (
|
|
<div className="h-3.5 w-3.5" />
|
|
)}
|
|
Move others to trash
|
|
</div>
|
|
<div className="ml-4.5 text-neutral">
|
|
Only the selected version will be kept; others will be moved to trash.
|
|
</div>
|
|
</SelectItem>
|
|
<SelectItem className="px-2.5 py-2 hover:bg-passive-5" value="delete-permanently">
|
|
<div className="flex items-center gap-1 text-sm font-bold text-text">
|
|
{selectedAction === 'delete-permanently' ? (
|
|
<Icon type="check-bold" size="small" />
|
|
) : (
|
|
<div className="h-3.5 w-3.5" />
|
|
)}
|
|
Delete others permanently
|
|
</div>
|
|
<div className="ml-4.5 text-neutral">
|
|
Only the selected version will be kept; others will be deleted permanently.
|
|
</div>
|
|
</SelectItem>
|
|
</SelectList>
|
|
</Popover>
|
|
</Toolbar>
|
|
</ModalDialogButtons>
|
|
}
|
|
>
|
|
<div className="flex border-b border-border md:hidden">
|
|
<button
|
|
className={classNames(
|
|
'relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:shadow-inner',
|
|
selectedMobileTab === 'list' ? 'font-medium text-info shadow-bottom' : 'text-text',
|
|
)}
|
|
onClick={() => {
|
|
setSelectedMobileTab('list')
|
|
}}
|
|
>
|
|
List
|
|
</button>
|
|
<button
|
|
className={classNames(
|
|
'relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:shadow-inner',
|
|
selectedMobileTab === 'preview' ? 'font-medium text-info shadow-bottom' : 'text-text',
|
|
)}
|
|
onClick={() => {
|
|
setSelectedMobileTab('preview')
|
|
}}
|
|
>
|
|
Preview
|
|
</button>
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
'w-full overflow-y-auto border-r border-border py-1.5 md:flex md:w-auto md:min-w-60 md:flex-col',
|
|
selectedMobileTab !== 'list' && 'hidden md:flex',
|
|
)}
|
|
ref={listRef}
|
|
>
|
|
{allVersions.map((note, index) => (
|
|
<ConflictListItem
|
|
disabled={isPerformingAction}
|
|
isSelected={selectedVersions.includes(note.uuid)}
|
|
onClick={() => {
|
|
setSelectedVersions((versions) => {
|
|
if (!versions.includes(note.uuid)) {
|
|
return versions.length > 1 ? versions.slice(1).concat(note.uuid) : versions.concat(note.uuid)
|
|
}
|
|
|
|
return versions.length > 1 ? versions.filter((version) => version !== note.uuid) : versions
|
|
})
|
|
setSelectedMobileTab('preview')
|
|
}}
|
|
key={note.uuid}
|
|
title={index === 0 ? 'Current version' : `Version ${index + 1}`}
|
|
note={note}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div
|
|
className={classNames(
|
|
'flex w-full flex-grow flex-col overflow-hidden',
|
|
selectedMobileTab !== 'preview' && 'hidden md:flex',
|
|
)}
|
|
>
|
|
{isPreviewMode && (
|
|
<div
|
|
className={classNames(
|
|
'min-h-0 w-full flex-grow divide-x divide-border pb-0.5',
|
|
isMobileScreen ? 'flex' : 'grid grid-rows-1',
|
|
)}
|
|
style={!isMobileScreen ? { gridTemplateColumns: `repeat(${selectedNotes.length}, 1fr)` } : undefined}
|
|
>
|
|
{selectedNotes.map((note) => (
|
|
<ReadonlyNoteContent
|
|
note={note}
|
|
content={note.content}
|
|
key={note.uuid}
|
|
scrollPos={comparisonScrollPos}
|
|
shouldSyncScroll={shouldSyncComparisonScroll}
|
|
onScroll={(event) => setComparisonScrollPos((event.target as HTMLElement).scrollTop)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!isPreviewMode && selectedNotes.length === 2 && (
|
|
<DiffView selectedNotes={selectedNotes} convertSuperToMarkdown={compareSuperMarkdown} />
|
|
)}
|
|
{selectedNotes.length === 2 && (
|
|
<div className="flex min-h-11 items-center justify-center gap-2 border-t border-border px-4 py-1.5">
|
|
{isPreviewMode && (
|
|
<StyledTooltip
|
|
className="!z-modal !max-w-[50ch]"
|
|
label={shouldSyncComparisonScroll ? 'Scrolling is synced' : 'Scrolling is not synced. Click to sync.'}
|
|
showOnMobile
|
|
portal={false}
|
|
>
|
|
<div className="relative rounded-full p-1 hover:bg-contrast">
|
|
<Icon type={shouldSyncComparisonScroll ? 'link' : 'link-off'} className="text-neutral" />
|
|
<Checkbox
|
|
className="absolute top-0 left-0 right-0 bottom-0 cursor-pointer opacity-0"
|
|
checked={shouldSyncComparisonScroll}
|
|
onChange={() => setShouldSyncComparisonScroll((shouldSync) => !shouldSync)}
|
|
/>
|
|
</div>
|
|
</StyledTooltip>
|
|
)}
|
|
{!isMobileScreen && (
|
|
<>
|
|
<div className={showSuperConversionInfo ? 'ml-9' : ''}>Preview Mode</div>
|
|
<Switch
|
|
checked={!isPreviewMode}
|
|
onChange={function (checked: boolean): void {
|
|
setMultipleSelectionMode(checked ? 'diff' : 'preview')
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
<div className={isPreviewMode ? 'mr-9' : ''}>Diff Mode</div>
|
|
{showSuperConversionInfo && (
|
|
<StyledTooltip
|
|
className="!z-modal !max-w-[50ch]"
|
|
label={
|
|
<>
|
|
<div className="mb-2">
|
|
Super notes use JSON under the hood to create rich and flexible documents. While neatly organized,
|
|
it's not ideal to read or compare manually. Instead, this diff compares a Markdown rendition of
|
|
the notes.
|
|
</div>
|
|
<label className="mb-1 flex select-none items-center gap-2">
|
|
<Switch
|
|
checked={!compareSuperMarkdown}
|
|
onChange={(checked) => setCompareSuperMarkdown(!checked)}
|
|
/>
|
|
Compare JSON instead
|
|
</label>
|
|
</>
|
|
}
|
|
showOnMobile
|
|
showOnHover={false}
|
|
portal={false}
|
|
>
|
|
<button className="rounded-full p-1 hover:bg-contrast">
|
|
<Icon type="info" className="text-neutral" />
|
|
</button>
|
|
</StyledTooltip>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
export default NoteConflictResolutionModal
|