feat: Added a conflict resolution dialog and a Conflicts view for easier management of conflicts (#2337)

This commit is contained in:
Aman Harwara
2023-06-25 14:27:51 +05:30
committed by GitHub
parent 49d43fd14b
commit e0e9249334
48 changed files with 1201 additions and 94 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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,

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<SmartViewContent>({
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<SNNote>[] = [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
}

View File

@@ -8,6 +8,7 @@ export const SmartViewIcons: Record<SystemViewId, IconType> = {
[SystemViewId.TrashedNotes]: 'trash',
[SystemViewId.UntaggedNotes]: 'hashtag-off',
[SystemViewId.StarredNotes]: 'star-filled',
[SystemViewId.Conflicts]: 'merge',
}
export function systemViewIcon(id: SystemViewId): IconType {

View File

@@ -5,4 +5,5 @@ export enum SystemViewId {
TrashedNotes = 'trashed-notes',
UntaggedNotes = 'untagged-notes',
StarredNotes = 'starred-notes',
Conflicts = 'conflicts',
}

View File

@@ -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

View File

@@ -167,4 +167,6 @@ export interface ItemsClientInterface {
predicate: PredicateInterface<T>,
iconString?: string,
): Promise<SmartView>
numberOfNotesWithConflicts(): number
}

View File

@@ -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()
}
}

View File

@@ -1,7 +1,8 @@
.windows-web,
.windows-desktop,
.linux-web,
.linux-desktop {
.linux-desktop,
.force-custom-scrollbar {
$thumb-width: 4px;
::-webkit-scrollbar {

View File

@@ -10,6 +10,14 @@ export class UuidMap {
/** uuid to uuids that have a relationship with us */
private inverseMap: Partial<Record<string, string[]>> = {}
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)

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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'> {

View File

@@ -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'>) => (
<div
className={classNames(
'relative h-5 w-5 rounded border-2 md:h-4 md:w-4',
checked ? 'border-info bg-info' : 'border-passive-1',
className,
)}
role="presentation"
{...props}
>
{checked && (
<Icon
type="check"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-info-contrast"
size="small"
/>
)}
</div>
)
export default CheckIndicator

View File

@@ -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,

View File

@@ -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<HTMLButtonElement>(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}
>
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" />
<span className="max-w-290px flex items-center overflow-hidden overflow-ellipsis whitespace-nowrap">
<span className="flex items-center overflow-hidden overflow-ellipsis whitespace-nowrap">
{tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
<span className="flex items-center gap-1">
{link.type === 'linked-by' && link.item.content_type !== ContentType.Tag && (
@@ -121,7 +126,7 @@ const LinkedItemBubble = ({
{getItemTitleInContextOfLinkBubble(link.item)}
</span>
</span>
{showUnlinkButton && (
{showUnlinkButton && !readonly && (
<a
ref={unlinkButtonRef}
role="button"

View File

@@ -2,7 +2,7 @@ import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@standardnotes/utils'
@@ -18,9 +18,22 @@ type Props = {
linkingController: LinkingController
item: DecryptedItemInterface
hideToggle?: boolean
readonly?: boolean
className?: {
base?: string
withToggle?: string
}
isCollapsedByDefault?: boolean
}
const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = false }: Props) => {
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<HTMLInputElement>(null)
const linkContainerRef = useRef<HTMLDivElement>(null)
const [canShowContainerToggle, setCanShowContainerToggle] = useState(true)
const [linkContainer, setLinkContainer] = useState<HTMLDivElement | null>(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 (
<div
className={classNames(
'flex w-full justify-between',
itemsToDisplay.length > 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,
)}
>
<div
className={classNames(
'note-view-linking-container flex min-w-80 max-w-full items-center gap-2 bg-transparent md:-mr-2',
'note-view-linking-container flex min-w-80 max-w-full items-center gap-2 bg-transparent',
allItemsLinkedToItem.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
isCollapsed ? 'overflow-hidden' : 'flex-wrap',
!shouldHideToggle && 'mr-2',
)}
ref={linkContainerRef}
ref={setLinkContainer}
>
{visibleItems.map((link) => (
<LinkedItemBubble
@@ -167,18 +191,20 @@ const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = fals
focusedId={focusedId}
setFocusedId={setFocusedId}
isBidirectional={isItemBidirectionallyLinked(link)}
readonly={readonly}
/>
))}
{isCollapsed && nonVisibleItems > 0 && <span className="flex-shrink-0">and {nonVisibleItems} more...</span>}
<ItemLinkAutocompleteInput
focusedId={focusedId}
linkingController={linkingController}
focusPreviousItem={focusPreviousItem}
setFocusedId={setFocusedId}
hoverLabel={`Focus input to add a link (${shortcut})`}
item={item}
ref={linkInputRef}
/>
{!readonly && (
<ItemLinkAutocompleteInput
focusedId={focusedId}
linkingController={linkingController}
focusPreviousItem={focusPreviousItem}
setFocusedId={setFocusedId}
hoverLabel={`Focus input to add a link (${shortcut})`}
item={item}
/>
)}
</div>
{itemsToDisplay.length > 0 && !shouldHideToggle && (
<RoundIconButton

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -1,13 +1,10 @@
import { useMemo, FunctionComponent } from 'react'
import { SNApplication, SNNote } from '@standardnotes/snjs'
import { SNApplication, SNNote, classNames } from '@standardnotes/snjs'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { calculateReadTime } from './Utils/calculateReadTime'
import { countNoteAttributes } from './Utils/countNoteAttributes'
export const NoteAttributes: FunctionComponent<{
application: SNApplication
note: SNNote
}> = ({ application, note }) => {
export const useNoteAttributes = (application: SNApplication, note: SNNote) => {
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
const readTime = useMemo(() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words])
@@ -19,8 +16,29 @@ export const NoteAttributes: FunctionComponent<{
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return {
words,
characters,
paragraphs,
readTime,
dateLastModified,
dateCreated,
format,
}
}
export const NoteAttributes: FunctionComponent<{
application: SNApplication
note: SNNote
className?: string
}> = ({ application, note, className }) => {
const { words, characters, paragraphs, readTime, dateLastModified, dateCreated, format } = useNoteAttributes(
application,
note,
)
return (
<div className="select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs">
<div className={classNames('select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs', className)}>
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
<>
<div className="mb-1">

View File

@@ -7,13 +7,18 @@ import { getAppRect, getPopoverMaxHeight, getPositionedPopoverRect } from './Uti
const percentOf = (percent: number, value: number) => (percent / 100) * value
export type PopoverCSSProperties = CSSProperties & {
'--translate-x': string
'--translate-y': string
}
const getStylesFromRect = (
rect: DOMRect,
options: {
disableMobileFullscreenTakeover?: boolean
maxHeight?: number | 'none'
},
): CSSProperties => {
): PopoverCSSProperties => {
const { disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
@@ -22,7 +27,9 @@ const getStylesFromRect = (
return {
willChange: 'transform',
transform: `translate(${shouldApplyMobileWidth ? marginForMobile / 2 : rect.x}px, ${rect.y}px)`,
'--translate-x': `${shouldApplyMobileWidth ? marginForMobile / 2 : rect.x}px`,
'--translate-y': `${rect.y}px`,
transform: 'translate(var(--translate-x), var(--translate-y))',
visibility: 'visible',
...(canApplyMaxHeight && {
maxHeight: `${maxHeight}px`,
@@ -53,7 +60,7 @@ export const getPositionedPopoverStyles = ({
disableMobileFullscreenTakeover,
maxHeightFunction,
offset,
}: Options): CSSProperties | null => {
}: Options): PopoverCSSProperties | null => {
if (!popoverRect || !anchorRect) {
return null
}

View File

@@ -46,6 +46,8 @@ const Popover = ({
disableMobileFullscreenTakeover,
maxHeight,
portal,
offset,
hideOnClickInModal,
}: Props) => {
const popoverId = useRef(UuidGenerator.GenerateUuid())
@@ -126,6 +128,8 @@ const Popover = ({
title={title}
togglePopover={togglePopover}
portal={portal}
offset={offset}
hideOnClickInModal={hideOnClickInModal}
>
{children}
</PositionedPopoverContent>

View File

@@ -1,14 +1,15 @@
import { useDocumentRect } from '@/Hooks/useDocumentRect'
import { useAutoElementRect } from '@/Hooks/useElementRect'
import { classNames } from '@standardnotes/utils'
import { useCallback, useLayoutEffect, useState } from 'react'
import { CSSProperties, useCallback, useLayoutEffect, useState } from 'react'
import Portal from '../Portal/Portal'
import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
import { PopoverCSSProperties, getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
import { PopoverContentProps } from './Types'
import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { KeyboardKey } from '@standardnotes/ui-services'
import { getAdjustedStylesForNonPortalPopover } from './Utils/getAdjustedStylesForNonPortal'
const PositionedPopoverContent = ({
align = 'end',
@@ -25,6 +26,8 @@ const PositionedPopoverContent = ({
disableMobileFullscreenTakeover,
maxHeight,
portal = true,
offset,
hideOnClickInModal = false,
}: PopoverContentProps) => {
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
const popoverRect = useAutoElementRect(popoverElement)
@@ -47,13 +50,21 @@ const PositionedPopoverContent = ({
side,
disableMobileFullscreenTakeover: disableMobileFullscreenTakeover,
maxHeightFunction: maxHeight,
offset,
})
let adjustedStyles: PopoverCSSProperties | undefined = undefined
if (!portal && popoverElement && styles) {
adjustedStyles = getAdjustedStylesForNonPortalPopover(popoverElement, styles)
}
usePopoverCloseOnClickOutside({
popoverElement,
anchorElement,
togglePopover,
childPopovers,
hideOnClickInModal,
disabled: disableClickOutside,
})
@@ -83,9 +94,12 @@ const PositionedPopoverContent = ({
isDesktopScreen || disableMobileFullscreenTakeover ? 'invisible' : '',
className,
)}
style={{
...styles,
}}
style={
{
...styles,
...adjustedStyles,
} as CSSProperties
}
ref={setPopoverElement}
data-popover={id}
onKeyDown={(event) => {

View File

@@ -42,6 +42,8 @@ type CommonPopoverProps = {
disableMobileFullscreenTakeover?: boolean
title: string
portal?: boolean
offset?: number
hideOnClickInModal?: boolean
}
export type PopoverContentProps = CommonPopoverProps & {

View File

@@ -95,25 +95,25 @@ export const getPositionedPopoverRect = (
if (side === 'top' || side === 'bottom') {
switch (align) {
case 'start':
positionPopoverRect.x = buttonRect.left - finalOffset
positionPopoverRect.x = buttonRect.left
break
case 'center':
positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2 - finalOffset
positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2
break
case 'end':
positionPopoverRect.x = buttonRect.right - width + finalOffset
positionPopoverRect.x = buttonRect.right - width
break
}
} else {
switch (align) {
case 'start':
positionPopoverRect.y = buttonRect.top - finalOffset
positionPopoverRect.y = buttonRect.top
break
case 'center':
positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2 - finalOffset
positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2
break
case 'end':
positionPopoverRect.y = buttonRect.bottom - height + finalOffset
positionPopoverRect.y = buttonRect.bottom - height
break
}
}

View File

@@ -0,0 +1,19 @@
export function getAbsolutePositionedParent(element: HTMLElement | null): HTMLElement | null {
if (!element) {
return null
}
const parent = element.parentElement
if (!parent) {
return null
}
const position = window.getComputedStyle(parent).getPropertyValue('position')
if (position === 'absolute') {
return parent
}
return getAbsolutePositionedParent(parent)
}

View File

@@ -0,0 +1,26 @@
import { PopoverCSSProperties } from '../GetPositionedPopoverStyles'
import { getAbsolutePositionedParent } from './getAbsolutePositionedParent'
export const getAdjustedStylesForNonPortalPopover = (popoverElement: HTMLElement, styles: PopoverCSSProperties) => {
const absoluteParent = getAbsolutePositionedParent(popoverElement)
const translateXProperty = styles?.['--translate-x']
const translateYProperty = styles?.['--translate-y']
const parsedTranslateX = translateXProperty ? parseInt(translateXProperty) : 0
const parsedTranslateY = translateYProperty ? parseInt(translateYProperty) : 0
if (!absoluteParent) {
return styles
}
const parentRect = absoluteParent.getBoundingClientRect()
const adjustedTranslateX = parsedTranslateX - parentRect.left
const adjustedTranslateY = parsedTranslateY - parentRect.top
return {
...styles,
'--translate-x': `${adjustedTranslateX}px`,
'--translate-y': `${adjustedTranslateY}px`,
} as PopoverCSSProperties
}

View File

@@ -6,6 +6,7 @@ type Options = {
togglePopover?: () => void
childPopovers: Set<string>
disabled?: boolean
hideOnClickInModal?: boolean
}
export const usePopoverCloseOnClickOutside = ({
@@ -14,6 +15,7 @@ export const usePopoverCloseOnClickOutside = ({
togglePopover,
childPopovers,
disabled,
hideOnClickInModal = false,
}: Options) => {
useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => {
@@ -26,7 +28,12 @@ export const usePopoverCloseOnClickOutside = ({
const isPopoverInModal = popoverElement?.closest('[data-dialog]')
const isDescendantOfModal = isPopoverInModal ? false : !!target.closest('[data-dialog]')
if (!isDescendantOfMenu && !isAnchorElement && !isDescendantOfChildPopover && !isDescendantOfModal) {
if (
!isDescendantOfMenu &&
!isAnchorElement &&
!isDescendantOfChildPopover &&
(!isDescendantOfModal || (isDescendantOfModal && hideOnClickInModal))
) {
if (!disabled) {
togglePopover?.()
}
@@ -39,5 +46,5 @@ export const usePopoverCloseOnClickOutside = ({
document.removeEventListener('click', closeIfClickedOutside, { capture: true })
document.removeEventListener('contextmenu', closeIfClickedOutside, { capture: true })
}
}, [anchorElement, childPopovers, popoverElement, togglePopover, disabled])
}, [anchorElement, childPopovers, popoverElement, togglePopover, disabled, hideOnClickInModal])
}

View File

@@ -28,7 +28,7 @@ const RevisionContentLocked: FunctionComponent<Props> = ({ subscriptionControlle
return (
<div className="flex h-full w-full items-center justify-center">
<div className="max-w-40% flex flex-col items-center text-center">
<div className="max-w-40% flex flex-col items-center px-8 text-center">
<HistoryLockedIllustration />
<div className="mt-2 mb-1 text-lg font-bold">Can't access this version</div>
<div className="leading-140% mb-4 text-passive-0">

View File

@@ -1,46 +1,61 @@
import { classNames } from '@standardnotes/snjs'
import { ReactNode } from 'react'
import { ReactNode, useState } from 'react'
import { Tooltip, TooltipAnchor, TooltipOptions, useTooltipStore } from '@ariakit/react'
import { Slot } from '@radix-ui/react-slot'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal'
const StyledTooltip = ({
children,
className,
label,
showOnMobile = false,
showOnHover = true,
...props
}: {
children: ReactNode
label: NonNullable<ReactNode>
className?: string
label: string
showOnMobile?: boolean
showOnHover?: boolean
} & Partial<TooltipOptions>) => {
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
const tooltip = useTooltipStore({
timeout: 350,
open: forceOpen,
})
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
if (isMobile) {
if (isMobile && !showOnMobile) {
return <>{children}</>
}
return (
<>
<TooltipAnchor store={tooltip} as={Slot}>
<TooltipAnchor
onFocus={() => setForceOpen(true)}
onBlur={() => setForceOpen(undefined)}
store={tooltip}
as={Slot}
showOnHover={showOnHover}
>
{children}
</TooltipAnchor>
<Tooltip
autoFocusOnShow={!showOnHover}
store={tooltip}
className={classNames(
'z-tooltip max-w-max rounded border border-border bg-contrast py-1.5 px-3 text-sm text-foreground shadow',
className,
)}
updatePosition={() => {
const { popoverElement, anchorElement } = tooltip.getState()
const { popoverElement, anchorElement, open } = tooltip.getState()
const documentElement = document.querySelector('.main-ui-view')
if (!popoverElement || !anchorElement || !documentElement) {
if (!popoverElement || !anchorElement || !documentElement || !open) {
return
}
@@ -55,10 +70,20 @@ const StyledTooltip = ({
popoverRect,
documentRect,
disableMobileFullscreenTakeover: true,
offset: 6,
offset: props.gutter ? props.gutter : 6,
})
if (!styles) {
return
}
Object.assign(popoverElement.style, styles)
if (!props.portal) {
const adjustedStyles = getAdjustedStylesForNonPortalPopover(popoverElement, styles)
popoverElement.style.setProperty('--translate-x', adjustedStyles['--translate-x'])
popoverElement.style.setProperty('--translate-y', adjustedStyles['--translate-y'])
}
}}
{...props}
>

View File

@@ -1,4 +1,4 @@
import { FunctionComponent, useCallback, useState } from 'react'
import { FunctionComponent, UIEventHandler, useCallback, useState } from 'react'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
@@ -35,6 +35,7 @@ type BlocksEditorProps = {
spellcheck?: boolean
ignoreFirstChange?: boolean
readonly?: boolean
onScroll?: UIEventHandler
}
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
@@ -45,6 +46,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
spellcheck,
ignoreFirstChange = false,
readonly,
onScroll,
}) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback(
@@ -79,6 +81,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
id={SuperEditorContentId}
className={classNames('ContentEditable__root overflow-y-auto', className)}
spellCheck={spellcheck}
onScroll={onScroll}
/>
<div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" />
</div>

View File

@@ -1,7 +1,7 @@
import Icon from '@/Components/Icon/Icon'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { SmartView, SystemViewId, isSystemView } from '@standardnotes/snjs'
import { ContentType, SmartView, SystemViewId, isSystemView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import {
FormEventHandler,
@@ -14,6 +14,7 @@ import {
} from 'react'
import { classNames } from '@standardnotes/utils'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { useApplication } from '../ApplicationProvider'
type Props = {
view: SmartView
@@ -34,6 +35,8 @@ const getIconClass = (view: SmartView, isSelected: boolean): string => {
}
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEditingSmartView }) => {
const application = useApplication()
const [title, setTitle] = useState(view.title || '')
const inputRef = useRef<HTMLInputElement>(null)
@@ -91,6 +94,22 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEdit
const isFaded = false
const iconClass = getIconClass(view, isSelected)
const [conflictsCount, setConflictsCount] = useState(0)
useEffect(() => {
if (view.uuid !== SystemViewId.Conflicts) {
return
}
return application.streamItems(ContentType.Note, () => {
setConflictsCount(application.items.numberOfNotesWithConflicts())
})
}, [application, view])
if (view.uuid === SystemViewId.Conflicts && !conflictsCount) {
return null
}
return (
<>
<div
@@ -138,6 +157,7 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEdit
<div className={'count text-base lg:text-sm'}>
{view.uuid === SystemViewId.AllNotes && tagsState.allNotesCount}
{view.uuid === SystemViewId.Files && tagsState.allFilesCount}
{view.uuid === SystemViewId.Conflicts && conflictsCount}
</div>
</div>

View File

@@ -109,6 +109,12 @@ export const useListKeyboardNavigation = (
}
}, [setInitialFocus, shouldAutoFocus])
useEffect(() => {
if (listItems.current.length > 0) {
listItems.current[0].tabIndex = 0
}
}, [])
useEffect(() => {
const containerElement = container.current
containerElement?.addEventListener('keydown', keyDownHandler)

View File

@@ -85,12 +85,8 @@ input:focus {
box-shadow: 0 0 0 1px var(--sn-stylekit-info-color);
}
.sk-button:focus,
button:focus {
box-shadow: 0 0 0 2px var(--sn-stylekit-background-color), 0 0 0 4px var(--sn-stylekit-info-color);
}
.sk-button:focus-visible,
button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--sn-stylekit-background-color), 0 0 0 4px var(--sn-stylekit-info-color);
}

View File

@@ -59,6 +59,7 @@ module.exports = {
minHeight: {
1: '0.25rem',
2: '0.5rem',
11: '2.75rem',
},
maxHeight: {
110: '27.5rem',

View File

@@ -22,36 +22,36 @@ __metadata:
languageName: node
linkType: hard
"@ariakit/core@npm:0.2.0":
version: 0.2.0
resolution: "@ariakit/core@npm:0.2.0"
checksum: f95e3db9ebb3a1b34ed8127f279c8d1a820ad04d42e59cb74b210df1bfb82bc7b5a3a2e965aeb089413015f2c7c573729230eb2e30494669c0a478a721aada3b
"@ariakit/core@npm:0.2.4":
version: 0.2.4
resolution: "@ariakit/core@npm:0.2.4"
checksum: be795380e7c6379af66f4700fc8333c0253a24b4009f714070f20e49d355ea7ee9a7caa84bdfca65ffd4796e950607a2d57c9d75e4dfa69e8076ad7791917684
languageName: node
linkType: hard
"@ariakit/react-core@npm:0.2.1":
version: 0.2.1
resolution: "@ariakit/react-core@npm:0.2.1"
"@ariakit/react-core@npm:0.2.8":
version: 0.2.8
resolution: "@ariakit/react-core@npm:0.2.8"
dependencies:
"@ariakit/core": 0.2.0
"@ariakit/core": 0.2.4
"@floating-ui/dom": ^1.0.0
use-sync-external-store: ^1.2.0
peerDependencies:
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
checksum: c10ba749afd48f16372bbed161587043dd1f97641b782f159031d4dafce71119b555403f99d71584e121b77b83d1f323578867119b08d779c49c34a22bab92f6
checksum: aeb94f4cb837fa534366f9a36b4fb430b4c46a54e256dbc11f25371ab618131d38877e16886094ecf91d734564cde53f69deb0649d1f413d16e639b55ca4cb8d
languageName: node
linkType: hard
"@ariakit/react@npm:^0.2.1":
version: 0.2.1
resolution: "@ariakit/react@npm:0.2.1"
"@ariakit/react@npm:^0.2.8":
version: 0.2.8
resolution: "@ariakit/react@npm:0.2.8"
dependencies:
"@ariakit/react-core": 0.2.1
"@ariakit/react-core": 0.2.8
peerDependencies:
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
checksum: 55de2d395fa6a2ae3ef3b383f7559af155e177b9065fe07b8baf8984689bc2ad0bb8531aac1c97ad2ca388c5cf3199b3407ffe3f292533d076bfd0613405d3d3
checksum: 8d974628e8a7ef34bb079d3fb94a5ece3bcf8d2e3bbcbd1f7f597f2599fa35a9a22b8a5acb3dd2863bd5ab590577f58a8be619b14889a5a28bd0bf0999c73dad
languageName: node
linkType: hard
@@ -4760,7 +4760,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@standardnotes/web@workspace:packages/web"
dependencies:
"@ariakit/react": ^0.2.1
"@ariakit/react": ^0.2.8
"@babel/core": "*"
"@babel/plugin-proposal-class-properties": ^7.18.6
"@babel/plugin-transform-react-jsx": ^7.19.0
@@ -4812,6 +4812,7 @@ __metadata:
eslint-config-prettier: ^8.5.0
eslint-plugin-react: ^7.31.11
eslint-plugin-react-hooks: ^4.6.0
fast-diff: ^1.3.0
identity-obj-proxy: ^3.0.0
jest: ^29.3.1
jest-environment-jsdom: ^29.3.1
@@ -11034,6 +11035,13 @@ __metadata:
languageName: node
linkType: hard
"fast-diff@npm:^1.3.0":
version: 1.3.0
resolution: "fast-diff@npm:1.3.0"
checksum: d22d371b994fdc8cce9ff510d7b8dc4da70ac327bcba20df607dd5b9cae9f908f4d1028f5fe467650f058d1e7270235ae0b8230809a262b4df587a3b3aa216c3
languageName: node
linkType: hard
"fast-glob@npm:^2.2.6":
version: 2.2.7
resolution: "fast-glob@npm:2.2.7"