feat: Editing large notes (greater than 1.5MB) will result in more optimized syncing, in which changes are saved locally immediately, but sync with the server less frequently (roughly every 30 seconds rather than after every change). (#2768)
This commit is contained in:
3
packages/icons/src/Icons/ic-clock.svg
Normal file
3
packages/icons/src/Icons/ic-clock.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d="M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -43,6 +43,7 @@ import ChevronUpIcon from './ic-chevron-up.svg'
|
|||||||
import CircleIcon from './circle-55.svg'
|
import CircleIcon from './circle-55.svg'
|
||||||
import ClearCircleFilledIcon from './ic-clear-circle-filled.svg'
|
import ClearCircleFilledIcon from './ic-clear-circle-filled.svg'
|
||||||
import CloseCircleFilledIcon from './ic-close-circle-filled.svg'
|
import CloseCircleFilledIcon from './ic-close-circle-filled.svg'
|
||||||
|
import ClockIcon from './ic-clock.svg'
|
||||||
import CloseIcon from './ic-close.svg'
|
import CloseIcon from './ic-close.svg'
|
||||||
import CloudOffIcon from './ic-cloud-off.svg'
|
import CloudOffIcon from './ic-cloud-off.svg'
|
||||||
import CodeIcon from './ic-code.svg'
|
import CodeIcon from './ic-code.svg'
|
||||||
@@ -263,6 +264,7 @@ export {
|
|||||||
CircleIcon,
|
CircleIcon,
|
||||||
ClearCircleFilledIcon,
|
ClearCircleFilledIcon,
|
||||||
CloseCircleFilledIcon,
|
CloseCircleFilledIcon,
|
||||||
|
ClockIcon,
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
CloudOffIcon,
|
CloudOffIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export type IconType =
|
|||||||
| 'chevron-right'
|
| 'chevron-right'
|
||||||
| 'chevron-up'
|
| 'chevron-up'
|
||||||
| 'clear-circle-filled'
|
| 'clear-circle-filled'
|
||||||
|
| 'clock'
|
||||||
| 'close'
|
| 'close'
|
||||||
| 'cloud-off'
|
| 'cloud-off'
|
||||||
| 'code-2'
|
| 'code-2'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export enum WebAppEvent {
|
export enum WebAppEvent {
|
||||||
NewUpdateAvailable = 'NewUpdateAvailable',
|
NewUpdateAvailable = 'NewUpdateAvailable',
|
||||||
EditorFocused = 'EditorFocused',
|
EditorDidFocus = 'EditorDidFocus',
|
||||||
BeganBackupDownload = 'BeganBackupDownload',
|
BeganBackupDownload = 'BeganBackupDownload',
|
||||||
EndedBackupDownload = 'EndedBackupDownload',
|
EndedBackupDownload = 'EndedBackupDownload',
|
||||||
PanelResized = 'PanelResized',
|
PanelResized = 'PanelResized',
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export enum SyncMode {
|
|||||||
* all data to see if user has an items key, and if not, only then create a new one.
|
* all data to see if user has an items key, and if not, only then create a new one.
|
||||||
*/
|
*/
|
||||||
DownloadFirst = 'DownloadFirst',
|
DownloadFirst = 'DownloadFirst',
|
||||||
|
LocalOnly = 'LocalOnly',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,6 +896,12 @@ export class SyncService
|
|||||||
|
|
||||||
const { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } = await this.prepareForSync(options)
|
const { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } = await this.prepareForSync(options)
|
||||||
|
|
||||||
|
if (options.mode === SyncMode.LocalOnly) {
|
||||||
|
this.logger.debug('Syncing local only, skipping remote sync request')
|
||||||
|
releaseLock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const inTimeResolveQueue = this.getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest()
|
const inTimeResolveQueue = this.getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest()
|
||||||
|
|
||||||
if (!shouldExecuteSync) {
|
if (!shouldExecuteSync) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class VisibilityObserver {
|
|||||||
*/
|
*/
|
||||||
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
window.addEventListener('focus', this.onFocusEvent, false)
|
window.addEventListener('focus', this.onFocusEvent, false)
|
||||||
|
window.addEventListener('blur', this.onBlurEvent, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onVisibilityChange = () => {
|
onVisibilityChange = () => {
|
||||||
@@ -23,6 +24,10 @@ export class VisibilityObserver {
|
|||||||
this.notifyEvent(WebAppEvent.WindowDidFocus)
|
this.notifyEvent(WebAppEvent.WindowDidFocus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBlurEvent = () => {
|
||||||
|
this.notifyEvent(WebAppEvent.WindowDidBlur)
|
||||||
|
}
|
||||||
|
|
||||||
private notifyEvent(event: WebAppEvent): void {
|
private notifyEvent(event: WebAppEvent): void {
|
||||||
if (this.raceTimeout) {
|
if (this.raceTimeout) {
|
||||||
clearTimeout(this.raceTimeout)
|
clearTimeout(this.raceTimeout)
|
||||||
@@ -35,6 +40,7 @@ export class VisibilityObserver {
|
|||||||
deinit(): void {
|
deinit(): void {
|
||||||
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||||
window.removeEventListener('focus', this.onFocusEvent)
|
window.removeEventListener('focus', this.onFocusEvent)
|
||||||
|
window.removeEventListener('blur', this.onBlurEvent)
|
||||||
;(this.onEvent as unknown) = undefined
|
;(this.onEvent as unknown) = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeObserver = application.addWebEventObserver(async (eventName) => {
|
const removeObserver = application.addWebEventObserver(async (eventName) => {
|
||||||
if (eventName === WebAppEvent.WindowDidFocus) {
|
if (eventName === WebAppEvent.WindowDidFocus || eventName === WebAppEvent.WindowDidBlur) {
|
||||||
if (!(await application.protections.isLocked())) {
|
if (!(await application.protections.isLocked())) {
|
||||||
application.sync.sync().catch(console.error)
|
application.sync.sync().catch(console.error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import { NoteType, noteTypeForEditorIdentifier } from '@standardnotes/snjs'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
noteViewController?: NoteViewController
|
noteViewController?: NoteViewController
|
||||||
|
onClick?: () => void
|
||||||
onClickPreprocessing?: () => Promise<void>
|
onClickPreprocessing?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClickPreprocessing }: Props) => {
|
const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClick, onClickPreprocessing }: Props) => {
|
||||||
const application = useApplication()
|
const application = useApplication()
|
||||||
|
|
||||||
const note = application.notesController.firstSelectedNote
|
const note = application.notesController.firstSelectedNote
|
||||||
@@ -48,7 +49,10 @@ const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onCl
|
|||||||
await onClickPreprocessing()
|
await onClickPreprocessing()
|
||||||
}
|
}
|
||||||
setIsOpen(willMenuOpen)
|
setIsOpen(willMenuOpen)
|
||||||
}, [onClickPreprocessing, isOpen])
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}, [isOpen, onClickPreprocessing, onClick])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return application.keyboardService.addCommandHandler({
|
return application.keyboardService.addCommandHandler({
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class Footer extends AbstractComponent<Props, State> {
|
|||||||
case WebAppEvent.NewUpdateAvailable:
|
case WebAppEvent.NewUpdateAvailable:
|
||||||
this.onNewUpdateAvailable()
|
this.onNewUpdateAvailable()
|
||||||
break
|
break
|
||||||
case WebAppEvent.EditorFocused:
|
case WebAppEvent.EditorDidFocus:
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
|
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
|
||||||
this.closeAccountMenu()
|
this.closeAccountMenu()
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const IconNameToSvgMapping = {
|
|||||||
bold: icons.BoldIcon,
|
bold: icons.BoldIcon,
|
||||||
camera: icons.CameraIcon,
|
camera: icons.CameraIcon,
|
||||||
check: icons.CheckIcon,
|
check: icons.CheckIcon,
|
||||||
|
clock: icons.ClockIcon,
|
||||||
close: icons.CloseIcon,
|
close: icons.CloseIcon,
|
||||||
code: icons.CodeIcon,
|
code: icons.CodeIcon,
|
||||||
comment: icons.FeedbackIcon,
|
comment: icons.FeedbackIcon,
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import LinkedItemsPanel from './LinkedItemsPanel'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
linkingController: LinkingController
|
linkingController: LinkingController
|
||||||
|
onClick?: () => void
|
||||||
onClickPreprocessing?: () => Promise<void>
|
onClickPreprocessing?: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) => {
|
const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => {
|
||||||
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
@@ -20,7 +21,10 @@ const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) =
|
|||||||
await onClickPreprocessing()
|
await onClickPreprocessing()
|
||||||
}
|
}
|
||||||
setIsLinkingPanelOpen(willMenuOpen)
|
setIsLinkingPanelOpen(willMenuOpen)
|
||||||
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen])
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen])
|
||||||
|
|
||||||
if (!activeItem) {
|
if (!activeItem) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { MILLISECONDS_IN_A_SECOND } from '@/Constants/Constants'
|
||||||
|
|
||||||
export const EditorSaveTimeoutDebounce = {
|
export const EditorSaveTimeoutDebounce = {
|
||||||
Desktop: 350,
|
Desktop: 350,
|
||||||
ImmediateChange: 100,
|
ImmediateChange: 100,
|
||||||
NativeMobileWeb: 700,
|
NativeMobileWeb: 700,
|
||||||
|
LargeNote: 60 * MILLISECONDS_IN_A_SECOND,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export class ItemGroupController {
|
|||||||
controller: NoteViewController | FileViewController,
|
controller: NoteViewController | FileViewController,
|
||||||
{ notify = true }: { notify: boolean } = { notify: true },
|
{ notify = true }: { notify: boolean } = { notify: true },
|
||||||
): void {
|
): void {
|
||||||
|
if (controller instanceof NoteViewController) {
|
||||||
|
controller.syncOnlyIfLargeNote()
|
||||||
|
}
|
||||||
controller.deinit()
|
controller.deinit()
|
||||||
|
|
||||||
removeFromArray(this.itemControllers, controller)
|
removeFromArray(this.itemControllers, controller)
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ describe('note view controller', () => {
|
|||||||
createTemplateItem: jest.fn().mockReturnValue({} as SNNote),
|
createTemplateItem: jest.fn().mockReturnValue({} as SNNote),
|
||||||
} as unknown as jest.Mocked<ItemManagerInterface>,
|
} as unknown as jest.Mocked<ItemManagerInterface>,
|
||||||
mutator: {} as jest.Mocked<MutatorClientInterface>,
|
mutator: {} as jest.Mocked<MutatorClientInterface>,
|
||||||
|
sessions: {
|
||||||
|
isSignedIn: jest.fn().mockReturnValue(true),
|
||||||
|
},
|
||||||
} as unknown as jest.Mocked<WebApplication>
|
} as unknown as jest.Mocked<WebApplication>
|
||||||
|
|
||||||
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
|
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerO
|
|||||||
import { log, LoggingDomain } from '@/Logging'
|
import { log, LoggingDomain } from '@/Logging'
|
||||||
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
|
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
|
||||||
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
|
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
|
||||||
|
import { NoteStatus } from '../NoteStatusIndicator'
|
||||||
|
|
||||||
export type EditorValues = {
|
export type EditorValues = {
|
||||||
title: string
|
title: string
|
||||||
@@ -219,4 +220,28 @@ export class NoteViewController implements ItemViewControllerInterface {
|
|||||||
|
|
||||||
await this.syncController.saveAndAwaitLocalPropagation(params)
|
await this.syncController.saveAndAwaitLocalPropagation(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get syncStatus(): NoteStatus | undefined {
|
||||||
|
return this.syncController.status
|
||||||
|
}
|
||||||
|
|
||||||
|
public showSavingStatus(): void {
|
||||||
|
this.syncController.showSavingStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public showAllChangesSavedStatus(): void {
|
||||||
|
this.syncController.showAllChangesSavedStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
public showErrorSyncStatus(error?: NoteStatus): void {
|
||||||
|
this.syncController.showErrorStatus(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
public syncNow(): void {
|
||||||
|
this.sync.sync().catch(console.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
public syncOnlyIfLargeNote(): void {
|
||||||
|
this.syncController.syncOnlyIfLargeNote()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,77 @@
|
|||||||
import { ElementIds } from '@/Constants/ElementIDs'
|
|
||||||
import { classNames } from '@standardnotes/utils'
|
import { classNames } from '@standardnotes/utils'
|
||||||
import { ReactNode, useCallback, useState } from 'react'
|
import { ReactNode, useCallback, useRef, useState } from 'react'
|
||||||
import { IconType, PrefKey, PrefDefaults } from '@standardnotes/snjs'
|
import { IconType, PrefKey, PrefDefaults, SNNote } from '@standardnotes/snjs'
|
||||||
import Icon from '../Icon/Icon'
|
import Icon from '../Icon/Icon'
|
||||||
import { useApplication } from '../ApplicationProvider'
|
import { useApplication } from '../ApplicationProvider'
|
||||||
|
import { observer } from 'mobx-react-lite'
|
||||||
|
import { VisuallyHidden } from '@ariakit/react'
|
||||||
|
import Button from '../Button/Button'
|
||||||
|
import Popover from '../Popover/Popover'
|
||||||
|
import { getRelativeTimeString } from '@/Utils/GetRelativeTimeString'
|
||||||
|
|
||||||
export type NoteStatus = {
|
export type NoteStatus = {
|
||||||
type: 'saving' | 'saved' | 'error'
|
type: 'saving' | 'saved' | 'error' | 'waiting'
|
||||||
message: string
|
message: string
|
||||||
desc?: string
|
description?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const IndicatorWithTooltip = ({
|
const IndicatorWithTooltip = ({
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
onBlur,
|
|
||||||
icon,
|
icon,
|
||||||
isTooltipVisible,
|
isTooltipVisible,
|
||||||
|
setIsTooltipVisible,
|
||||||
children,
|
children,
|
||||||
animateIcon = false,
|
animateIcon = false,
|
||||||
}: {
|
}: {
|
||||||
className: string
|
className: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onBlur: () => void
|
|
||||||
icon: IconType
|
icon: IconType
|
||||||
isTooltipVisible: boolean
|
isTooltipVisible: boolean
|
||||||
|
setIsTooltipVisible: React.Dispatch<React.SetStateAction<boolean>>
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
animateIcon?: boolean
|
animateIcon?: boolean
|
||||||
}) => (
|
}) => {
|
||||||
<div className="note-status-tooltip-container relative">
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
<button
|
|
||||||
className={classNames('peer flex h-5 w-5 items-center justify-center rounded-full', className)}
|
return (
|
||||||
onClick={onClick}
|
<div className="note-status-tooltip-container">
|
||||||
onBlur={onBlur}
|
<button
|
||||||
aria-describedby={ElementIds.NoteStatusTooltip}
|
className={classNames('peer flex h-5 w-5 cursor-pointer items-center justify-center rounded-full', className)}
|
||||||
>
|
onClick={onClick}
|
||||||
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
ref={buttonRef}
|
||||||
<span className="sr-only">Note sync status</span>
|
>
|
||||||
</button>
|
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
||||||
<div
|
<VisuallyHidden>Note sync status</VisuallyHidden>
|
||||||
id={ElementIds.NoteStatusTooltip}
|
</button>
|
||||||
className={classNames(
|
<Popover
|
||||||
isTooltipVisible ? '' : 'hidden',
|
title="Note sync status"
|
||||||
'absolute right-0 top-full min-w-[90vw] translate-x-2 translate-y-1 select-none rounded border border-border',
|
open={isTooltipVisible}
|
||||||
'bg-default px-3 py-1.5 text-left peer-hover:block peer-focus:block md:min-w-max',
|
togglePopover={() => setIsTooltipVisible((visible) => !visible)}
|
||||||
)}
|
className="px-3 py-2"
|
||||||
>
|
containerClassName="!min-w-0 !w-auto max-w-[90vw]"
|
||||||
{children}
|
anchorElement={buttonRef}
|
||||||
|
side="bottom"
|
||||||
|
align="center"
|
||||||
|
offset={6}
|
||||||
|
disableMobileFullscreenTakeover
|
||||||
|
disableApplyingMobileWidth
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
note: SNNote
|
||||||
status: NoteStatus | undefined
|
status: NoteStatus | undefined
|
||||||
syncTakingTooLong: boolean
|
syncTakingTooLong: boolean
|
||||||
updateSavingIndicator?: boolean
|
updateSavingIndicator?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoteStatusIndicator = ({
|
const NoteStatusIndicator = ({
|
||||||
|
note,
|
||||||
status,
|
status,
|
||||||
syncTakingTooLong,
|
syncTakingTooLong,
|
||||||
updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
|
updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
|
||||||
@@ -65,7 +79,9 @@ const NoteStatusIndicator = ({
|
|||||||
const application = useApplication()
|
const application = useApplication()
|
||||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false)
|
const [isTooltipVisible, setIsTooltipVisible] = useState(false)
|
||||||
|
|
||||||
const onBlur = () => setIsTooltipVisible(false)
|
const toggleTooltip = useCallback(() => {
|
||||||
|
setIsTooltipVisible((visible) => !visible)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const toggleShowPreference = useCallback(() => {
|
const toggleShowPreference = useCallback(() => {
|
||||||
void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator)
|
void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator)
|
||||||
@@ -79,13 +95,13 @@ const NoteStatusIndicator = ({
|
|||||||
return (
|
return (
|
||||||
<IndicatorWithTooltip
|
<IndicatorWithTooltip
|
||||||
className="bg-danger text-danger-contrast"
|
className="bg-danger text-danger-contrast"
|
||||||
onClick={toggleShowPreference}
|
onClick={toggleTooltip}
|
||||||
onBlur={onBlur}
|
|
||||||
icon="warning"
|
icon="warning"
|
||||||
isTooltipVisible={isTooltipVisible}
|
isTooltipVisible={isTooltipVisible}
|
||||||
|
setIsTooltipVisible={setIsTooltipVisible}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-bold text-danger">{status.message}</div>
|
<div className="text-sm font-bold text-danger">{status.message}</div>
|
||||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||||
</IndicatorWithTooltip>
|
</IndicatorWithTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -94,15 +110,15 @@ const NoteStatusIndicator = ({
|
|||||||
return (
|
return (
|
||||||
<IndicatorWithTooltip
|
<IndicatorWithTooltip
|
||||||
className="bg-warning text-warning-contrast"
|
className="bg-warning text-warning-contrast"
|
||||||
onClick={toggleShowPreference}
|
onClick={toggleTooltip}
|
||||||
onBlur={onBlur}
|
|
||||||
icon={status && status.type === 'saving' ? 'sync' : 'warning'}
|
icon={status && status.type === 'saving' ? 'sync' : 'warning'}
|
||||||
isTooltipVisible={isTooltipVisible}
|
isTooltipVisible={isTooltipVisible}
|
||||||
|
setIsTooltipVisible={setIsTooltipVisible}
|
||||||
>
|
>
|
||||||
{status ? (
|
{status ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-sm font-bold text-warning">{status.message}</div>
|
<div className="text-sm font-bold text-warning">{status.message}</div>
|
||||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm font-bold text-warning">Sync taking too long</div>
|
<div className="text-sm font-bold text-warning">Sync taking too long</div>
|
||||||
@@ -117,15 +133,35 @@ const NoteStatusIndicator = ({
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
status.type === 'saving' && 'bg-contrast',
|
status.type === 'saving' && 'bg-contrast',
|
||||||
status.type === 'saved' && 'bg-success text-success-contrast',
|
status.type === 'saved' && 'bg-success text-success-contrast',
|
||||||
|
status.type === 'waiting' && 'bg-warning text-warning-contrast',
|
||||||
)}
|
)}
|
||||||
onClick={toggleShowPreference}
|
onClick={toggleTooltip}
|
||||||
onBlur={onBlur}
|
icon={status.type === 'saving' ? 'sync' : status.type === 'waiting' ? 'clock' : 'check'}
|
||||||
icon={status.type === 'saving' ? 'sync' : 'check'}
|
|
||||||
animateIcon={status.type === 'saving'}
|
animateIcon={status.type === 'saving'}
|
||||||
isTooltipVisible={isTooltipVisible}
|
isTooltipVisible={isTooltipVisible}
|
||||||
|
setIsTooltipVisible={setIsTooltipVisible}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-bold">{status.message}</div>
|
<div className="text-sm font-bold">{status.message}</div>
|
||||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||||
|
{status.type === 'waiting' && note.lastSyncEnd && (
|
||||||
|
<div className="mt-0.5">Last synced {getRelativeTimeString(note.lastSyncEnd)}</div>
|
||||||
|
)}
|
||||||
|
{status.type === 'waiting' ? (
|
||||||
|
<Button
|
||||||
|
small
|
||||||
|
className="mt-1"
|
||||||
|
onClick={() => {
|
||||||
|
application.sync.sync().catch(console.error)
|
||||||
|
toggleTooltip()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sync now
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button small className="mt-1" onClick={toggleShowPreference}>
|
||||||
|
Disable status updates
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</IndicatorWithTooltip>
|
</IndicatorWithTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -133,15 +169,17 @@ const NoteStatusIndicator = ({
|
|||||||
return (
|
return (
|
||||||
<IndicatorWithTooltip
|
<IndicatorWithTooltip
|
||||||
className="bg-contrast text-passive-1"
|
className="bg-contrast text-passive-1"
|
||||||
onClick={toggleShowPreference}
|
onClick={toggleTooltip}
|
||||||
onBlur={onBlur}
|
|
||||||
icon="info"
|
icon="info"
|
||||||
isTooltipVisible={isTooltipVisible}
|
isTooltipVisible={isTooltipVisible}
|
||||||
|
setIsTooltipVisible={setIsTooltipVisible}
|
||||||
>
|
>
|
||||||
<div className="text-sm font-bold">Note status updates are disabled</div>
|
<div className="text-sm font-bold">Note status updates are disabled</div>
|
||||||
<div className="mt-0.5">Click to enable.</div>
|
<Button small className="mt-1" onClick={toggleShowPreference}>
|
||||||
|
Enable status updates
|
||||||
|
</Button>
|
||||||
</IndicatorWithTooltip>
|
</IndicatorWithTooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default NoteStatusIndicator
|
export default observer(NoteStatusIndicator)
|
||||||
|
|||||||
@@ -54,8 +54,6 @@ import ModalOverlay from '../Modal/ModalOverlay'
|
|||||||
import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
|
import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
|
||||||
import Icon from '../Icon/Icon'
|
import Icon from '../Icon/Icon'
|
||||||
|
|
||||||
const MinimumStatusDuration = 400
|
|
||||||
|
|
||||||
function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] {
|
function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] {
|
||||||
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
|
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
|
||||||
}
|
}
|
||||||
@@ -83,6 +81,7 @@ type State = {
|
|||||||
updateSavingIndicator?: boolean
|
updateSavingIndicator?: boolean
|
||||||
editorFeatureIdentifier?: string
|
editorFeatureIdentifier?: string
|
||||||
noteType?: NoteType
|
noteType?: NoteType
|
||||||
|
focusModeEnabled?: boolean
|
||||||
|
|
||||||
conflictedNotes: SNNote[]
|
conflictedNotes: SNNote[]
|
||||||
showConflictResolutionModal: boolean
|
showConflictResolutionModal: boolean
|
||||||
@@ -91,7 +90,6 @@ type State = {
|
|||||||
class NoteView extends AbstractComponent<NoteViewProps, State> {
|
class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||||
readonly controller!: NoteViewController
|
readonly controller!: NoteViewController
|
||||||
|
|
||||||
private statusTimeout?: NodeJS.Timeout
|
|
||||||
onEditorComponentLoad?: () => void
|
onEditorComponentLoad?: () => void
|
||||||
|
|
||||||
private removeTrashKeyObserver?: () => void
|
private removeTrashKeyObserver?: () => void
|
||||||
@@ -167,8 +165,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
|
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
|
||||||
|
|
||||||
this.onEditorComponentLoad = undefined
|
this.onEditorComponentLoad = undefined
|
||||||
|
|
||||||
this.statusTimeout = undefined
|
|
||||||
;(this.onPanelResizeFinish as unknown) = undefined
|
;(this.onPanelResizeFinish as unknown) = undefined
|
||||||
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
|
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
|
||||||
;(this.editorComponentViewerRequestsReload as unknown) = undefined
|
;(this.editorComponentViewerRequestsReload as unknown) = undefined
|
||||||
@@ -235,9 +231,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.autorun(() => {
|
this.autorun(() => {
|
||||||
|
const syncStatus = this.controller.syncStatus
|
||||||
|
|
||||||
|
const isFocusModeEnabled = this.application.paneController.focusModeEnabled
|
||||||
|
const didFocusModeChange = this.state.focusModeEnabled !== isFocusModeEnabled
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
showProtectedWarning: this.application.notesController.showProtectedWarning,
|
showProtectedWarning: this.application.notesController.showProtectedWarning,
|
||||||
|
noteStatus: syncStatus,
|
||||||
|
saveError: syncStatus?.type === 'error',
|
||||||
|
syncTakingTooLong: false,
|
||||||
|
focusModeEnabled: isFocusModeEnabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!isFocusModeEnabled && didFocusModeChange) {
|
||||||
|
this.controller.syncOnlyIfLargeNote()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.reloadEditorComponent().catch(console.error)
|
this.reloadEditorComponent().catch(console.error)
|
||||||
@@ -327,16 +336,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (note.lastSyncBegan || note.dirty) {
|
if (note.lastSyncBegan || note.dirty) {
|
||||||
|
const currentStatus = this.controller.syncStatus
|
||||||
|
const isWaitingToSyncLargeNote = currentStatus?.type === 'waiting'
|
||||||
if (note.lastSyncEnd) {
|
if (note.lastSyncEnd) {
|
||||||
const shouldShowSavingStatus = note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime()
|
const hasStartedNewSync = note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime()
|
||||||
const shouldShowSavedStatus = note.lastSyncBegan && note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
|
const shouldShowSavedStatus = note.lastSyncBegan && note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
|
||||||
if (note.dirty || shouldShowSavingStatus) {
|
if (hasStartedNewSync) {
|
||||||
this.showSavingStatus()
|
this.controller.showSavingStatus()
|
||||||
} else if (this.state.noteStatus && shouldShowSavedStatus) {
|
} else if (this.state.noteStatus && shouldShowSavedStatus && !isWaitingToSyncLargeNote) {
|
||||||
this.showAllChangesSavedStatus()
|
this.controller.showAllChangesSavedStatus()
|
||||||
}
|
}
|
||||||
} else {
|
} else if (note.lastSyncBegan) {
|
||||||
this.showSavingStatus()
|
this.controller.showSavingStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,7 +383,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
const isInErrorState = this.state.saveError
|
const isInErrorState = this.state.saveError
|
||||||
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
||||||
if (!this.note.dirty && isInErrorState) {
|
if (!this.note.dirty && isInErrorState) {
|
||||||
this.showAllChangesSavedStatus()
|
this.controller.showAllChangesSavedStatus()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -383,14 +394,14 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
* and we don't want to display an error here.
|
* and we don't want to display an error here.
|
||||||
*/
|
*/
|
||||||
if (this.note.dirty) {
|
if (this.note.dirty) {
|
||||||
this.showErrorStatus()
|
this.controller.showErrorSyncStatus()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case ApplicationEvent.LocalDatabaseWriteError:
|
case ApplicationEvent.LocalDatabaseWriteError:
|
||||||
this.showErrorStatus({
|
this.controller.showErrorSyncStatus({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Offline Saving Issue',
|
message: 'Offline Saving Issue',
|
||||||
desc: 'Changes not saved',
|
description: 'Changes not saved',
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case ApplicationEvent.UnprotectedSessionBegan: {
|
case ApplicationEvent.UnprotectedSessionBegan: {
|
||||||
@@ -551,59 +562,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
return this.application.actions.extensionsInContextOfItem(this.note).length > 0
|
return this.application.actions.extensionsInContextOfItem(this.note).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
showSavingStatus() {
|
|
||||||
this.setStatus({ type: 'saving', message: 'Saving…' }, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
showAllChangesSavedStatus() {
|
|
||||||
this.setState({
|
|
||||||
saveError: false,
|
|
||||||
syncTakingTooLong: false,
|
|
||||||
})
|
|
||||||
this.setStatus({
|
|
||||||
type: 'saved',
|
|
||||||
message: 'All changes saved' + (this.application.sessions.isSignedOut() ? ' offline' : ''),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
showErrorStatus(error?: NoteStatus) {
|
|
||||||
if (!error) {
|
|
||||||
error = {
|
|
||||||
type: 'error',
|
|
||||||
message: 'Sync Unreachable',
|
|
||||||
desc: 'Changes saved offline',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
saveError: true,
|
|
||||||
syncTakingTooLong: false,
|
|
||||||
})
|
|
||||||
this.setStatus(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(status: NoteStatus, wait = true) {
|
|
||||||
if (this.statusTimeout) {
|
|
||||||
clearTimeout(this.statusTimeout)
|
|
||||||
}
|
|
||||||
if (wait) {
|
|
||||||
this.statusTimeout = setTimeout(() => {
|
|
||||||
this.setState({
|
|
||||||
noteStatus: status,
|
|
||||||
})
|
|
||||||
}, MinimumStatusDuration)
|
|
||||||
} else {
|
|
||||||
this.setState({
|
|
||||||
noteStatus: status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelPendingSetStatus() {
|
|
||||||
if (this.statusTimeout) {
|
|
||||||
clearTimeout(this.statusTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
|
onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
|
||||||
if (key !== KeyboardKey.Enter) {
|
if (key !== KeyboardKey.Enter) {
|
||||||
return
|
return
|
||||||
@@ -839,6 +797,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
triggerSyncOnAction = () => {
|
||||||
|
this.controller.syncNow()
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (this.controller.dealloced) {
|
if (this.controller.dealloced) {
|
||||||
return null
|
return null
|
||||||
@@ -923,6 +885,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<NoteStatusIndicator
|
<NoteStatusIndicator
|
||||||
|
note={this.note}
|
||||||
status={this.state.noteStatus}
|
status={this.state.noteStatus}
|
||||||
syncTakingTooLong={this.state.syncTakingTooLong}
|
syncTakingTooLong={this.state.syncTakingTooLong}
|
||||||
updateSavingIndicator={this.state.updateSavingIndicator}
|
updateSavingIndicator={this.state.updateSavingIndicator}
|
||||||
@@ -930,6 +893,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
</div>
|
</div>
|
||||||
{shouldShowConflictsButton && (
|
{shouldShowConflictsButton && (
|
||||||
<Button
|
<Button
|
||||||
|
id={ElementIds.ConflictResolutionButton}
|
||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
primary
|
primary
|
||||||
colorStyle="warning"
|
colorStyle="warning"
|
||||||
@@ -946,10 +910,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
<>
|
<>
|
||||||
<LinkedItemsButton
|
<LinkedItemsButton
|
||||||
linkingController={this.application.linkingController}
|
linkingController={this.application.linkingController}
|
||||||
|
onClick={this.triggerSyncOnAction}
|
||||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||||
/>
|
/>
|
||||||
<ChangeEditorButton
|
<ChangeEditorButton
|
||||||
noteViewController={this.controller}
|
noteViewController={this.controller}
|
||||||
|
onClick={this.triggerSyncOnAction}
|
||||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||||
/>
|
/>
|
||||||
<PinNoteButton
|
<PinNoteButton
|
||||||
@@ -960,6 +926,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
|||||||
)}
|
)}
|
||||||
<NotesOptionsPanel
|
<NotesOptionsPanel
|
||||||
notesController={this.application.notesController}
|
notesController={this.application.notesController}
|
||||||
|
onClick={this.triggerSyncOnAction}
|
||||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||||
onButtonBlur={() => {
|
onButtonBlur={() => {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
|||||||
@@ -122,23 +122,20 @@ export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
|
|||||||
needsAdjustMobileCursor.current = true
|
needsAdjustMobileCursor.current = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastEditorFocusEventSource.current) {
|
application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: lastEditorFocusEventSource.current })
|
||||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
|
||||||
}
|
|
||||||
|
|
||||||
lastEditorFocusEventSource.current = undefined
|
lastEditorFocusEventSource.current = undefined
|
||||||
|
|
||||||
onFocus()
|
onFocus()
|
||||||
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
|
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
|
||||||
|
|
||||||
const onContentBlur = useCallback(
|
const onContentBlur = useCallback(
|
||||||
(event: FocusEvent) => {
|
(event: FocusEvent) => {
|
||||||
if (lastEditorFocusEventSource.current) {
|
|
||||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
|
||||||
}
|
|
||||||
lastEditorFocusEventSource.current = undefined
|
lastEditorFocusEventSource.current = undefined
|
||||||
|
|
||||||
onBlur(event)
|
onBlur(event)
|
||||||
},
|
},
|
||||||
[application, lastEditorFocusEventSource, onBlur],
|
[lastEditorFocusEventSource, onBlur],
|
||||||
)
|
)
|
||||||
|
|
||||||
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
|
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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'
|
||||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||||
|
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||||
|
|
||||||
export const useNoteAttributes = (application: WebApplicationInterface, note: SNNote) => {
|
export const useNoteAttributes = (application: WebApplicationInterface, note: SNNote) => {
|
||||||
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
|
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
|
||||||
@@ -15,10 +16,13 @@ export const useNoteAttributes = (application: WebApplicationInterface, note: SN
|
|||||||
|
|
||||||
const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at])
|
const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at])
|
||||||
|
|
||||||
|
const size = useMemo(() => new Blob([note.text]).size, [note.text])
|
||||||
|
|
||||||
const editor = application.componentManager.editorForNote(note)
|
const editor = application.componentManager.editorForNote(note)
|
||||||
const format = editor.fileType
|
const format = editor.fileType
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
size,
|
||||||
words,
|
words,
|
||||||
characters,
|
characters,
|
||||||
paragraphs,
|
paragraphs,
|
||||||
@@ -35,14 +39,16 @@ export const NoteAttributes: FunctionComponent<{
|
|||||||
note: SNNote
|
note: SNNote
|
||||||
className?: string
|
className?: string
|
||||||
}> = ({ application, note, className }) => {
|
}> = ({ application, note, className }) => {
|
||||||
const { words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes(
|
const { size, words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes(
|
||||||
application,
|
application,
|
||||||
note,
|
note,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const canShowWordCount = typeof words === 'number' && (format === 'txt' || format === 'md')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs', className)}>
|
<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') ? (
|
{canShowWordCount ? (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
{words} words · {characters} characters · {paragraphs} paragraphs
|
{words} words · {characters} characters · {paragraphs} paragraphs
|
||||||
@@ -58,9 +64,12 @@ export const NoteAttributes: FunctionComponent<{
|
|||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<span className="font-semibold">Created:</span> {dateCreated}
|
<span className="font-semibold">Created:</span> {dateCreated}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="mb-1">
|
||||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold">Size:</span> {formatSizeToReadableString(size)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import Icon from '@/Components/Icon/Icon'
|
import Icon from '@/Components/Icon/Icon'
|
||||||
import { FunctionComponent } from 'react'
|
import { FunctionComponent } from 'react'
|
||||||
import { SNNote } from '@standardnotes/snjs'
|
import { SNNote } from '@standardnotes/snjs'
|
||||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
import { LargeNoteThreshold } from '@/Constants/Constants'
|
||||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||||
|
|
||||||
export const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
|
||||||
|
|
||||||
export const NoteSizeWarning: FunctionComponent<{
|
export const NoteSizeWarning: FunctionComponent<{
|
||||||
note: SNNote
|
note: SNNote
|
||||||
}> = ({ note }) => {
|
}> = ({ note }) => {
|
||||||
return new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? (
|
return new Blob([note.text]).size > LargeNoteThreshold ? (
|
||||||
<>
|
<>
|
||||||
<HorizontalSeparator classes="my-2" />
|
<HorizontalSeparator classes="mt-2" />
|
||||||
<div className="bg-warning-faded relative flex items-center px-3 py-3.5">
|
<div className="bg-warning-faded relative flex items-center px-3 py-3.5">
|
||||||
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
|
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
|
||||||
<div className="leading-140% max-w-80% select-none text-warning">
|
<div className="leading-140% max-w-80% select-none text-warning">
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import { ElementIds } from '@/Constants/ElementIDs'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
notesController: NotesController
|
notesController: NotesController
|
||||||
|
onClick?: () => void
|
||||||
onClickPreprocessing?: () => Promise<void>
|
onClickPreprocessing?: () => Promise<void>
|
||||||
onButtonBlur?: (event: FocusEvent) => void
|
onButtonBlur?: (event: FocusEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur }: Props) => {
|
const NotesOptionsPanel = ({ notesController, onClick, onClickPreprocessing, onButtonBlur }: Props) => {
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
@@ -23,7 +24,10 @@ const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur
|
|||||||
await onClickPreprocessing()
|
await onClickPreprocessing()
|
||||||
}
|
}
|
||||||
setIsOpen(willMenuOpen)
|
setIsOpen(willMenuOpen)
|
||||||
}, [onClickPreprocessing, isOpen])
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
}, [isOpen, onClickPreprocessing, onClick])
|
||||||
|
|
||||||
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
||||||
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
|
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const PositionedPopoverContent = ({
|
|||||||
disableClickOutside,
|
disableClickOutside,
|
||||||
disableMobileFullscreenTakeover,
|
disableMobileFullscreenTakeover,
|
||||||
disableFlip,
|
disableFlip,
|
||||||
|
disableApplyingMobileWidth,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
portal = true,
|
portal = true,
|
||||||
offset,
|
offset,
|
||||||
@@ -57,6 +58,7 @@ const PositionedPopoverContent = ({
|
|||||||
side,
|
side,
|
||||||
disableMobileFullscreenTakeover,
|
disableMobileFullscreenTakeover,
|
||||||
disableFlip,
|
disableFlip,
|
||||||
|
disableApplyingMobileWidth,
|
||||||
maxHeightFunction: maxHeight,
|
maxHeightFunction: maxHeight,
|
||||||
offset,
|
offset,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type CommonPopoverProps = {
|
|||||||
togglePopover?: () => void
|
togglePopover?: () => void
|
||||||
disableMobileFullscreenTakeover?: boolean
|
disableMobileFullscreenTakeover?: boolean
|
||||||
disableFlip?: boolean
|
disableFlip?: boolean
|
||||||
|
disableApplyingMobileWidth?: boolean
|
||||||
forceFullHeightOnMobile?: boolean
|
forceFullHeightOnMobile?: boolean
|
||||||
title: string
|
title: string
|
||||||
portal?: boolean
|
portal?: boolean
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ type BlocksEditorProps = {
|
|||||||
spellcheck?: boolean
|
spellcheck?: boolean
|
||||||
ignoreFirstChange?: boolean
|
ignoreFirstChange?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
onFocus?: () => void
|
||||||
|
onBlur?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||||
@@ -54,6 +56,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
spellcheck,
|
spellcheck,
|
||||||
ignoreFirstChange = false,
|
ignoreFirstChange = false,
|
||||||
readonly,
|
readonly,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
}) => {
|
}) => {
|
||||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
|
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -95,6 +99,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
|||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
spellCheck={spellcheck}
|
spellCheck={spellcheck}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onBlur={onBlur}
|
||||||
/>
|
/>
|
||||||
<div className="search-highlight-container pointer-events-none absolute left-0 top-0 h-full w-full" />
|
<div className="search-highlight-container pointer-events-none absolute left-0 top-0 h-full w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
FeatureStatus,
|
FeatureStatus,
|
||||||
GetSuperNoteFeature,
|
GetSuperNoteFeature,
|
||||||
EditorLineHeightValues,
|
EditorLineHeightValues,
|
||||||
|
WebAppEvent,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { BlocksEditor } from './BlocksEditor'
|
import { BlocksEditor } from './BlocksEditor'
|
||||||
@@ -37,6 +38,7 @@ import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
|
|||||||
import AutoFocusPlugin from './Plugins/AutoFocusPlugin'
|
import AutoFocusPlugin from './Plugins/AutoFocusPlugin'
|
||||||
import usePreference from '@/Hooks/usePreference'
|
import usePreference from '@/Hooks/usePreference'
|
||||||
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
||||||
|
import { EditorEventSource } from '@/Types/EditorEventSource'
|
||||||
|
|
||||||
export const SuperNotePreviewCharLimit = 160
|
export const SuperNotePreviewCharLimit = 160
|
||||||
|
|
||||||
@@ -179,6 +181,10 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const onFocus = useCallback(() => {
|
||||||
|
application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: EditorEventSource.UserInteraction })
|
||||||
|
}, [application])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="font-editor relative flex h-full w-full flex-col"
|
className="font-editor relative flex h-full w-full flex-col"
|
||||||
@@ -203,6 +209,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
|||||||
previewLength={SuperNotePreviewCharLimit}
|
previewLength={SuperNotePreviewCharLimit}
|
||||||
spellcheck={spellcheck}
|
spellcheck={spellcheck}
|
||||||
readonly={note.current.locked || readonly}
|
readonly={note.current.locked || readonly}
|
||||||
|
onFocus={onFocus}
|
||||||
>
|
>
|
||||||
<ItemSelectionPlugin currentNote={note.current} />
|
<ItemSelectionPlugin currentNote={note.current} />
|
||||||
<FilePlugin currentNote={note.current} />
|
<FilePlugin currentNote={note.current} />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const MAX_MENU_SIZE_MULTIPLIER = 30
|
|||||||
export const FOCUSABLE_BUT_NOT_TABBABLE = -1
|
export const FOCUSABLE_BUT_NOT_TABBABLE = -1
|
||||||
export const NOTES_LIST_SCROLL_THRESHOLD = 200
|
export const NOTES_LIST_SCROLL_THRESHOLD = 200
|
||||||
|
|
||||||
|
export const MILLISECONDS_IN_A_SECOND = 1000
|
||||||
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24
|
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24
|
||||||
export const DAYS_IN_A_WEEK = 7
|
export const DAYS_IN_A_WEEK = 7
|
||||||
export const DAYS_IN_A_YEAR = 365
|
export const DAYS_IN_A_YEAR = 365
|
||||||
@@ -58,3 +59,5 @@ export const SupportsPassiveListeners = (() => {
|
|||||||
}
|
}
|
||||||
return supportsPassive
|
return supportsPassive
|
||||||
})()
|
})()
|
||||||
|
|
||||||
|
export const LargeNoteThreshold = 1.5 * BYTES_IN_ONE_MEGABYTE
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ export const ElementIds = {
|
|||||||
NoteStatusTooltip: 'note-status-tooltip',
|
NoteStatusTooltip: 'note-status-tooltip',
|
||||||
ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
|
ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
|
||||||
SearchBar: 'search-bar',
|
SearchBar: 'search-bar',
|
||||||
|
ConflictResolutionButton: 'conflict-resolution-button',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
|
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
|
||||||
import { destroyAllObjectProperties, isMobileScreen } from '@/Utils'
|
import { debounce, destroyAllObjectProperties, isMobileScreen } from '@/Utils'
|
||||||
import {
|
import {
|
||||||
ApplicationEvent,
|
ApplicationEvent,
|
||||||
CollectionSort,
|
CollectionSort,
|
||||||
@@ -216,7 +216,7 @@ export class ItemListController
|
|||||||
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
|
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
|
||||||
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
|
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
|
||||||
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
|
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
|
||||||
eventBus.addEventHandler(this, WebAppEvent.EditorFocused)
|
eventBus.addEventHandler(this, WebAppEvent.EditorDidFocus)
|
||||||
|
|
||||||
this.disposers.push(
|
this.disposers.push(
|
||||||
reaction(
|
reaction(
|
||||||
@@ -276,9 +276,9 @@ export class ItemListController
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
window.onresize = () => {
|
window.onresize = debounce(() => {
|
||||||
this.resetPagination(true)
|
this.resetPagination(true)
|
||||||
}
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
getPersistableValue = (): SelectionControllerPersistableValue => {
|
getPersistableValue = (): SelectionControllerPersistableValue => {
|
||||||
@@ -325,7 +325,7 @@ export class ItemListController
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case WebAppEvent.EditorFocused: {
|
case WebAppEvent.EditorDidFocus: {
|
||||||
this.setShowDisplayOptionsMenu(false)
|
this.setShowDisplayOptionsMenu(false)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,18 @@ import {
|
|||||||
ItemManagerInterface,
|
ItemManagerInterface,
|
||||||
MutatorClientInterface,
|
MutatorClientInterface,
|
||||||
SessionsClientInterface,
|
SessionsClientInterface,
|
||||||
|
SyncMode,
|
||||||
SyncServiceInterface,
|
SyncServiceInterface,
|
||||||
} from '@standardnotes/snjs'
|
} from '@standardnotes/snjs'
|
||||||
import { Deferred } from '@standardnotes/utils'
|
import { Deferred } from '@standardnotes/utils'
|
||||||
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
|
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
|
||||||
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
|
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
|
||||||
|
import { LargeNoteThreshold } from '@/Constants/Constants'
|
||||||
|
import { NoteStatus } from '@/Components/NoteView/NoteStatusIndicator'
|
||||||
|
import { action, makeObservable, observable, runInAction } from 'mobx'
|
||||||
|
|
||||||
const NotePreviewCharLimit = 160
|
const NotePreviewCharLimit = 160
|
||||||
|
const MinimumStatusChangeDuration = 400
|
||||||
|
|
||||||
export type NoteSaveFunctionParams = {
|
export type NoteSaveFunctionParams = {
|
||||||
title?: string
|
title?: string
|
||||||
@@ -22,13 +27,16 @@ export type NoteSaveFunctionParams = {
|
|||||||
previews?: { previewPlain: string; previewHtml?: string }
|
previews?: { previewPlain: string; previewHtml?: string }
|
||||||
customMutate?: (mutator: NoteMutator) => void
|
customMutate?: (mutator: NoteMutator) => void
|
||||||
onLocalPropagationComplete?: () => void
|
onLocalPropagationComplete?: () => void
|
||||||
onRemoteSyncComplete?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NoteSyncController {
|
export class NoteSyncController {
|
||||||
savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
|
savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
|
||||||
|
|
||||||
private saveTimeout?: ReturnType<typeof setTimeout>
|
private syncTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
private largeNoteSyncTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
private statusChangeTimeout?: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
status: NoteStatus | undefined = undefined
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private item: SNNote,
|
private item: SNNote,
|
||||||
@@ -38,43 +46,123 @@ export class NoteSyncController {
|
|||||||
private sync: SyncServiceInterface,
|
private sync: SyncServiceInterface,
|
||||||
private alerts: AlertService,
|
private alerts: AlertService,
|
||||||
private _isNativeMobileWeb: IsNativeMobileWeb,
|
private _isNativeMobileWeb: IsNativeMobileWeb,
|
||||||
) {}
|
) {
|
||||||
|
makeObservable(this, {
|
||||||
|
status: observable,
|
||||||
|
setStatus: action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(status: NoteStatus, wait = true) {
|
||||||
|
if (this.statusChangeTimeout) {
|
||||||
|
clearTimeout(this.statusChangeTimeout)
|
||||||
|
}
|
||||||
|
if (wait) {
|
||||||
|
this.statusChangeTimeout = setTimeout(() => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.status = status
|
||||||
|
})
|
||||||
|
}, MinimumStatusChangeDuration)
|
||||||
|
} else {
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSavingStatus() {
|
||||||
|
this.setStatus(
|
||||||
|
{
|
||||||
|
type: 'saving',
|
||||||
|
message: 'Saving…',
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showAllChangesSavedStatus() {
|
||||||
|
this.setStatus({
|
||||||
|
type: 'saved',
|
||||||
|
message: 'All changes saved' + (this.sessions.isSignedOut() ? ' offline' : ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
showWaitingToSyncLargeNoteStatus() {
|
||||||
|
this.setStatus(
|
||||||
|
{
|
||||||
|
type: 'waiting',
|
||||||
|
message: 'Note is too large',
|
||||||
|
description: 'It will be synced less often. Changes will be saved offline normally.',
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
showErrorStatus(error?: NoteStatus) {
|
||||||
|
if (!error) {
|
||||||
|
error = {
|
||||||
|
type: 'error',
|
||||||
|
message: 'Sync Unreachable',
|
||||||
|
description: 'Changes saved offline',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.setStatus(error)
|
||||||
|
}
|
||||||
|
|
||||||
setItem(item: SNNote) {
|
setItem(item: SNNote) {
|
||||||
this.item = item
|
this.item = item
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit() {
|
deinit() {
|
||||||
if (this.saveTimeout) {
|
if (this.syncTimeout) {
|
||||||
clearTimeout(this.saveTimeout)
|
clearTimeout(this.syncTimeout)
|
||||||
|
}
|
||||||
|
if (this.largeNoteSyncTimeout) {
|
||||||
|
clearTimeout(this.largeNoteSyncTimeout)
|
||||||
|
}
|
||||||
|
if (this.statusChangeTimeout) {
|
||||||
|
clearTimeout(this.statusChangeTimeout)
|
||||||
}
|
}
|
||||||
if (this.savingLocallyPromise) {
|
if (this.savingLocallyPromise) {
|
||||||
this.savingLocallyPromise.reject()
|
this.savingLocallyPromise.reject()
|
||||||
}
|
}
|
||||||
this.savingLocallyPromise = null
|
this.savingLocallyPromise = null
|
||||||
this.saveTimeout = undefined
|
this.largeNoteSyncTimeout = undefined
|
||||||
|
this.syncTimeout = undefined
|
||||||
|
this.status = undefined
|
||||||
|
this.statusChangeTimeout = undefined
|
||||||
;(this.item as unknown) = undefined
|
;(this.item as unknown) = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isLargeNote(text: string): boolean {
|
||||||
|
const textByteSize = new Blob([text]).size
|
||||||
|
return textByteSize > LargeNoteThreshold
|
||||||
|
}
|
||||||
|
|
||||||
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
|
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
|
||||||
this.savingLocallyPromise = Deferred<void>()
|
this.savingLocallyPromise = Deferred<void>()
|
||||||
|
|
||||||
if (this.saveTimeout) {
|
if (this.syncTimeout) {
|
||||||
clearTimeout(this.saveTimeout)
|
clearTimeout(this.syncTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
|
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
|
||||||
|
const syncDebounceMs = noDebounce
|
||||||
const syncDebouceMs = noDebounce
|
|
||||||
? EditorSaveTimeoutDebounce.ImmediateChange
|
? EditorSaveTimeoutDebounce.ImmediateChange
|
||||||
: this._isNativeMobileWeb.execute().getValue()
|
: this._isNativeMobileWeb.execute().getValue()
|
||||||
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
||||||
: EditorSaveTimeoutDebounce.Desktop
|
: EditorSaveTimeoutDebounce.Desktop
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.saveTimeout = setTimeout(() => {
|
const isLargeNote = this.isLargeNote(params.text ? params.text : this.item.text)
|
||||||
void this.undebouncedSave({
|
|
||||||
|
if (isLargeNote) {
|
||||||
|
this.showWaitingToSyncLargeNoteStatus()
|
||||||
|
this.queueLargeNoteSyncIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncTimeout = setTimeout(() => {
|
||||||
|
void this.undebouncedMutateAndSync({
|
||||||
...params,
|
...params,
|
||||||
|
localOnly: isLargeNote,
|
||||||
onLocalPropagationComplete: () => {
|
onLocalPropagationComplete: () => {
|
||||||
if (this.savingLocallyPromise) {
|
if (this.savingLocallyPromise) {
|
||||||
this.savingLocallyPromise.resolve()
|
this.savingLocallyPromise.resolve()
|
||||||
@@ -82,11 +170,34 @@ export class NoteSyncController {
|
|||||||
resolve()
|
resolve()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, syncDebouceMs)
|
}, syncDebounceMs)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async undebouncedSave(params: NoteSaveFunctionParams): Promise<void> {
|
private queueLargeNoteSyncIfNeeded(): void {
|
||||||
|
const isAlreadyAQueuedLargeNoteSync = this.largeNoteSyncTimeout !== undefined
|
||||||
|
|
||||||
|
if (!isAlreadyAQueuedLargeNoteSync) {
|
||||||
|
const isSignedIn = this.sessions.isSignedIn()
|
||||||
|
const timeout = isSignedIn ? EditorSaveTimeoutDebounce.LargeNote : EditorSaveTimeoutDebounce.ImmediateChange
|
||||||
|
|
||||||
|
this.largeNoteSyncTimeout = setTimeout(() => {
|
||||||
|
this.largeNoteSyncTimeout = undefined
|
||||||
|
void this.performSyncOfLargeItem()
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performSyncOfLargeItem(): Promise<void> {
|
||||||
|
const item = this.items.findItem(this.item.uuid)
|
||||||
|
if (!item || !item.dirty) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.sync.sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async undebouncedMutateAndSync(params: NoteSaveFunctionParams & { localOnly: boolean }): Promise<void> {
|
||||||
if (!this.items.findItem(this.item.uuid)) {
|
if (!this.items.findItem(this.item.uuid)) {
|
||||||
void this.alerts.alert(InfoStrings.InvalidNote)
|
void this.alerts.alert(InfoStrings.InvalidNote)
|
||||||
return
|
return
|
||||||
@@ -123,10 +234,17 @@ export class NoteSyncController {
|
|||||||
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
|
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
|
||||||
)
|
)
|
||||||
|
|
||||||
void this.sync.sync().then(() => {
|
void this.sync.sync({ mode: params.localOnly ? SyncMode.LocalOnly : undefined })
|
||||||
params.onRemoteSyncComplete?.()
|
|
||||||
})
|
this.queueLargeNoteSyncIfNeeded()
|
||||||
|
|
||||||
params.onLocalPropagationComplete?.()
|
params.onLocalPropagationComplete?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public syncOnlyIfLargeNote(): void {
|
||||||
|
const isLargeNote = this.isLargeNote(this.item.text)
|
||||||
|
if (isLargeNote) {
|
||||||
|
void this.performSyncOfLargeItem()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
packages/web/src/javascripts/Utils/GetRelativeTimeString.ts
Normal file
28
packages/web/src/javascripts/Utils/GetRelativeTimeString.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
|
import RelativeTimePlugin from 'dayjs/plugin/relativeTime'
|
||||||
|
import UpdateLocalePlugin from 'dayjs/plugin/updateLocale'
|
||||||
|
|
||||||
|
dayjs.extend(UpdateLocalePlugin)
|
||||||
|
dayjs.extend(RelativeTimePlugin)
|
||||||
|
|
||||||
|
dayjs.updateLocale('en', {
|
||||||
|
relativeTime: {
|
||||||
|
future: 'in %s',
|
||||||
|
past: '%s ago',
|
||||||
|
s: '%ds',
|
||||||
|
m: 'a minute',
|
||||||
|
mm: '%d minutes',
|
||||||
|
h: 'an hour',
|
||||||
|
hh: '%d hours',
|
||||||
|
d: 'a day',
|
||||||
|
dd: '%d days',
|
||||||
|
M: 'a month',
|
||||||
|
MM: '%d months',
|
||||||
|
y: 'a year',
|
||||||
|
yy: '%d years',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getRelativeTimeString(date: Parameters<typeof dayjs>[0]): string {
|
||||||
|
return dayjs(date).fromNow()
|
||||||
|
}
|
||||||
@@ -49,13 +49,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-view-options-buttons,
|
.note-view-options-buttons,
|
||||||
.note-status-tooltip-container {
|
.note-status-tooltip-container,
|
||||||
|
#conflict-resolution-button {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#editor-title-bar:hover .note-view-options-buttons,
|
#editor-title-bar:hover {
|
||||||
#editor-title-bar:hover .note-status-tooltip-container {
|
.note-view-options-buttons,
|
||||||
opacity: 1;
|
.note-status-tooltip-container,
|
||||||
|
#conflict-resolution-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user