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 SuperscriptIcon from './ic-superscript.svg'
import SyncIcon from './ic-sync.svg' import SyncIcon from './ic-sync.svg'
import TasksIcon from './ic-tasks.svg' import TasksIcon from './ic-tasks.svg'
import TextIcon from './ic-text.svg'
import TextCircleIcon from './ic-text-circle.svg' import TextCircleIcon from './ic-text-circle.svg'
import TextParagraphLongIcon from './ic-text-paragraph-long.svg' import TextParagraphLongIcon from './ic-text-paragraph-long.svg'
import ThemesFilledIcon from './ic-themes-filled.svg' import ThemesFilledIcon from './ic-themes-filled.svg'
@@ -392,6 +393,7 @@ export {
SuperscriptIcon, SuperscriptIcon,
SyncIcon, SyncIcon,
TasksIcon, TasksIcon,
TextIcon,
TextCircleIcon, TextCircleIcon,
TextParagraphLongIcon, TextParagraphLongIcon,
ThemesFilledIcon, ThemesFilledIcon,

View File

@@ -187,6 +187,8 @@ export abstract class Collection<
const conflictOf = element.content.conflict_of const conflictOf = element.content.conflict_of
if (conflictOf) { if (conflictOf) {
this.conflictMap.establishRelationship(conflictOf, element.uuid) this.conflictMap.establishRelationship(conflictOf, element.uuid)
} else if (this.conflictMap.getInverseRelationships(element.uuid).length > 0) {
this.conflictMap.removeFromMap(element.uuid)
} }
this.referenceMap.setAllRelationships( this.referenceMap.setAllRelationships(
@@ -203,6 +205,9 @@ export abstract class Collection<
if (element.deleted) { if (element.deleted) {
this.nondeletedIndex.delete(element.uuid) this.nondeletedIndex.delete(element.uuid)
if (this.conflictMap.getInverseRelationships(element.uuid).length > 0) {
this.conflictMap.removeFromMap(element.uuid)
}
} else { } else {
this.nondeletedIndex.add(element.uuid) this.nondeletedIndex.add(element.uuid)
} }
@@ -260,4 +265,8 @@ export abstract class Collection<
remove(array, { uuid: element.uuid as never }) remove(array, { uuid: element.uuid as never })
this.typedMap[element.content_type] = array 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) => { private isItemCountable = (item: ItemInterface) => {
if (isDecryptedItem(item)) { if (isDecryptedItem(item)) {
return !item.archived && !item.trashed return !item.archived && !item.trashed && !item.conflictOf
} }
return false return false
} }

View File

@@ -10,8 +10,9 @@ import { FilterDisplayOptions } from './DisplayOptions'
export function computeUnifiedFilterForDisplayOptions( export function computeUnifiedFilterForDisplayOptions(
options: FilterDisplayOptions, options: FilterDisplayOptions,
collection: ReferenceLookupCollection, collection: ReferenceLookupCollection,
additionalFilters: ItemFilter[] = [],
): ItemFilter { ): ItemFilter {
const filters = computeFiltersForDisplayOptions(options, collection) const filters = computeFiltersForDisplayOptions(options, collection).concat(additionalFilters)
return (item: SearchableDecryptedItem) => { return (item: SearchableDecryptedItem) => {
return itemPassesFilters(item, filters) return itemPassesFilters(item, filters)
@@ -74,5 +75,9 @@ export function computeFiltersForDisplayOptions(
filters.push((item) => itemMatchesQuery(item, query, collection)) filters.push((item) => itemMatchesQuery(item, query, collection))
} }
if (!viewsPredicate?.keypathIncludesString('conflict_of')) {
filters.push((item) => !item.conflictOf)
}
return filters 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) { function allNotesPredicate(options: FilterDisplayOptions) {
@@ -203,3 +215,26 @@ function starredNotesPredicate(options: FilterDisplayOptions) {
return predicate 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.TrashedNotes]: 'trash',
[SystemViewId.UntaggedNotes]: 'hashtag-off', [SystemViewId.UntaggedNotes]: 'hashtag-off',
[SystemViewId.StarredNotes]: 'star-filled', [SystemViewId.StarredNotes]: 'star-filled',
[SystemViewId.Conflicts]: 'merge',
} }
export function systemViewIcon(id: SystemViewId): IconType { export function systemViewIcon(id: SystemViewId): IconType {

View File

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

View File

@@ -23,7 +23,7 @@ export class ItemCounter implements ItemCounterInterface {
continue continue
} }
if (item.content_type === ContentType.Note) { if (item.content_type === ContentType.Note && !item.conflictOf) {
counts.notes++ counts.notes++
continue continue

View File

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

View File

@@ -123,6 +123,7 @@ export class ItemManager
public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void { public setPrimaryItemDisplayOptions(options: Models.DisplayOptions): void {
const override: Models.FilterDisplayOptions = {} const override: Models.FilterDisplayOptions = {}
const additionalFilters: Models.ItemFilter[] = []
if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) { if (options.views && options.views.find((view) => view.uuid === Models.SystemViewId.AllNotes)) {
if (options.includeArchived === undefined) { if (options.includeArchived === undefined) {
@@ -142,6 +143,9 @@ export class ItemManager
override.includeArchived = true 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 }) this.rebuildSystemSmartViews({ ...options, ...override })
@@ -174,7 +178,7 @@ export class ItemManager
} }
this.navigationDisplayController.setDisplayOptions({ this.navigationDisplayController.setDisplayOptions({
customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection), customFilter: Models.computeUnifiedFilterForDisplayOptions(updatedOptions, this.collection, additionalFilters),
...updatedOptions, ...updatedOptions,
}) })
} }
@@ -1410,4 +1414,8 @@ export class ItemManager
}, },
}) })
} }
numberOfNotesWithConflicts(): number {
return this.collection.numberOfItemsWithConflicts()
}
} }

View File

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

View File

@@ -10,6 +10,14 @@ export class UuidMap {
/** uuid to uuids that have a relationship with us */ /** uuid to uuids that have a relationship with us */
private inverseMap: Partial<Record<string, string[]>> = {} 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 { public makeCopy(): UuidMap {
const copy = new UuidMap() const copy = new UuidMap()
copy.directMap = Object.assign({}, this.directMap) copy.directMap = Object.assign({}, this.directMap)

View File

@@ -107,8 +107,9 @@
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
}, },
"dependencies": { "dependencies": {
"@ariakit/react": "^0.2.1", "@ariakit/react": "^0.2.8",
"@lexical/headless": "0.11.0", "@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-neutral-contrast-color: #ffffff;
--sn-stylekit-success-color: #2b9612; --sn-stylekit-success-color: #2b9612;
--sn-stylekit-success-contrast-color: #ffffff; --sn-stylekit-success-contrast-color: #ffffff;
--sn-stylekit-warning-color: #f6a200; --sn-stylekit-warning-color: #cc8800;
--sn-stylekit-warning-contrast-color: #ffffff; --sn-stylekit-warning-contrast-color: #ffffff;
--sn-stylekit-danger-color: #f80324; --sn-stylekit-danger-color: #f80324;
--sn-stylekit-danger-contrast-color: #ffffff; --sn-stylekit-danger-contrast-color: #ffffff;
@@ -27,7 +27,7 @@
--sn-stylekit-editor-foreground-color: var(--sn-stylekit-foreground-color); --sn-stylekit-editor-foreground-color: var(--sn-stylekit-foreground-color);
--sn-stylekit-background-color: var(--background-color); --sn-stylekit-background-color: var(--background-color);
--sn-stylekit-foreground-color: var(--foreground-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-background-color: #000000;
--sn-stylekit-contrast-foreground-color: #ffffff; --sn-stylekit-contrast-foreground-color: #ffffff;
--sn-stylekit-contrast-border-color: #000000; --sn-stylekit-contrast-border-color: #000000;

View File

@@ -58,7 +58,7 @@ const getClassName = (
let colors = primary ? getColorsForPrimaryVariant(style) : getColorsForNormalVariant(style) let colors = primary ? getColorsForPrimaryVariant(style) : getColorsForNormalVariant(style)
let focusHoverStates = primary 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' : 'focus:bg-contrast focus:outline-none hover:border-info hover:text-info hover:bg-contrast'
if (disabled) { if (disabled) {
@@ -68,7 +68,7 @@ const getClassName = (
: 'focus:bg-default focus:outline-none hover:bg-default' : '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'> { 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, listed: icons.ListedIcon,
lock: icons.LockIcon, lock: icons.LockIcon,
markdown: icons.MarkdownIcon, markdown: icons.MarkdownIcon,
merge: icons.MergeIcon,
more: icons.MoreIcon, more: icons.MoreIcon,
notes: icons.NotesIcon, notes: icons.NotesIcon,
paragraph: icons.TextParagraphLongIcon, paragraph: icons.TextParagraphLongIcon,
@@ -126,6 +127,7 @@ export const IconNameToSvgMapping = {
superscript: icons.SuperscriptIcon, superscript: icons.SuperscriptIcon,
sync: icons.SyncIcon, sync: icons.SyncIcon,
tasks: icons.TasksIcon, tasks: icons.TasksIcon,
text: icons.TextIcon,
themes: icons.ThemesIcon, themes: icons.ThemesIcon,
trash: icons.TrashIcon, trash: icons.TrashIcon,
tune: icons.TuneIcon, tune: icons.TuneIcon,

View File

@@ -23,6 +23,7 @@ type Props = {
isBidirectional: boolean isBidirectional: boolean
inlineFlex?: boolean inlineFlex?: boolean
className?: string className?: string
readonly?: boolean
} }
const LinkedItemBubble = ({ const LinkedItemBubble = ({
@@ -36,6 +37,7 @@ const LinkedItemBubble = ({
isBidirectional, isBidirectional,
inlineFlex, inlineFlex,
className, className,
readonly,
}: Props) => { }: Props) => {
const ref = useRef<HTMLButtonElement>(null) const ref = useRef<HTMLButtonElement>(null)
const application = useApplication() const application = useApplication()
@@ -60,6 +62,9 @@ const LinkedItemBubble = ({
const onClick: MouseEventHandler = (event) => { const onClick: MouseEventHandler = (event) => {
if (wasClicked && event.target !== unlinkButtonRef.current) { if (wasClicked && event.target !== unlinkButtonRef.current) {
setWasClicked(false) setWasClicked(false)
if (readonly) {
return
}
void activateItem?.(link.item) void activateItem?.(link.item)
} else { } else {
setWasClicked(true) setWasClicked(true)
@@ -112,7 +117,7 @@ const LinkedItemBubble = ({
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
> >
<Icon type={icon} className={classNames('mr-1 flex-shrink-0', iconClassName)} size="small" /> <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>} {tagTitle && <span className="text-passive-1">{tagTitle.titlePrefix}</span>}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
{link.type === 'linked-by' && link.item.content_type !== ContentType.Tag && ( {link.type === 'linked-by' && link.item.content_type !== ContentType.Tag && (
@@ -121,7 +126,7 @@ const LinkedItemBubble = ({
{getItemTitleInContextOfLinkBubble(link.item)} {getItemTitleInContextOfLinkBubble(link.item)}
</span> </span>
</span> </span>
{showUnlinkButton && ( {showUnlinkButton && !readonly && (
<a <a
ref={unlinkButtonRef} ref={unlinkButtonRef}
role="button" role="button"

View File

@@ -2,7 +2,7 @@ import { observer } from 'mobx-react-lite'
import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput' import ItemLinkAutocompleteInput from './ItemLinkAutocompleteInput'
import { LinkingController } from '@/Controllers/LinkingController' import { LinkingController } from '@/Controllers/LinkingController'
import LinkedItemBubble from './LinkedItemBubble' 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 { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { ElementIds } from '@/Constants/ElementIDs' import { ElementIds } from '@/Constants/ElementIDs'
import { classNames } from '@standardnotes/utils' import { classNames } from '@standardnotes/utils'
@@ -18,9 +18,22 @@ type Props = {
linkingController: LinkingController linkingController: LinkingController
item: DecryptedItemInterface item: DecryptedItemInterface
hideToggle?: boolean 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 { toggleAppPane } = useResponsiveAppPane()
const commandService = useCommandService() 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 itemsToDisplay = allItemsLinkedToItem.concat(notesLinkingToItem).concat(filesLinkingToItem)
const visibleItems = isCollapsed ? itemsToDisplay.slice(0, 5) : itemsToDisplay const visibleItems = isCollapsed ? itemsToDisplay.slice(0, 5) : itemsToDisplay
const nonVisibleItems = itemsToDisplay.length - visibleItems.length const nonVisibleItems = itemsToDisplay.length - visibleItems.length
const [canShowContainerToggle, setCanShowContainerToggle] = useState(false) const [canShowContainerToggle, setCanShowContainerToggle] = useState(true)
const linkInputRef = useRef<HTMLInputElement>(null) const [linkContainer, setLinkContainer] = useState<HTMLDivElement | null>(null)
const linkContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const container = linkContainerRef.current const container = linkContainer
const linkInput = linkInputRef.current if (!container) {
if (!container || !linkInput) {
return return
} }
const resizeObserver = new ResizeObserver(() => { 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) setCanShowContainerToggle(true)
} else { } else {
setCanShowContainerToggle(false) setCanShowContainerToggle(false)
} }
}) })
resizeObserver.observe(linkContainerRef.current) resizeObserver.observe(container)
return () => { return () => {
resizeObserver.disconnect() resizeObserver.disconnect()
} }
}, []) }, [linkContainer])
const shouldHideToggle = hideToggle || (!canShowContainerToggle && !isCollapsed) const shouldHideToggle = hideToggle || (!canShowContainerToggle && !isCollapsed)
if (readonly && itemsToDisplay.length === 0) {
return null
}
return ( return (
<div <div
className={classNames( className={classNames(
'flex w-full justify-between', 'flex w-full flex-wrap justify-between md:flex-nowrap',
itemsToDisplay.length > 0 && !shouldHideToggle && 'pt-2', itemsToDisplay.length > 0 && !shouldHideToggle ? 'pt-2 ' + className.withToggle : undefined,
isCollapsed ? 'gap-4' : 'gap-1', isCollapsed ? 'gap-4' : 'gap-1',
className.base,
)} )}
> >
<div <div
className={classNames( 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', allItemsLinkedToItem.length || notesLinkingToItem.length ? 'mt-1' : 'mt-0.5',
isCollapsed ? 'overflow-hidden' : 'flex-wrap', isCollapsed ? 'overflow-hidden' : 'flex-wrap',
!shouldHideToggle && 'mr-2',
)} )}
ref={linkContainerRef} ref={setLinkContainer}
> >
{visibleItems.map((link) => ( {visibleItems.map((link) => (
<LinkedItemBubble <LinkedItemBubble
@@ -167,18 +191,20 @@ const LinkedItemBubblesContainer = ({ item, linkingController, hideToggle = fals
focusedId={focusedId} focusedId={focusedId}
setFocusedId={setFocusedId} setFocusedId={setFocusedId}
isBidirectional={isItemBidirectionallyLinked(link)} isBidirectional={isItemBidirectionallyLinked(link)}
readonly={readonly}
/> />
))} ))}
{isCollapsed && nonVisibleItems > 0 && <span className="flex-shrink-0">and {nonVisibleItems} more...</span>} {isCollapsed && nonVisibleItems > 0 && <span className="flex-shrink-0">and {nonVisibleItems} more...</span>}
<ItemLinkAutocompleteInput {!readonly && (
focusedId={focusedId} <ItemLinkAutocompleteInput
linkingController={linkingController} focusedId={focusedId}
focusPreviousItem={focusPreviousItem} linkingController={linkingController}
setFocusedId={setFocusedId} focusPreviousItem={focusPreviousItem}
hoverLabel={`Focus input to add a link (${shortcut})`} setFocusedId={setFocusedId}
item={item} hoverLabel={`Focus input to add a link (${shortcut})`}
ref={linkInputRef} item={item}
/> />
)}
</div> </div>
{itemsToDisplay.length > 0 && !shouldHideToggle && ( {itemsToDisplay.length > 0 && !shouldHideToggle && (
<RoundIconButton <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 { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings'
import { log, LoggingDomain } from '@/Logging' import { log, LoggingDomain } from '@/Logging'
import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils' import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils'
import { classNames } from '@standardnotes/utils' import { classNames, pluralize } from '@standardnotes/utils'
import { import {
ApplicationEvent, ApplicationEvent,
ComponentArea, ComponentArea,
@@ -45,6 +45,10 @@ import { SuperEditorContentId } from '../SuperEditor/Constants'
import { NoteViewController } from './Controller/NoteViewController' import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor' import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths' 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 const MinimumStatusDuration = 400
@@ -74,6 +78,9 @@ type State = {
updateSavingIndicator?: boolean updateSavingIndicator?: boolean
editorFeatureIdentifier?: string editorFeatureIdentifier?: string
noteType?: NoteType noteType?: NoteType
conflictedNotes: SNNote[]
showConflictResolutionModal: boolean
} }
class NoteView extends AbstractComponent<NoteViewProps, State> { class NoteView extends AbstractComponent<NoteViewProps, State> {
@@ -84,6 +91,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
private removeTrashKeyObserver?: () => void private removeTrashKeyObserver?: () => void
private removeComponentStreamObserver?: () => void private removeComponentStreamObserver?: () => void
private removeNoteStreamObserver?: () => void
private removeComponentManagerObserver?: () => void private removeComponentManagerObserver?: () => void
private removeInnerNoteObserver?: () => void private removeInnerNoteObserver?: () => void
@@ -120,6 +128,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
syncTakingTooLong: false, syncTakingTooLong: false,
editorFeatureIdentifier: this.controller.item.editorIdentifier, editorFeatureIdentifier: this.controller.item.editorIdentifier,
noteType: this.controller.item.noteType, noteType: this.controller.item.noteType,
conflictedNotes: [],
showConflictResolutionModal: false,
} }
this.noteViewElementRef = createRef<HTMLDivElement>() this.noteViewElementRef = createRef<HTMLDivElement>()
@@ -133,6 +143,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.removeComponentStreamObserver?.() this.removeComponentStreamObserver?.()
;(this.removeComponentStreamObserver as unknown) = undefined ;(this.removeComponentStreamObserver as unknown) = undefined
this.removeNoteStreamObserver?.()
;(this.removeNoteStreamObserver as unknown) = undefined
this.removeInnerNoteObserver?.() this.removeInnerNoteObserver?.()
;(this.removeInnerNoteObserver as unknown) = undefined ;(this.removeInnerNoteObserver as unknown) = undefined
@@ -418,6 +431,37 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
await this.reloadStackComponents() await this.reloadStackComponents()
this.debounceReloadEditorComponent() 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) { private createComponentViewer(component: SNComponent) {
@@ -776,6 +820,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({ plainEditorFocused: false }) this.setState({ plainEditorFocused: false })
} }
toggleConflictResolutionModal = () => {
this.setState((state) => ({
showConflictResolutionModal: !state.showConflictResolutionModal,
}))
}
override render() { override render() {
if (this.controller.dealloced) { if (this.controller.dealloced) {
return null return null
@@ -803,6 +853,8 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
? 'component' ? 'component'
: 'plain' : 'plain'
const shouldShowConflictsButton = this.state.conflictedNotes.length > 0
return ( return (
<div aria-label="Note" className="section editor sn-component h-full md:max-h-full" ref={this.noteViewElementRef}> <div aria-label="Note" className="section editor sn-component h-full md:max-h-full" ref={this.noteViewElementRef}>
{this.note && ( {this.note && (
@@ -826,7 +878,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
id="editor-title-bar" id="editor-title-bar"
className="content-title-bar section-title-bar z-editor-title-bar w-full bg-default pt-4" 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')}> <div className={classNames(this.state.noteLocked && 'locked', 'flex flex-grow items-center')}>
<MobileItemsListButton /> <MobileItemsListButton />
<div className="title flex-grow overflow-auto"> <div className="title flex-grow overflow-auto">
@@ -850,6 +907,19 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
updateSavingIndicator={this.state.updateSavingIndicator} updateSavingIndicator={this.state.updateSavingIndicator}
/> />
</div> </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 && ( {renderHeaderOptions && (
<div className="note-view-options-buttons flex items-center gap-3"> <div className="note-view-options-buttons flex items-center gap-3">
<LinkedItemsButton <LinkedItemsButton
@@ -979,6 +1049,14 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})} })}
</div> </div>
</div> </div>
<ModalOverlay isOpen={this.state.showConflictResolutionModal} close={this.toggleConflictResolutionModal}>
<NoteConflictResolutionModal
currentNote={this.note}
conflictedNotes={this.state.conflictedNotes}
close={this.toggleConflictResolutionModal}
/>
</ModalOverlay>
</div> </div>
) )
} }

View File

@@ -1,13 +1,10 @@
import { useMemo, FunctionComponent } from 'react' import { useMemo, FunctionComponent } from 'react'
import { SNApplication, SNNote } from '@standardnotes/snjs' import { SNApplication, SNNote, classNames } from '@standardnotes/snjs'
import { formatDateForContextMenu } from '@/Utils/DateUtils' import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { calculateReadTime } from './Utils/calculateReadTime' import { calculateReadTime } from './Utils/calculateReadTime'
import { countNoteAttributes } from './Utils/countNoteAttributes' import { countNoteAttributes } from './Utils/countNoteAttributes'
export const NoteAttributes: FunctionComponent<{ export const useNoteAttributes = (application: SNApplication, note: SNNote) => {
application: SNApplication
note: SNNote
}> = ({ application, note }) => {
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text]) const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
const readTime = useMemo(() => (typeof words === 'number' ? calculateReadTime(words) : 'N/A'), [words]) 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 editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt' 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 ( 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') ? ( {typeof words === 'number' && (format === 'txt' || format === 'md') ? (
<> <>
<div className="mb-1"> <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 const percentOf = (percent: number, value: number) => (percent / 100) * value
export type PopoverCSSProperties = CSSProperties & {
'--translate-x': string
'--translate-y': string
}
const getStylesFromRect = ( const getStylesFromRect = (
rect: DOMRect, rect: DOMRect,
options: { options: {
disableMobileFullscreenTakeover?: boolean disableMobileFullscreenTakeover?: boolean
maxHeight?: number | 'none' maxHeight?: number | 'none'
}, },
): CSSProperties => { ): PopoverCSSProperties => {
const { disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options const { disableMobileFullscreenTakeover = false, maxHeight = 'none' } = options
const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover) const canApplyMaxHeight = maxHeight !== 'none' && (!isMobileScreen() || disableMobileFullscreenTakeover)
@@ -22,7 +27,9 @@ const getStylesFromRect = (
return { return {
willChange: 'transform', 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', visibility: 'visible',
...(canApplyMaxHeight && { ...(canApplyMaxHeight && {
maxHeight: `${maxHeight}px`, maxHeight: `${maxHeight}px`,
@@ -53,7 +60,7 @@ export const getPositionedPopoverStyles = ({
disableMobileFullscreenTakeover, disableMobileFullscreenTakeover,
maxHeightFunction, maxHeightFunction,
offset, offset,
}: Options): CSSProperties | null => { }: Options): PopoverCSSProperties | null => {
if (!popoverRect || !anchorRect) { if (!popoverRect || !anchorRect) {
return null return null
} }

View File

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

View File

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

View File

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

View File

@@ -95,25 +95,25 @@ export const getPositionedPopoverRect = (
if (side === 'top' || side === 'bottom') { if (side === 'top' || side === 'bottom') {
switch (align) { switch (align) {
case 'start': case 'start':
positionPopoverRect.x = buttonRect.left - finalOffset positionPopoverRect.x = buttonRect.left
break break
case 'center': case 'center':
positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2 - finalOffset positionPopoverRect.x = buttonRect.left - width / 2 + buttonRect.width / 2
break break
case 'end': case 'end':
positionPopoverRect.x = buttonRect.right - width + finalOffset positionPopoverRect.x = buttonRect.right - width
break break
} }
} else { } else {
switch (align) { switch (align) {
case 'start': case 'start':
positionPopoverRect.y = buttonRect.top - finalOffset positionPopoverRect.y = buttonRect.top
break break
case 'center': case 'center':
positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2 - finalOffset positionPopoverRect.y = buttonRect.top - height / 2 + buttonRect.height / 2
break break
case 'end': case 'end':
positionPopoverRect.y = buttonRect.bottom - height + finalOffset positionPopoverRect.y = buttonRect.bottom - height
break 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 togglePopover?: () => void
childPopovers: Set<string> childPopovers: Set<string>
disabled?: boolean disabled?: boolean
hideOnClickInModal?: boolean
} }
export const usePopoverCloseOnClickOutside = ({ export const usePopoverCloseOnClickOutside = ({
@@ -14,6 +15,7 @@ export const usePopoverCloseOnClickOutside = ({
togglePopover, togglePopover,
childPopovers, childPopovers,
disabled, disabled,
hideOnClickInModal = false,
}: Options) => { }: Options) => {
useEffect(() => { useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => { const closeIfClickedOutside = (event: MouseEvent) => {
@@ -26,7 +28,12 @@ export const usePopoverCloseOnClickOutside = ({
const isPopoverInModal = popoverElement?.closest('[data-dialog]') const isPopoverInModal = popoverElement?.closest('[data-dialog]')
const isDescendantOfModal = isPopoverInModal ? false : !!target.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) { if (!disabled) {
togglePopover?.() togglePopover?.()
} }
@@ -39,5 +46,5 @@ export const usePopoverCloseOnClickOutside = ({
document.removeEventListener('click', closeIfClickedOutside, { capture: true }) document.removeEventListener('click', closeIfClickedOutside, { capture: true })
document.removeEventListener('contextmenu', 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 ( return (
<div className="flex h-full w-full items-center justify-center"> <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 /> <HistoryLockedIllustration />
<div className="mt-2 mb-1 text-lg font-bold">Can't access this version</div> <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"> <div className="leading-140% mb-4 text-passive-0">

View File

@@ -1,46 +1,61 @@
import { classNames } from '@standardnotes/snjs' import { classNames } from '@standardnotes/snjs'
import { ReactNode } from 'react' import { ReactNode, useState } from 'react'
import { Tooltip, TooltipAnchor, TooltipOptions, useTooltipStore } from '@ariakit/react' import { Tooltip, TooltipAnchor, TooltipOptions, useTooltipStore } from '@ariakit/react'
import { Slot } from '@radix-ui/react-slot' import { Slot } from '@radix-ui/react-slot'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery' import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles' import { getPositionedPopoverStyles } from '../Popover/GetPositionedPopoverStyles'
import { getAdjustedStylesForNonPortalPopover } from '../Popover/Utils/getAdjustedStylesForNonPortal'
const StyledTooltip = ({ const StyledTooltip = ({
children, children,
className, className,
label, label,
showOnMobile = false,
showOnHover = true,
...props ...props
}: { }: {
children: ReactNode children: ReactNode
label: NonNullable<ReactNode>
className?: string className?: string
label: string showOnMobile?: boolean
showOnHover?: boolean
} & Partial<TooltipOptions>) => { } & Partial<TooltipOptions>) => {
const [forceOpen, setForceOpen] = useState<boolean | undefined>()
const tooltip = useTooltipStore({ const tooltip = useTooltipStore({
timeout: 350, timeout: 350,
open: forceOpen,
}) })
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm) const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
if (isMobile) { if (isMobile && !showOnMobile) {
return <>{children}</> return <>{children}</>
} }
return ( return (
<> <>
<TooltipAnchor store={tooltip} as={Slot}> <TooltipAnchor
onFocus={() => setForceOpen(true)}
onBlur={() => setForceOpen(undefined)}
store={tooltip}
as={Slot}
showOnHover={showOnHover}
>
{children} {children}
</TooltipAnchor> </TooltipAnchor>
<Tooltip <Tooltip
autoFocusOnShow={!showOnHover}
store={tooltip} store={tooltip}
className={classNames( className={classNames(
'z-tooltip max-w-max rounded border border-border bg-contrast py-1.5 px-3 text-sm text-foreground shadow', 'z-tooltip max-w-max rounded border border-border bg-contrast py-1.5 px-3 text-sm text-foreground shadow',
className, className,
)} )}
updatePosition={() => { updatePosition={() => {
const { popoverElement, anchorElement } = tooltip.getState() const { popoverElement, anchorElement, open } = tooltip.getState()
const documentElement = document.querySelector('.main-ui-view') const documentElement = document.querySelector('.main-ui-view')
if (!popoverElement || !anchorElement || !documentElement) { if (!popoverElement || !anchorElement || !documentElement || !open) {
return return
} }
@@ -55,10 +70,20 @@ const StyledTooltip = ({
popoverRect, popoverRect,
documentRect, documentRect,
disableMobileFullscreenTakeover: true, disableMobileFullscreenTakeover: true,
offset: 6, offset: props.gutter ? props.gutter : 6,
}) })
if (!styles) {
return
}
Object.assign(popoverElement.style, styles) 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} {...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 { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin' import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
@@ -35,6 +35,7 @@ type BlocksEditorProps = {
spellcheck?: boolean spellcheck?: boolean
ignoreFirstChange?: boolean ignoreFirstChange?: boolean
readonly?: boolean readonly?: boolean
onScroll?: UIEventHandler
} }
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
@@ -45,6 +46,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
spellcheck, spellcheck,
ignoreFirstChange = false, ignoreFirstChange = false,
readonly, readonly,
onScroll,
}) => { }) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false) const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback( const handleChange = useCallback(
@@ -79,6 +81,7 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
id={SuperEditorContentId} id={SuperEditorContentId}
className={classNames('ContentEditable__root overflow-y-auto', className)} className={classNames('ContentEditable__root overflow-y-auto', className)}
spellCheck={spellcheck} spellCheck={spellcheck}
onScroll={onScroll}
/> />
<div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" /> <div className="search-highlight-container pointer-events-none absolute top-0 left-0 h-full w-full" />
</div> </div>

View File

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

View File

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

View File

@@ -85,12 +85,8 @@ input:focus {
box-shadow: 0 0 0 1px var(--sn-stylekit-info-color); 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, .sk-button:focus-visible,
button:focus-visible { button:focus-visible {
outline: none; 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: { minHeight: {
1: '0.25rem', 1: '0.25rem',
2: '0.5rem', 2: '0.5rem',
11: '2.75rem',
}, },
maxHeight: { maxHeight: {
110: '27.5rem', 110: '27.5rem',

View File

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