feat: Added a conflict resolution dialog and a Conflicts view for easier management of conflicts (#2337)
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { SNNote, classNames } from '@standardnotes/snjs'
|
||||
import { MouseEventHandler } from 'react'
|
||||
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
|
||||
import { useApplication } from '../../ApplicationProvider'
|
||||
import { useNoteAttributes } from '../../NotesOptions/NoteAttributes'
|
||||
import CheckIndicator from '../../Checkbox/CheckIndicator'
|
||||
import { VisuallyHidden } from '@ariakit/react'
|
||||
import Icon from '../../Icon/Icon'
|
||||
import StyledTooltip from '../../StyledTooltip/StyledTooltip'
|
||||
|
||||
export const ConflictListItem = ({
|
||||
isSelected,
|
||||
onClick,
|
||||
title,
|
||||
note,
|
||||
disabled,
|
||||
}: {
|
||||
isSelected: boolean
|
||||
disabled: boolean
|
||||
onClick: MouseEventHandler<HTMLButtonElement>
|
||||
title: string
|
||||
note: SNNote
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
const { words, characters, paragraphs, dateLastModified, dateCreated, format } = useNoteAttributes(application, note)
|
||||
|
||||
return (
|
||||
<button
|
||||
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
|
||||
className={classNames(
|
||||
'flex w-full select-none flex-col overflow-hidden border-l-2 bg-transparent px-3 py-2.5 pl-4 text-left text-sm text-text',
|
||||
isSelected ? 'border-info bg-info-backdrop' : 'border-transparent',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-75'
|
||||
: 'cursor-pointer hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none',
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-selected={isSelected}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<CheckIndicator checked={isSelected} />
|
||||
<div className="font-semibold">{title}</div>
|
||||
</div>
|
||||
<div className="w-full text-sm text-neutral lg:text-xs">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<StyledTooltip gutter={8} label="Last modified" className="!z-modal">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon type="restore" size="medium" />
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
<VisuallyHidden>Last modified</VisuallyHidden> {dateLastModified}
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<StyledTooltip gutter={8} label="Created" className="!z-modal">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon type="pencil-filled" size="medium" />
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
<VisuallyHidden>Created</VisuallyHidden> {dateCreated}
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center gap-2 overflow-hidden">
|
||||
<StyledTooltip gutter={8} label="Note ID" className="!z-modal">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon type="info" size="medium" />
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
<VisuallyHidden>Note ID</VisuallyHidden>
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">{note.uuid}</div>
|
||||
</div>
|
||||
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<StyledTooltip gutter={8} label={`${words} words`} className="!z-modal">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon type="line-width" size="medium" />
|
||||
{words}
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
<StyledTooltip gutter={8} label={`${characters} characters`} className="!z-modal">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon type="bold" size="small" />
|
||||
<span>{characters}</span>
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
<StyledTooltip gutter={8} label={`${paragraphs} paragraphs`} className="!z-modal">
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon type="paragraph" size="medium" />
|
||||
<span>{paragraphs}</span>
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { NoteType, SNNote, classNames } from '@standardnotes/snjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import fastdiff from 'fast-diff'
|
||||
import { HeadlessSuperConverter } from '../../SuperEditor/Tools/HeadlessSuperConverter'
|
||||
|
||||
const DiffSpan = ({ state, text }: { state: fastdiff.Diff[0]; text: fastdiff.Diff[1] }) => (
|
||||
<span
|
||||
data-diff={state !== fastdiff.EQUAL ? state : undefined}
|
||||
className={classNames(
|
||||
'whitespace-pre-wrap',
|
||||
state === fastdiff.INSERT && 'bg-success text-success-contrast',
|
||||
state === fastdiff.DELETE && 'bg-danger text-danger-contrast',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
|
||||
export const DiffView = ({
|
||||
selectedNotes,
|
||||
convertSuperToMarkdown,
|
||||
}: {
|
||||
selectedNotes: SNNote[]
|
||||
convertSuperToMarkdown: boolean
|
||||
}) => {
|
||||
const [titleDiff, setTitleDiff] = useState<fastdiff.Diff[]>([])
|
||||
const [textDiff, setTextDiff] = useState<fastdiff.Diff[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const firstNote = selectedNotes[0]
|
||||
const firstTitle = firstNote.title
|
||||
const firstText =
|
||||
firstNote.noteType === NoteType.Super && convertSuperToMarkdown
|
||||
? new HeadlessSuperConverter().convertString(firstNote.text, 'md')
|
||||
: firstNote.text
|
||||
|
||||
const secondNote = selectedNotes[1]
|
||||
const secondTitle = secondNote.title
|
||||
const secondText =
|
||||
secondNote.noteType === NoteType.Super && convertSuperToMarkdown
|
||||
? new HeadlessSuperConverter().convertString(secondNote.text, 'md')
|
||||
: secondNote.text
|
||||
|
||||
const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true)
|
||||
const textDiff = fastdiff(firstText, secondText, undefined, true)
|
||||
|
||||
setTitleDiff(titleDiff)
|
||||
setTextDiff(textDiff)
|
||||
}, [convertSuperToMarkdown, selectedNotes])
|
||||
|
||||
const [preElement, setPreElement] = useState<HTMLPreElement | null>(null)
|
||||
const [diffVisualizer, setDiffVisualizer] = useState<HTMLDivElement | null>(null)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!preElement) {
|
||||
return
|
||||
}
|
||||
|
||||
setHasOverflow(preElement.scrollHeight > preElement.clientHeight)
|
||||
}, [preElement, textDiff])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preElement || !diffVisualizer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasOverflow) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!textDiff.length) {
|
||||
return
|
||||
}
|
||||
|
||||
diffVisualizer.innerHTML = ''
|
||||
const preElementRect = preElement.getBoundingClientRect()
|
||||
const diffVisualizerRect = diffVisualizer.getBoundingClientRect()
|
||||
|
||||
const diffs = preElement.querySelectorAll('[data-diff]')
|
||||
|
||||
diffs.forEach((diff) => {
|
||||
const state = diff.getAttribute('data-diff')
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
const parsedState = parseInt(state)
|
||||
|
||||
const rect = diff.getBoundingClientRect()
|
||||
|
||||
const topAsPercent = (rect.top - preElementRect.top) / preElement.scrollHeight
|
||||
const topAdjustedForDiffVisualizer = diffVisualizerRect.height * topAsPercent
|
||||
|
||||
const heightAsPercent = rect.height / preElement.scrollHeight
|
||||
const heightAdjustedForDiffVisualizer = diffVisualizerRect.height * heightAsPercent
|
||||
|
||||
const div = document.createElement('div')
|
||||
div.className = `absolute top-0 left-0 w-full bg-${
|
||||
parsedState === fastdiff.INSERT ? 'success' : 'danger'
|
||||
} opacity-50`
|
||||
div.style.height = `${heightAdjustedForDiffVisualizer}px`
|
||||
div.style.top = `${topAdjustedForDiffVisualizer}px`
|
||||
|
||||
diffVisualizer.appendChild(div)
|
||||
})
|
||||
}, [preElement, hasOverflow, textDiff, diffVisualizer])
|
||||
|
||||
return (
|
||||
<div className="force-custom-scrollbar relative flex flex-grow flex-col overflow-hidden">
|
||||
<div className="w-full px-4 py-4 text-base font-bold">
|
||||
{titleDiff.map(([state, text], index) => (
|
||||
<DiffSpan state={state} text={text} key={index} />
|
||||
))}
|
||||
</div>
|
||||
<pre
|
||||
className="font-editor min-h-0 w-full flex-grow overflow-y-auto whitespace-pre-wrap p-4 pt-0 text-editor [&::-webkit-scrollbar]:bg-transparent"
|
||||
ref={setPreElement}
|
||||
>
|
||||
{textDiff.map(([state, text], index) => (
|
||||
<DiffSpan state={state} text={text} key={index} />
|
||||
))}
|
||||
</pre>
|
||||
{hasOverflow && (
|
||||
<div className="absolute top-0 right-0 z-[-1] h-full w-[19px] border-l border-border" ref={setDiffVisualizer} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
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 { NoteContent } from './NoteContent'
|
||||
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) => (
|
||||
<NoteContent
|
||||
note={note}
|
||||
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
|
||||
@@ -0,0 +1,121 @@
|
||||
import { ContentType, NoteType, SNNote } from '@standardnotes/snjs'
|
||||
import { UIEventHandler, useEffect, useMemo, useRef } from 'react'
|
||||
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
|
||||
import { useApplication } from '../../ApplicationProvider'
|
||||
import ComponentView from '../../ComponentView/ComponentView'
|
||||
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
|
||||
import { BlocksEditor } from '../../SuperEditor/BlocksEditor'
|
||||
import { BlocksEditorComposer } from '../../SuperEditor/BlocksEditorComposer'
|
||||
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
|
||||
import LinkedItemBubblesContainer from '../../LinkedItems/LinkedItemBubblesContainer'
|
||||
|
||||
export const NoteContent = ({
|
||||
note,
|
||||
scrollPos,
|
||||
shouldSyncScroll,
|
||||
onScroll,
|
||||
}: {
|
||||
note: SNNote
|
||||
scrollPos: number
|
||||
shouldSyncScroll: boolean
|
||||
onScroll: UIEventHandler
|
||||
}) => {
|
||||
const application = useApplication()
|
||||
const linkingController = useLinkingController()
|
||||
|
||||
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
|
||||
|
||||
const componentViewer = useMemo(() => {
|
||||
const editorForCurrentNote = note ? application.componentManager.editorForNote(note) : undefined
|
||||
|
||||
if (!editorForCurrentNote) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const templateNoteForRevision = application.mutator.createTemplateItem(ContentType.Note, note.content) as SNNote
|
||||
|
||||
const componentViewer = application.componentManager.createComponentViewer(editorForCurrentNote)
|
||||
componentViewer.setReadonly(true)
|
||||
componentViewer.lockReadonly = true
|
||||
componentViewer.overrideContextItem = templateNoteForRevision
|
||||
return componentViewer
|
||||
}, [application.componentManager, application.mutator, note])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (componentViewer) {
|
||||
application.componentManager.destroyComponentViewer(componentViewer)
|
||||
}
|
||||
}
|
||||
}, [application, componentViewer])
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldSyncScroll) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!containerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const scroller = containerRef.current.querySelector('textarea, .ContentEditable__root')
|
||||
|
||||
if (!scroller) {
|
||||
return
|
||||
}
|
||||
|
||||
scroller.scrollTo({
|
||||
top: scrollPos,
|
||||
})
|
||||
}, [scrollPos, shouldSyncScroll])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-grow flex-col overflow-hidden" ref={containerRef}>
|
||||
<div className="w-full px-4 pt-4 text-base font-bold">
|
||||
<div className="title">{note.title}</div>
|
||||
</div>
|
||||
<LinkedItemBubblesContainer
|
||||
item={note}
|
||||
linkingController={linkingController}
|
||||
readonly
|
||||
className={{ base: 'mt-2 px-4', withToggle: '!mt-1 !pt-0' }}
|
||||
isCollapsedByDefault={isMobileScreen}
|
||||
/>
|
||||
{componentViewer ? (
|
||||
<div className="component-view">
|
||||
<ComponentView key={componentViewer.identifier} componentViewer={componentViewer} application={application} />
|
||||
</div>
|
||||
) : note?.noteType === NoteType.Super ? (
|
||||
<ErrorBoundary>
|
||||
<div className="w-full flex-grow overflow-hidden overflow-y-auto">
|
||||
<BlocksEditorComposer readonly initialValue={note.text}>
|
||||
<BlocksEditor
|
||||
readonly
|
||||
className="blocks-editor relative h-full resize-none p-4 text-base focus:shadow-none focus:outline-none"
|
||||
spellcheck={note.spellcheck}
|
||||
onScroll={onScroll}
|
||||
></BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="relative mt-3 min-h-0 flex-grow overflow-hidden">
|
||||
{note.text.length ? (
|
||||
<textarea
|
||||
readOnly={true}
|
||||
className="font-editor h-full w-full resize-none border-0 bg-default p-4 pt-0 text-editor text-text"
|
||||
value={note.text}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-passive-0">
|
||||
Empty note.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils'
|
||||
import { classNames } from '@standardnotes/utils'
|
||||
import { classNames, pluralize } from '@standardnotes/utils'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentArea,
|
||||
@@ -45,6 +45,10 @@ import { SuperEditorContentId } from '../SuperEditor/Constants'
|
||||
import { NoteViewController } from './Controller/NoteViewController'
|
||||
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
|
||||
import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths'
|
||||
import Button from '../Button/Button'
|
||||
import ModalOverlay from '../Modal/ModalOverlay'
|
||||
import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
const MinimumStatusDuration = 400
|
||||
|
||||
@@ -74,6 +78,9 @@ type State = {
|
||||
updateSavingIndicator?: boolean
|
||||
editorFeatureIdentifier?: string
|
||||
noteType?: NoteType
|
||||
|
||||
conflictedNotes: SNNote[]
|
||||
showConflictResolutionModal: boolean
|
||||
}
|
||||
|
||||
class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
@@ -84,6 +91,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
private removeTrashKeyObserver?: () => void
|
||||
private removeComponentStreamObserver?: () => void
|
||||
private removeNoteStreamObserver?: () => void
|
||||
private removeComponentManagerObserver?: () => void
|
||||
private removeInnerNoteObserver?: () => void
|
||||
|
||||
@@ -120,6 +128,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
syncTakingTooLong: false,
|
||||
editorFeatureIdentifier: this.controller.item.editorIdentifier,
|
||||
noteType: this.controller.item.noteType,
|
||||
conflictedNotes: [],
|
||||
showConflictResolutionModal: false,
|
||||
}
|
||||
|
||||
this.noteViewElementRef = createRef<HTMLDivElement>()
|
||||
@@ -133,6 +143,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
this.removeComponentStreamObserver?.()
|
||||
;(this.removeComponentStreamObserver as unknown) = undefined
|
||||
|
||||
this.removeNoteStreamObserver?.()
|
||||
;(this.removeNoteStreamObserver as unknown) = undefined
|
||||
|
||||
this.removeInnerNoteObserver?.()
|
||||
;(this.removeInnerNoteObserver as unknown) = undefined
|
||||
|
||||
@@ -418,6 +431,37 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
await this.reloadStackComponents()
|
||||
this.debounceReloadEditorComponent()
|
||||
})
|
||||
|
||||
this.removeNoteStreamObserver = this.application.streamItems<SNNote>(
|
||||
ContentType.Note,
|
||||
async ({ inserted, changed, removed }) => {
|
||||
const insertedOrChanged = inserted.concat(changed)
|
||||
|
||||
for (const note of insertedOrChanged) {
|
||||
if (note.conflictOf === this.note.uuid && !note.trashed) {
|
||||
this.setState((state) => ({
|
||||
conflictedNotes: state.conflictedNotes
|
||||
.filter((conflictedNote) => conflictedNote.uuid !== note.uuid)
|
||||
.concat([note]),
|
||||
}))
|
||||
} else {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
conflictedNotes: state.conflictedNotes.filter((conflictedNote) => conflictedNote.uuid !== note.uuid),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const note of removed) {
|
||||
this.setState((state) => {
|
||||
return {
|
||||
conflictedNotes: state.conflictedNotes.filter((conflictedNote) => conflictedNote.uuid !== note.uuid),
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private createComponentViewer(component: SNComponent) {
|
||||
@@ -776,6 +820,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
this.setState({ plainEditorFocused: false })
|
||||
}
|
||||
|
||||
toggleConflictResolutionModal = () => {
|
||||
this.setState((state) => ({
|
||||
showConflictResolutionModal: !state.showConflictResolutionModal,
|
||||
}))
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.controller.dealloced) {
|
||||
return null
|
||||
@@ -803,6 +853,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
? 'component'
|
||||
: 'plain'
|
||||
|
||||
const shouldShowConflictsButton = this.state.conflictedNotes.length > 0
|
||||
|
||||
return (
|
||||
<div aria-label="Note" className="section editor sn-component h-full md:max-h-full" ref={this.noteViewElementRef}>
|
||||
{this.note && (
|
||||
@@ -826,7 +878,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
id="editor-title-bar"
|
||||
className="content-title-bar section-title-bar z-editor-title-bar w-full bg-default pt-4"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 md:mb-0 md:flex-nowrap md:gap-4 xl:items-center">
|
||||
<div
|
||||
className={classNames(
|
||||
'mb-2 flex flex-wrap justify-between gap-2 md:mb-0 md:flex-nowrap md:gap-4 xl:items-center',
|
||||
shouldShowConflictsButton ? 'items-center' : 'items-start',
|
||||
)}
|
||||
>
|
||||
<div className={classNames(this.state.noteLocked && 'locked', 'flex flex-grow items-center')}>
|
||||
<MobileItemsListButton />
|
||||
<div className="title flex-grow overflow-auto">
|
||||
@@ -850,6 +907,19 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
updateSavingIndicator={this.state.updateSavingIndicator}
|
||||
/>
|
||||
</div>
|
||||
{shouldShowConflictsButton && (
|
||||
<Button
|
||||
className="flex items-center"
|
||||
primary
|
||||
colorStyle="warning"
|
||||
small
|
||||
onClick={this.toggleConflictResolutionModal}
|
||||
>
|
||||
<Icon type="merge" size="small" className="mr-2" />
|
||||
{this.state.conflictedNotes.length}{' '}
|
||||
{pluralize(this.state.conflictedNotes.length, 'conflict', 'conflicts')}
|
||||
</Button>
|
||||
)}
|
||||
{renderHeaderOptions && (
|
||||
<div className="note-view-options-buttons flex items-center gap-3">
|
||||
<LinkedItemsButton
|
||||
@@ -979,6 +1049,14 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalOverlay isOpen={this.state.showConflictResolutionModal} close={this.toggleConflictResolutionModal}>
|
||||
<NoteConflictResolutionModal
|
||||
currentNote={this.note}
|
||||
conflictedNotes={this.state.conflictedNotes}
|
||||
close={this.toggleConflictResolutionModal}
|
||||
/>
|
||||
</ModalOverlay>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user