diff --git a/.yarn/cache/@ariakit-core-npm-0.2.0-ce89b84e1e-f95e3db9eb.zip b/.yarn/cache/@ariakit-core-npm-0.2.0-ce89b84e1e-f95e3db9eb.zip deleted file mode 100644 index 5a2f89842..000000000 Binary files a/.yarn/cache/@ariakit-core-npm-0.2.0-ce89b84e1e-f95e3db9eb.zip and /dev/null differ diff --git a/.yarn/cache/@ariakit-core-npm-0.2.4-c56bded4d3-be795380e7.zip b/.yarn/cache/@ariakit-core-npm-0.2.4-c56bded4d3-be795380e7.zip new file mode 100644 index 000000000..38895b2fd Binary files /dev/null and b/.yarn/cache/@ariakit-core-npm-0.2.4-c56bded4d3-be795380e7.zip differ diff --git a/.yarn/cache/@ariakit-react-core-npm-0.2.1-181b948e55-c10ba749af.zip b/.yarn/cache/@ariakit-react-core-npm-0.2.1-181b948e55-c10ba749af.zip deleted file mode 100644 index ec9564dd9..000000000 Binary files a/.yarn/cache/@ariakit-react-core-npm-0.2.1-181b948e55-c10ba749af.zip and /dev/null differ diff --git a/.yarn/cache/@ariakit-react-core-npm-0.2.8-467895afc1-aeb94f4cb8.zip b/.yarn/cache/@ariakit-react-core-npm-0.2.8-467895afc1-aeb94f4cb8.zip new file mode 100644 index 000000000..b5ce5ce01 Binary files /dev/null and b/.yarn/cache/@ariakit-react-core-npm-0.2.8-467895afc1-aeb94f4cb8.zip differ diff --git a/.yarn/cache/@ariakit-react-npm-0.2.1-0c8c192054-55de2d395f.zip b/.yarn/cache/@ariakit-react-npm-0.2.1-0c8c192054-55de2d395f.zip deleted file mode 100644 index ee13f7492..000000000 Binary files a/.yarn/cache/@ariakit-react-npm-0.2.1-0c8c192054-55de2d395f.zip and /dev/null differ diff --git a/.yarn/cache/@ariakit-react-npm-0.2.8-b4d81a4292-8d974628e8.zip b/.yarn/cache/@ariakit-react-npm-0.2.8-b4d81a4292-8d974628e8.zip new file mode 100644 index 000000000..065cdb446 Binary files /dev/null and b/.yarn/cache/@ariakit-react-npm-0.2.8-b4d81a4292-8d974628e8.zip differ diff --git a/.yarn/cache/fast-diff-npm-1.3.0-9f19e3b743-d22d371b99.zip b/.yarn/cache/fast-diff-npm-1.3.0-9f19e3b743-d22d371b99.zip new file mode 100644 index 000000000..0e9ed0fa2 Binary files /dev/null and b/.yarn/cache/fast-diff-npm-1.3.0-9f19e3b743-d22d371b99.zip differ diff --git a/packages/icons/src/Icons/index.ts b/packages/icons/src/Icons/index.ts index 5a32917e5..53b9cb5e6 100644 --- a/packages/icons/src/Icons/index.ts +++ b/packages/icons/src/Icons/index.ts @@ -186,6 +186,7 @@ import SubtractIcon from './ic-subtract.svg' import SuperscriptIcon from './ic-superscript.svg' import SyncIcon from './ic-sync.svg' import TasksIcon from './ic-tasks.svg' +import TextIcon from './ic-text.svg' import TextCircleIcon from './ic-text-circle.svg' import TextParagraphLongIcon from './ic-text-paragraph-long.svg' import ThemesFilledIcon from './ic-themes-filled.svg' @@ -392,6 +393,7 @@ export { SuperscriptIcon, SyncIcon, TasksIcon, + TextIcon, TextCircleIcon, TextParagraphLongIcon, ThemesFilledIcon, diff --git a/packages/models/src/Domain/Runtime/Collection/Collection.ts b/packages/models/src/Domain/Runtime/Collection/Collection.ts index 7b1afd1da..14827d6aa 100644 --- a/packages/models/src/Domain/Runtime/Collection/Collection.ts +++ b/packages/models/src/Domain/Runtime/Collection/Collection.ts @@ -187,6 +187,8 @@ export abstract class Collection< const conflictOf = element.content.conflict_of if (conflictOf) { this.conflictMap.establishRelationship(conflictOf, element.uuid) + } else if (this.conflictMap.getInverseRelationships(element.uuid).length > 0) { + this.conflictMap.removeFromMap(element.uuid) } this.referenceMap.setAllRelationships( @@ -203,6 +205,9 @@ export abstract class Collection< if (element.deleted) { this.nondeletedIndex.delete(element.uuid) + if (this.conflictMap.getInverseRelationships(element.uuid).length > 0) { + this.conflictMap.removeFromMap(element.uuid) + } } else { this.nondeletedIndex.add(element.uuid) } @@ -260,4 +265,8 @@ export abstract class Collection< remove(array, { uuid: element.uuid as never }) this.typedMap[element.content_type] = array } + + public numberOfItemsWithConflicts(): number { + return this.conflictMap.directMapSize + } } diff --git a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts index 2662b493b..0dac76acf 100644 --- a/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts +++ b/packages/models/src/Domain/Runtime/Collection/Item/TagItemsIndex.ts @@ -18,7 +18,7 @@ export class TagItemsIndex implements SNIndex { private isItemCountable = (item: ItemInterface) => { if (isDecryptedItem(item)) { - return !item.archived && !item.trashed + return !item.archived && !item.trashed && !item.conflictOf } return false } diff --git a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts index 5143b71b1..fee35643a 100644 --- a/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts +++ b/packages/models/src/Domain/Runtime/Display/DisplayOptionsToFilters.ts @@ -10,8 +10,9 @@ import { FilterDisplayOptions } from './DisplayOptions' export function computeUnifiedFilterForDisplayOptions( options: FilterDisplayOptions, collection: ReferenceLookupCollection, + additionalFilters: ItemFilter[] = [], ): ItemFilter { - const filters = computeFiltersForDisplayOptions(options, collection) + const filters = computeFiltersForDisplayOptions(options, collection).concat(additionalFilters) return (item: SearchableDecryptedItem) => { return itemPassesFilters(item, filters) @@ -74,5 +75,9 @@ export function computeFiltersForDisplayOptions( filters.push((item) => itemMatchesQuery(item, query, collection)) } + if (!viewsPredicate?.keypathIncludesString('conflict_of')) { + filters.push((item) => !item.conflictOf) + } + return filters } diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts index 48e338347..b1da2982c 100644 --- a/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts +++ b/packages/models/src/Domain/Syncable/SmartView/SmartViewBuilder.ts @@ -85,7 +85,19 @@ export function BuildSmartViews(options: FilterDisplayOptions): SmartView[] { }), ) - return [notes, files, starred, archived, trash, untagged] + const conflicts = new SmartView( + new DecryptedPayload({ + uuid: SystemViewId.Conflicts, + content_type: ContentType.SmartView, + ...PayloadTimestampDefaults(), + content: FillItemContent({ + title: 'Conflicts', + predicate: conflictsPredicate(options).toJson(), + }), + }), + ) + + return [notes, files, starred, archived, trash, untagged, conflicts] } function allNotesPredicate(options: FilterDisplayOptions) { @@ -203,3 +215,26 @@ function starredNotesPredicate(options: FilterDisplayOptions) { return predicate } + +function conflictsPredicate(options: FilterDisplayOptions) { + const subPredicates: Predicate[] = [new Predicate('content_type', '=', ContentType.Note)] + + if (options.includeTrashed === false) { + subPredicates.push(new Predicate('trashed', '=', false)) + } + + if (options.includeArchived === false) { + subPredicates.push(new Predicate('archived', '=', false)) + } + + if (options.includeProtected === false) { + subPredicates.push(new Predicate('protected', '=', false)) + } + + if (options.includePinned === false) { + subPredicates.push(new Predicate('pinned', '=', false)) + } + + const predicate = new CompoundPredicate('and', subPredicates) + return predicate +} diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartViewIcons.ts b/packages/models/src/Domain/Syncable/SmartView/SmartViewIcons.ts index 32a6b51cd..658013e9b 100644 --- a/packages/models/src/Domain/Syncable/SmartView/SmartViewIcons.ts +++ b/packages/models/src/Domain/Syncable/SmartView/SmartViewIcons.ts @@ -8,6 +8,7 @@ export const SmartViewIcons: Record = { [SystemViewId.TrashedNotes]: 'trash', [SystemViewId.UntaggedNotes]: 'hashtag-off', [SystemViewId.StarredNotes]: 'star-filled', + [SystemViewId.Conflicts]: 'merge', } export function systemViewIcon(id: SystemViewId): IconType { diff --git a/packages/models/src/Domain/Syncable/SmartView/SystemViewId.ts b/packages/models/src/Domain/Syncable/SmartView/SystemViewId.ts index 913e1a10e..0e304c33c 100644 --- a/packages/models/src/Domain/Syncable/SmartView/SystemViewId.ts +++ b/packages/models/src/Domain/Syncable/SmartView/SystemViewId.ts @@ -5,4 +5,5 @@ export enum SystemViewId { TrashedNotes = 'trashed-notes', UntaggedNotes = 'untagged-notes', StarredNotes = 'starred-notes', + Conflicts = 'conflicts', } diff --git a/packages/services/src/Domain/Item/ItemCounter.ts b/packages/services/src/Domain/Item/ItemCounter.ts index 879b93ccf..6b678cfae 100644 --- a/packages/services/src/Domain/Item/ItemCounter.ts +++ b/packages/services/src/Domain/Item/ItemCounter.ts @@ -23,7 +23,7 @@ export class ItemCounter implements ItemCounterInterface { continue } - if (item.content_type === ContentType.Note) { + if (item.content_type === ContentType.Note && !item.conflictOf) { counts.notes++ continue diff --git a/packages/services/src/Domain/Item/ItemsClientInterface.ts b/packages/services/src/Domain/Item/ItemsClientInterface.ts index 59368efcf..8c544c4b8 100644 --- a/packages/services/src/Domain/Item/ItemsClientInterface.ts +++ b/packages/services/src/Domain/Item/ItemsClientInterface.ts @@ -167,4 +167,6 @@ export interface ItemsClientInterface { predicate: PredicateInterface, iconString?: string, ): Promise + + numberOfNotesWithConflicts(): number } diff --git a/packages/snjs/lib/Services/Items/ItemManager.ts b/packages/snjs/lib/Services/Items/ItemManager.ts index ddb7fcc70..745fbe5c4 100644 --- a/packages/snjs/lib/Services/Items/ItemManager.ts +++ b/packages/snjs/lib/Services/Items/ItemManager.ts @@ -123,6 +123,7 @@ export class ItemManager public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void { const override: Models.FilterDisplayOptions = {} + const additionalFilters: Models.ItemFilter[] = [] if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) { if (options.includeArchived === undefined) { @@ -142,6 +143,9 @@ export class ItemManager override.includeArchived = true } } + if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.Conflicts)) { + additionalFilters.push((item) => this.collection.conflictsOf(item.uuid).length > 0) + } this.rebuildSystemSmartViews({ ...options, ...override }) @@ -174,7 +178,7 @@ export class ItemManager } this.navigationDisplayController.setDisplayOptions({ - customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection), + customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection, additionalFilters), ...updatedOptions, }) } @@ -1410,4 +1414,8 @@ export class ItemManager }, }) } + + numberOfNotesWithConflicts(): number { + return this.collection.numberOfItemsWithConflicts() + } } diff --git a/packages/styles/src/Styles/_scrollbar.scss b/packages/styles/src/Styles/_scrollbar.scss index 947709d27..1ae72596a 100644 --- a/packages/styles/src/Styles/_scrollbar.scss +++ b/packages/styles/src/Styles/_scrollbar.scss @@ -1,7 +1,8 @@ .windows-web, .windows-desktop, .linux-web, -.linux-desktop { +.linux-desktop, +.force-custom-scrollbar { $thumb-width: 4px; ::-webkit-scrollbar { diff --git a/packages/utils/src/Domain/Uuid/UuidMap.ts b/packages/utils/src/Domain/Uuid/UuidMap.ts index 917ae4eed..cd9d1cf23 100644 --- a/packages/utils/src/Domain/Uuid/UuidMap.ts +++ b/packages/utils/src/Domain/Uuid/UuidMap.ts @@ -10,6 +10,14 @@ export class UuidMap { /** uuid to uuids that have a relationship with us */ private inverseMap: Partial> = {} + public get directMapSize(): number { + return Object.keys(this.directMap).length + } + + public get inverseMapSize(): number { + return Object.keys(this.inverseMap).length + } + public makeCopy(): UuidMap { const copy = new UuidMap() copy.directMap = Object.assign({}, this.directMap) diff --git a/packages/web/package.json b/packages/web/package.json index b20354814..77abde4ec 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -107,8 +107,9 @@ "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" }, "dependencies": { - "@ariakit/react": "^0.2.1", + "@ariakit/react": "^0.2.8", "@lexical/headless": "0.11.0", - "@radix-ui/react-slot": "^1.0.1" + "@radix-ui/react-slot": "^1.0.1", + "fast-diff": "^1.3.0" } } diff --git a/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css b/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css index 702404e65..703ee6527 100644 --- a/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css +++ b/packages/web/src/components/assets/org.standardnotes.theme-focus/index.css @@ -19,7 +19,7 @@ --sn-stylekit-neutral-contrast-color: #ffffff; --sn-stylekit-success-color: #2b9612; --sn-stylekit-success-contrast-color: #ffffff; - --sn-stylekit-warning-color: #f6a200; + --sn-stylekit-warning-color: #cc8800; --sn-stylekit-warning-contrast-color: #ffffff; --sn-stylekit-danger-color: #f80324; --sn-stylekit-danger-contrast-color: #ffffff; @@ -27,7 +27,7 @@ --sn-stylekit-editor-foreground-color: var(--sn-stylekit-foreground-color); --sn-stylekit-background-color: var(--background-color); --sn-stylekit-foreground-color: var(--foreground-color); - --sn-stylekit-border-color: #000000; + --sn-stylekit-border-color: #181a1b; --sn-stylekit-contrast-background-color: #000000; --sn-stylekit-contrast-foreground-color: #ffffff; --sn-stylekit-contrast-border-color: #000000; diff --git a/packages/web/src/javascripts/Components/Button/Button.tsx b/packages/web/src/javascripts/Components/Button/Button.tsx index 303561a8b..0db52cecd 100644 --- a/packages/web/src/javascripts/Components/Button/Button.tsx +++ b/packages/web/src/javascripts/Components/Button/Button.tsx @@ -58,7 +58,7 @@ const getClassName = ( let colors = primary ? getColorsForPrimaryVariant(style) : getColorsForNormalVariant(style) let focusHoverStates = primary - ? 'hover:brightness-125 focus:outline-none focus:brightness-125' + ? 'hover:brightness-125 focus:outline-none focus-visible:brightness-125' : 'focus:bg-contrast focus:outline-none hover:border-info hover:text-info hover:bg-contrast' if (disabled) { @@ -68,7 +68,7 @@ const getClassName = ( : 'focus:bg-default focus:outline-none hover:bg-default' } - return `${rounded} font-bold ${width} ${padding} ${textSize} ${colors} ${borders} ${focusHoverStates} ${cursor}` + return `${rounded} font-bold select-none ${width} ${padding} ${textSize} ${colors} ${borders} ${focusHoverStates} ${cursor}` } interface ButtonProps extends ComponentPropsWithoutRef<'button'> { diff --git a/packages/web/src/javascripts/Components/Checkbox/CheckIndicator.tsx b/packages/web/src/javascripts/Components/Checkbox/CheckIndicator.tsx new file mode 100644 index 000000000..9365707d0 --- /dev/null +++ b/packages/web/src/javascripts/Components/Checkbox/CheckIndicator.tsx @@ -0,0 +1,25 @@ +import { classNames } from '@standardnotes/utils' +import Icon from '../Icon/Icon' +import { ComponentPropsWithoutRef } from 'react' + +const CheckIndicator = ({ checked, className, ...props }: { checked: boolean } & ComponentPropsWithoutRef<'div'>) => ( +
+ {checked && ( + + )} +
+) + +export default CheckIndicator diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 5c376a5a2..2cb9a0356 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -102,6 +102,7 @@ export const IconNameToSvgMapping = { listed: icons.ListedIcon, lock: icons.LockIcon, markdown: icons.MarkdownIcon, + merge: icons.MergeIcon, more: icons.MoreIcon, notes: icons.NotesIcon, paragraph: icons.TextParagraphLongIcon, @@ -126,6 +127,7 @@ export const IconNameToSvgMapping = { superscript: icons.SuperscriptIcon, sync: icons.SyncIcon, tasks: icons.TasksIcon, + text: icons.TextIcon, themes: icons.ThemesIcon, trash: icons.TrashIcon, tune: icons.TuneIcon, diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx index 3ce8b0faa..dc8e3aafb 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemBubble.tsx @@ -23,6 +23,7 @@ type Props = { isBidirectional: boolean inlineFlex?: boolean className?: string + readonly?: boolean } const LinkedItemBubble = ({ @@ -36,6 +37,7 @@ const LinkedItemBubble = ({ isBidirectional, inlineFlex, className, + readonly, }: Props) => { const ref = useRef(null) const application = useApplication() @@ -60,6 +62,9 @@ const LinkedItemBubble = ({ const onClick: MouseEventHandler = (event) => { if (wasClicked && event.target !== unlinkButtonRef.current) { setWasClicked(false) + if (readonly) { + return + } void activateItem?.(link.item) } else { setWasClicked(true) @@ -112,7 +117,7 @@ const LinkedItemBubble = ({ onKeyDown={onKeyDown} > - + {tagTitle && {tagTitle.titlePrefix}} {link.type === 'linked-by' && link.item.content_type !== ContentType.Tag && ( @@ -121,7 +126,7 @@ const LinkedItemBubble = ({ {getItemTitleInContextOfLinkBubble(link.item)} - {showUnlinkButton && ( + {showUnlinkButton && !readonly && ( { +const LinkedItemBubblesContainer = ({ + item, + linkingController, + hideToggle = false, + readonly = false, + className = {}, + isCollapsedByDefault = false, +}: Props) => { const { toggleAppPane } = useResponsiveAppPane() const commandService = useCommandService() @@ -106,55 +119,66 @@ const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = fals ) } - const [isCollapsed, setIsCollapsed] = useState(false) + const [isCollapsed, setIsCollapsed] = useState(() => isCollapsedByDefault) const itemsToDisplay = allItemsLinkedToItem.concat(notesLinkingToItem).concat(filesLinkingToItem) const visibleItems = isCollapsed ? itemsToDisplay.slice(0, 5) : itemsToDisplay const nonVisibleItems = itemsToDisplay.length - visibleItems.length - const [canShowContainerToggle, setCanShowContainerToggle] = useState(false) - const linkInputRef = useRef(null) - const linkContainerRef = useRef(null) + const [canShowContainerToggle, setCanShowContainerToggle] = useState(true) + const [linkContainer, setLinkContainer] = useState(null) useEffect(() => { - const container = linkContainerRef.current - const linkInput = linkInputRef.current - - if (!container || !linkInput) { + const container = linkContainer + if (!container) { return } const resizeObserver = new ResizeObserver(() => { - if (container.clientHeight > linkInput.clientHeight) { + const firstChild = container.firstElementChild + if (!firstChild) { + return + } + + const threshold = firstChild.clientHeight + 4 + const didWrap = container.clientHeight > threshold + + if (didWrap) { setCanShowContainerToggle(true) } else { setCanShowContainerToggle(false) } }) - resizeObserver.observe(linkContainerRef.current) + resizeObserver.observe(container) return () => { resizeObserver.disconnect() } - }, []) + }, [linkContainer]) const shouldHideToggle = hideToggle || (!canShowContainerToggle && !isCollapsed) + if (readonly && itemsToDisplay.length === 0) { + return null + } + return (
0 && !shouldHideToggle && 'pt-2', + 'flex w-full flex-wrap justify-between md:flex-nowrap', + itemsToDisplay.length > 0 && !shouldHideToggle ? 'pt-2 ' + className.withToggle : undefined, isCollapsed ? 'gap-4' : 'gap-1', + className.base, )} >
{visibleItems.map((link) => ( ))} {isCollapsed && nonVisibleItems > 0 && and {nonVisibleItems} more...} - + {!readonly && ( + + )}
{itemsToDisplay.length > 0 && !shouldHideToggle && ( + title: string + note: SNNote +}) => { + const application = useApplication() + const { words, characters, paragraphs, dateLastModified, dateCreated, format } = useNoteAttributes(application, note) + + return ( + + ) +} diff --git a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx new file mode 100644 index 000000000..c2f3228e3 --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/DiffView.tsx @@ -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] }) => ( + + {text} + +) + +export const DiffView = ({ + selectedNotes, + convertSuperToMarkdown, +}: { + selectedNotes: SNNote[] + convertSuperToMarkdown: boolean +}) => { + const [titleDiff, setTitleDiff] = useState([]) + const [textDiff, setTextDiff] = useState([]) + + 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(null) + const [diffVisualizer, setDiffVisualizer] = useState(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 ( +
+
+ {titleDiff.map(([state, text], index) => ( + + ))} +
+
+        {textDiff.map(([state, text], index) => (
+          
+        ))}
+      
+ {hasOverflow && ( +
+ )} +
+ ) +} diff --git a/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx new file mode 100644 index 000000000..a1c9d5bda --- /dev/null +++ b/packages/web/src/javascripts/Components/NoteView/NoteConflictResolutionModal/NoteConflictResolutionModal.tsx @@ -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('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(null) + useListKeyboardNavigation(listRef) + + const [selectedMobileTab, setSelectedMobileTab] = useState<'list' | 'preview'>('list') + + const toolbarStore = useToolbarStore() + const isSelectOpen = selectStore.useState('open') + const [selectAnchor, setSelectAnchor] = useState(null) + + const [multipleSelectionMode, setMultipleSelectionMode] = useState( + 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 ( + 1 ? 'hidden md:flex' : ''}> + + + + {isPerformingAction ? ( + <> + + + ) : ( + <>Keep selected, {selectedAction === 'move-to-trash' ? 'trash others' : 'delete others'} + )} + +