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 ClearCircleFilledIcon from './ic-clear-circle-filled.svg'
|
||||
import CloseCircleFilledIcon from './ic-close-circle-filled.svg'
|
||||
import ClockIcon from './ic-clock.svg'
|
||||
import CloseIcon from './ic-close.svg'
|
||||
import CloudOffIcon from './ic-cloud-off.svg'
|
||||
import CodeIcon from './ic-code.svg'
|
||||
@@ -263,6 +264,7 @@ export {
|
||||
CircleIcon,
|
||||
ClearCircleFilledIcon,
|
||||
CloseCircleFilledIcon,
|
||||
ClockIcon,
|
||||
CloseIcon,
|
||||
CloudOffIcon,
|
||||
CodeIcon,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type IconType =
|
||||
| 'chevron-right'
|
||||
| 'chevron-up'
|
||||
| 'clear-circle-filled'
|
||||
| 'clock'
|
||||
| 'close'
|
||||
| 'cloud-off'
|
||||
| 'code-2'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export enum WebAppEvent {
|
||||
NewUpdateAvailable = 'NewUpdateAvailable',
|
||||
EditorFocused = 'EditorFocused',
|
||||
EditorDidFocus = 'EditorDidFocus',
|
||||
BeganBackupDownload = 'BeganBackupDownload',
|
||||
EndedBackupDownload = 'EndedBackupDownload',
|
||||
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.
|
||||
*/
|
||||
DownloadFirst = 'DownloadFirst',
|
||||
LocalOnly = 'LocalOnly',
|
||||
}
|
||||
|
||||
@@ -896,6 +896,12 @@ export class SyncService
|
||||
|
||||
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()
|
||||
|
||||
if (!shouldExecuteSync) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export class VisibilityObserver {
|
||||
*/
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange)
|
||||
window.addEventListener('focus', this.onFocusEvent, false)
|
||||
window.addEventListener('blur', this.onBlurEvent, false)
|
||||
}
|
||||
|
||||
onVisibilityChange = () => {
|
||||
@@ -23,6 +24,10 @@ export class VisibilityObserver {
|
||||
this.notifyEvent(WebAppEvent.WindowDidFocus)
|
||||
}
|
||||
|
||||
onBlurEvent = () => {
|
||||
this.notifyEvent(WebAppEvent.WindowDidBlur)
|
||||
}
|
||||
|
||||
private notifyEvent(event: WebAppEvent): void {
|
||||
if (this.raceTimeout) {
|
||||
clearTimeout(this.raceTimeout)
|
||||
@@ -35,6 +40,7 @@ export class VisibilityObserver {
|
||||
deinit(): void {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange)
|
||||
window.removeEventListener('focus', this.onFocusEvent)
|
||||
window.removeEventListener('blur', this.onBlurEvent)
|
||||
;(this.onEvent as unknown) = undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
|
||||
useEffect(() => {
|
||||
const removeObserver = application.addWebEventObserver(async (eventName) => {
|
||||
if (eventName === WebAppEvent.WindowDidFocus) {
|
||||
if (eventName === WebAppEvent.WindowDidFocus || eventName === WebAppEvent.WindowDidBlur) {
|
||||
if (!(await application.protections.isLocked())) {
|
||||
application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import { NoteType, noteTypeForEditorIdentifier } from '@standardnotes/snjs'
|
||||
|
||||
type Props = {
|
||||
noteViewController?: NoteViewController
|
||||
onClick?: () => void
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClickPreprocessing }: Props) => {
|
||||
const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClick, onClickPreprocessing }: Props) => {
|
||||
const application = useApplication()
|
||||
|
||||
const note = application.notesController.firstSelectedNote
|
||||
@@ -48,7 +49,10 @@ const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onCl
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
setIsOpen(willMenuOpen)
|
||||
}, [onClickPreprocessing, isOpen])
|
||||
if (onClick) {
|
||||
onClick()
|
||||
}
|
||||
}, [isOpen, onClickPreprocessing, onClick])
|
||||
|
||||
useEffect(() => {
|
||||
return application.keyboardService.addCommandHandler({
|
||||
|
||||
@@ -70,7 +70,7 @@ class Footer extends AbstractComponent<Props, State> {
|
||||
case WebAppEvent.NewUpdateAvailable:
|
||||
this.onNewUpdateAvailable()
|
||||
break
|
||||
case WebAppEvent.EditorFocused:
|
||||
case WebAppEvent.EditorDidFocus:
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((data as any).eventSource === EditorEventSource.UserInteraction) {
|
||||
this.closeAccountMenu()
|
||||
|
||||
@@ -84,6 +84,7 @@ export const IconNameToSvgMapping = {
|
||||
bold: icons.BoldIcon,
|
||||
camera: icons.CameraIcon,
|
||||
check: icons.CheckIcon,
|
||||
clock: icons.ClockIcon,
|
||||
close: icons.CloseIcon,
|
||||
code: icons.CodeIcon,
|
||||
comment: icons.FeedbackIcon,
|
||||
|
||||
@@ -7,10 +7,11 @@ import LinkedItemsPanel from './LinkedItemsPanel'
|
||||
|
||||
type Props = {
|
||||
linkingController: LinkingController
|
||||
onClick?: () => void
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) => {
|
||||
const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => {
|
||||
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -20,7 +21,10 @@ const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) =
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
setIsLinkingPanelOpen(willMenuOpen)
|
||||
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen])
|
||||
if (onClick) {
|
||||
onClick()
|
||||
}
|
||||
}, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen])
|
||||
|
||||
if (!activeItem) {
|
||||
return null
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { MILLISECONDS_IN_A_SECOND } from '@/Constants/Constants'
|
||||
|
||||
export const EditorSaveTimeoutDebounce = {
|
||||
Desktop: 350,
|
||||
ImmediateChange: 100,
|
||||
NativeMobileWeb: 700,
|
||||
LargeNote: 60 * MILLISECONDS_IN_A_SECOND,
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ export class ItemGroupController {
|
||||
controller: NoteViewController | FileViewController,
|
||||
{ notify = true }: { notify: boolean } = { notify: true },
|
||||
): void {
|
||||
if (controller instanceof NoteViewController) {
|
||||
controller.syncOnlyIfLargeNote()
|
||||
}
|
||||
controller.deinit()
|
||||
|
||||
removeFromArray(this.itemControllers, controller)
|
||||
|
||||
@@ -28,6 +28,9 @@ describe('note view controller', () => {
|
||||
createTemplateItem: jest.fn().mockReturnValue({} as SNNote),
|
||||
} as unknown as jest.Mocked<ItemManagerInterface>,
|
||||
mutator: {} as jest.Mocked<MutatorClientInterface>,
|
||||
sessions: {
|
||||
isSignedIn: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
} as unknown as jest.Mocked<WebApplication>
|
||||
|
||||
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerO
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
|
||||
import { IsNativeMobileWeb } from '@standardnotes/ui-services'
|
||||
import { NoteStatus } from '../NoteStatusIndicator'
|
||||
|
||||
export type EditorValues = {
|
||||
title: string
|
||||
@@ -219,4 +220,28 @@ export class NoteViewController implements ItemViewControllerInterface {
|
||||
|
||||
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 { ReactNode, useCallback, useState } from 'react'
|
||||
import { IconType, PrefKey, PrefDefaults } from '@standardnotes/snjs'
|
||||
import { ReactNode, useCallback, useRef, useState } from 'react'
|
||||
import { IconType, PrefKey, PrefDefaults, SNNote } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
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 = {
|
||||
type: 'saving' | 'saved' | 'error'
|
||||
type: 'saving' | 'saved' | 'error' | 'waiting'
|
||||
message: string
|
||||
desc?: string
|
||||
description?: ReactNode
|
||||
}
|
||||
|
||||
const IndicatorWithTooltip = ({
|
||||
className,
|
||||
onClick,
|
||||
onBlur,
|
||||
icon,
|
||||
isTooltipVisible,
|
||||
setIsTooltipVisible,
|
||||
children,
|
||||
animateIcon = false,
|
||||
}: {
|
||||
className: string
|
||||
onClick: () => void
|
||||
onBlur: () => void
|
||||
icon: IconType
|
||||
isTooltipVisible: boolean
|
||||
setIsTooltipVisible: React.Dispatch<React.SetStateAction<boolean>>
|
||||
children: ReactNode
|
||||
animateIcon?: boolean
|
||||
}) => (
|
||||
<div className="note-status-tooltip-container relative">
|
||||
<button
|
||||
className={classNames('peer flex h-5 w-5 items-center justify-center rounded-full', className)}
|
||||
onClick={onClick}
|
||||
onBlur={onBlur}
|
||||
aria-describedby={ElementIds.NoteStatusTooltip}
|
||||
>
|
||||
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
||||
<span className="sr-only">Note sync status</span>
|
||||
</button>
|
||||
<div
|
||||
id={ElementIds.NoteStatusTooltip}
|
||||
className={classNames(
|
||||
isTooltipVisible ? '' : 'hidden',
|
||||
'absolute right-0 top-full min-w-[90vw] translate-x-2 translate-y-1 select-none rounded border border-border',
|
||||
'bg-default px-3 py-1.5 text-left peer-hover:block peer-focus:block md:min-w-max',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<div className="note-status-tooltip-container">
|
||||
<button
|
||||
className={classNames('peer flex h-5 w-5 cursor-pointer items-center justify-center rounded-full', className)}
|
||||
onClick={onClick}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
||||
<VisuallyHidden>Note sync status</VisuallyHidden>
|
||||
</button>
|
||||
<Popover
|
||||
title="Note sync status"
|
||||
open={isTooltipVisible}
|
||||
togglePopover={() => setIsTooltipVisible((visible) => !visible)}
|
||||
className="px-3 py-2"
|
||||
containerClassName="!min-w-0 !w-auto max-w-[90vw]"
|
||||
anchorElement={buttonRef}
|
||||
side="bottom"
|
||||
align="center"
|
||||
offset={6}
|
||||
disableMobileFullscreenTakeover
|
||||
disableApplyingMobileWidth
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
note: SNNote
|
||||
status: NoteStatus | undefined
|
||||
syncTakingTooLong: boolean
|
||||
updateSavingIndicator?: boolean
|
||||
}
|
||||
|
||||
const NoteStatusIndicator = ({
|
||||
note,
|
||||
status,
|
||||
syncTakingTooLong,
|
||||
updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
|
||||
@@ -65,7 +79,9 @@ const NoteStatusIndicator = ({
|
||||
const application = useApplication()
|
||||
const [isTooltipVisible, setIsTooltipVisible] = useState(false)
|
||||
|
||||
const onBlur = () => setIsTooltipVisible(false)
|
||||
const toggleTooltip = useCallback(() => {
|
||||
setIsTooltipVisible((visible) => !visible)
|
||||
}, [])
|
||||
|
||||
const toggleShowPreference = useCallback(() => {
|
||||
void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator)
|
||||
@@ -79,13 +95,13 @@ const NoteStatusIndicator = ({
|
||||
return (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-danger text-danger-contrast"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon="warning"
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -94,15 +110,15 @@ const NoteStatusIndicator = ({
|
||||
return (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-warning text-warning-contrast"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon={status && status.type === 'saving' ? 'sync' : 'warning'}
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
{status ? (
|
||||
<>
|
||||
<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>
|
||||
@@ -117,15 +133,35 @@ const NoteStatusIndicator = ({
|
||||
className={classNames(
|
||||
status.type === 'saving' && 'bg-contrast',
|
||||
status.type === 'saved' && 'bg-success text-success-contrast',
|
||||
status.type === 'waiting' && 'bg-warning text-warning-contrast',
|
||||
)}
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
icon={status.type === 'saving' ? 'sync' : 'check'}
|
||||
onClick={toggleTooltip}
|
||||
icon={status.type === 'saving' ? 'sync' : status.type === 'waiting' ? 'clock' : 'check'}
|
||||
animateIcon={status.type === 'saving'}
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -133,15 +169,17 @@ const NoteStatusIndicator = ({
|
||||
return (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-contrast text-passive-1"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon="info"
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteStatusIndicator
|
||||
export default observer(NoteStatusIndicator)
|
||||
|
||||
@@ -54,8 +54,6 @@ import ModalOverlay from '../Modal/ModalOverlay'
|
||||
import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
|
||||
import Icon from '../Icon/Icon'
|
||||
|
||||
const MinimumStatusDuration = 400
|
||||
|
||||
function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] {
|
||||
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
|
||||
}
|
||||
@@ -83,6 +81,7 @@ type State = {
|
||||
updateSavingIndicator?: boolean
|
||||
editorFeatureIdentifier?: string
|
||||
noteType?: NoteType
|
||||
focusModeEnabled?: boolean
|
||||
|
||||
conflictedNotes: SNNote[]
|
||||
showConflictResolutionModal: boolean
|
||||
@@ -91,7 +90,6 @@ type State = {
|
||||
class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
readonly controller!: NoteViewController
|
||||
|
||||
private statusTimeout?: NodeJS.Timeout
|
||||
onEditorComponentLoad?: () => void
|
||||
|
||||
private removeTrashKeyObserver?: () => void
|
||||
@@ -167,8 +165,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
|
||||
|
||||
this.onEditorComponentLoad = undefined
|
||||
|
||||
this.statusTimeout = undefined
|
||||
;(this.onPanelResizeFinish as unknown) = undefined
|
||||
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
|
||||
;(this.editorComponentViewerRequestsReload as unknown) = undefined
|
||||
@@ -235,9 +231,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
})
|
||||
|
||||
this.autorun(() => {
|
||||
const syncStatus = this.controller.syncStatus
|
||||
|
||||
const isFocusModeEnabled = this.application.paneController.focusModeEnabled
|
||||
const didFocusModeChange = this.state.focusModeEnabled !== isFocusModeEnabled
|
||||
|
||||
this.setState({
|
||||
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)
|
||||
@@ -327,16 +336,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
const currentStatus = this.controller.syncStatus
|
||||
const isWaitingToSyncLargeNote = currentStatus?.type === 'waiting'
|
||||
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()
|
||||
if (note.dirty || shouldShowSavingStatus) {
|
||||
this.showSavingStatus()
|
||||
} else if (this.state.noteStatus && shouldShowSavedStatus) {
|
||||
this.showAllChangesSavedStatus()
|
||||
if (hasStartedNewSync) {
|
||||
this.controller.showSavingStatus()
|
||||
} else if (this.state.noteStatus && shouldShowSavedStatus && !isWaitingToSyncLargeNote) {
|
||||
this.controller.showAllChangesSavedStatus()
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus()
|
||||
} else if (note.lastSyncBegan) {
|
||||
this.controller.showSavingStatus()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,7 +383,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
const isInErrorState = this.state.saveError
|
||||
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
||||
if (!this.note.dirty && isInErrorState) {
|
||||
this.showAllChangesSavedStatus()
|
||||
this.controller.showAllChangesSavedStatus()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -383,14 +394,14 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
* and we don't want to display an error here.
|
||||
*/
|
||||
if (this.note.dirty) {
|
||||
this.showErrorStatus()
|
||||
this.controller.showErrorSyncStatus()
|
||||
}
|
||||
break
|
||||
case ApplicationEvent.LocalDatabaseWriteError:
|
||||
this.showErrorStatus({
|
||||
this.controller.showErrorSyncStatus({
|
||||
type: 'error',
|
||||
message: 'Offline Saving Issue',
|
||||
desc: 'Changes not saved',
|
||||
description: 'Changes not saved',
|
||||
})
|
||||
break
|
||||
case ApplicationEvent.UnprotectedSessionBegan: {
|
||||
@@ -551,59 +562,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
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 }) => {
|
||||
if (key !== KeyboardKey.Enter) {
|
||||
return
|
||||
@@ -839,6 +797,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
}))
|
||||
}
|
||||
|
||||
triggerSyncOnAction = () => {
|
||||
this.controller.syncNow()
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.controller.dealloced) {
|
||||
return null
|
||||
@@ -923,6 +885,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
/>
|
||||
</div>
|
||||
<NoteStatusIndicator
|
||||
note={this.note}
|
||||
status={this.state.noteStatus}
|
||||
syncTakingTooLong={this.state.syncTakingTooLong}
|
||||
updateSavingIndicator={this.state.updateSavingIndicator}
|
||||
@@ -930,6 +893,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
</div>
|
||||
{shouldShowConflictsButton && (
|
||||
<Button
|
||||
id={ElementIds.ConflictResolutionButton}
|
||||
className="flex items-center"
|
||||
primary
|
||||
colorStyle="warning"
|
||||
@@ -946,10 +910,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
<>
|
||||
<LinkedItemsButton
|
||||
linkingController={this.application.linkingController}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
<ChangeEditorButton
|
||||
noteViewController={this.controller}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
<PinNoteButton
|
||||
@@ -960,6 +926,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
)}
|
||||
<NotesOptionsPanel
|
||||
notesController={this.application.notesController}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
onButtonBlur={() => {
|
||||
this.setState({
|
||||
|
||||
@@ -122,23 +122,20 @@ export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
|
||||
needsAdjustMobileCursor.current = true
|
||||
}
|
||||
|
||||
if (lastEditorFocusEventSource.current) {
|
||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
||||
}
|
||||
application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: lastEditorFocusEventSource.current })
|
||||
|
||||
lastEditorFocusEventSource.current = undefined
|
||||
|
||||
onFocus()
|
||||
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
|
||||
|
||||
const onContentBlur = useCallback(
|
||||
(event: FocusEvent) => {
|
||||
if (lastEditorFocusEventSource.current) {
|
||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
||||
}
|
||||
lastEditorFocusEventSource.current = undefined
|
||||
|
||||
onBlur(event)
|
||||
},
|
||||
[application, lastEditorFocusEventSource, onBlur],
|
||||
[lastEditorFocusEventSource, onBlur],
|
||||
)
|
||||
|
||||
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatDateForContextMenu } from '@/Utils/DateUtils'
|
||||
import { calculateReadTime } from './Utils/calculateReadTime'
|
||||
import { countNoteAttributes } from './Utils/countNoteAttributes'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
import { formatSizeToReadableString } from '@standardnotes/filepicker'
|
||||
|
||||
export const useNoteAttributes = (application: WebApplicationInterface, note: SNNote) => {
|
||||
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 size = useMemo(() => new Blob([note.text]).size, [note.text])
|
||||
|
||||
const editor = application.componentManager.editorForNote(note)
|
||||
const format = editor.fileType
|
||||
|
||||
return {
|
||||
size,
|
||||
words,
|
||||
characters,
|
||||
paragraphs,
|
||||
@@ -35,14 +39,16 @@ export const NoteAttributes: FunctionComponent<{
|
||||
note: SNNote
|
||||
className?: string
|
||||
}> = ({ application, note, className }) => {
|
||||
const { words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes(
|
||||
const { size, words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes(
|
||||
application,
|
||||
note,
|
||||
)
|
||||
|
||||
const canShowWordCount = typeof words === 'number' && (format === 'txt' || format === 'md')
|
||||
|
||||
return (
|
||||
<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">
|
||||
{words} words · {characters} characters · {paragraphs} paragraphs
|
||||
@@ -58,9 +64,12 @@ export const NoteAttributes: FunctionComponent<{
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Created:</span> {dateCreated}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<span className="font-semibold">Note ID:</span> {note.uuid}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">Size:</span> {formatSizeToReadableString(size)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { FunctionComponent } from 'react'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
|
||||
import { LargeNoteThreshold } from '@/Constants/Constants'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
|
||||
export const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
|
||||
|
||||
export const NoteSizeWarning: FunctionComponent<{
|
||||
note: SNNote
|
||||
}> = ({ 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">
|
||||
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
|
||||
<div className="leading-140% max-w-80% select-none text-warning">
|
||||
|
||||
@@ -9,11 +9,12 @@ import { ElementIds } from '@/Constants/ElementIDs'
|
||||
|
||||
type Props = {
|
||||
notesController: NotesController
|
||||
onClick?: () => void
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
onButtonBlur?: (event: FocusEvent) => void
|
||||
}
|
||||
|
||||
const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur }: Props) => {
|
||||
const NotesOptionsPanel = ({ notesController, onClick, onClickPreprocessing, onButtonBlur }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
@@ -23,7 +24,10 @@ const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur
|
||||
await onClickPreprocessing()
|
||||
}
|
||||
setIsOpen(willMenuOpen)
|
||||
}, [onClickPreprocessing, isOpen])
|
||||
if (onClick) {
|
||||
onClick()
|
||||
}
|
||||
}, [isOpen, onClickPreprocessing, onClick])
|
||||
|
||||
const [disableClickOutside, setDisableClickOutside] = useState(false)
|
||||
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const PositionedPopoverContent = ({
|
||||
disableClickOutside,
|
||||
disableMobileFullscreenTakeover,
|
||||
disableFlip,
|
||||
disableApplyingMobileWidth,
|
||||
maxHeight,
|
||||
portal = true,
|
||||
offset,
|
||||
@@ -57,6 +58,7 @@ const PositionedPopoverContent = ({
|
||||
side,
|
||||
disableMobileFullscreenTakeover,
|
||||
disableFlip,
|
||||
disableApplyingMobileWidth,
|
||||
maxHeightFunction: maxHeight,
|
||||
offset,
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ type CommonPopoverProps = {
|
||||
togglePopover?: () => void
|
||||
disableMobileFullscreenTakeover?: boolean
|
||||
disableFlip?: boolean
|
||||
disableApplyingMobileWidth?: boolean
|
||||
forceFullHeightOnMobile?: boolean
|
||||
title: string
|
||||
portal?: boolean
|
||||
|
||||
@@ -44,6 +44,8 @@ type BlocksEditorProps = {
|
||||
spellcheck?: boolean
|
||||
ignoreFirstChange?: boolean
|
||||
readonly?: boolean
|
||||
onFocus?: () => void
|
||||
onBlur?: () => void
|
||||
}
|
||||
|
||||
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
@@ -54,6 +56,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
spellcheck,
|
||||
ignoreFirstChange = false,
|
||||
readonly,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}) => {
|
||||
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
|
||||
const handleChange = useCallback(
|
||||
@@ -95,6 +99,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
|
||||
className,
|
||||
)}
|
||||
spellCheck={spellcheck}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<div className="search-highlight-container pointer-events-none absolute left-0 top-0 h-full w-full" />
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FeatureStatus,
|
||||
GetSuperNoteFeature,
|
||||
EditorLineHeightValues,
|
||||
WebAppEvent,
|
||||
} from '@standardnotes/snjs'
|
||||
import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { BlocksEditor } from './BlocksEditor'
|
||||
@@ -37,6 +38,7 @@ import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
|
||||
import AutoFocusPlugin from './Plugins/AutoFocusPlugin'
|
||||
import usePreference from '@/Hooks/usePreference'
|
||||
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
||||
import { EditorEventSource } from '@/Types/EditorEventSource'
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="font-editor relative flex h-full w-full flex-col"
|
||||
@@ -203,6 +209,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
||||
previewLength={SuperNotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
readonly={note.current.locked || readonly}
|
||||
onFocus={onFocus}
|
||||
>
|
||||
<ItemSelectionPlugin 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 NOTES_LIST_SCROLL_THRESHOLD = 200
|
||||
|
||||
export const MILLISECONDS_IN_A_SECOND = 1000
|
||||
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24
|
||||
export const DAYS_IN_A_WEEK = 7
|
||||
export const DAYS_IN_A_YEAR = 365
|
||||
@@ -58,3 +59,5 @@ export const SupportsPassiveListeners = (() => {
|
||||
}
|
||||
return supportsPassive
|
||||
})()
|
||||
|
||||
export const LargeNoteThreshold = 1.5 * BYTES_IN_ONE_MEGABYTE
|
||||
|
||||
@@ -13,4 +13,5 @@ export const ElementIds = {
|
||||
NoteStatusTooltip: 'note-status-tooltip',
|
||||
ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
|
||||
SearchBar: 'search-bar',
|
||||
ConflictResolutionButton: 'conflict-resolution-button',
|
||||
} as const
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
|
||||
import { destroyAllObjectProperties, isMobileScreen } from '@/Utils'
|
||||
import { debounce, destroyAllObjectProperties, isMobileScreen } from '@/Utils'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
CollectionSort,
|
||||
@@ -216,7 +216,7 @@ export class ItemListController
|
||||
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
|
||||
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
|
||||
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
|
||||
eventBus.addEventHandler(this, WebAppEvent.EditorFocused)
|
||||
eventBus.addEventHandler(this, WebAppEvent.EditorDidFocus)
|
||||
|
||||
this.disposers.push(
|
||||
reaction(
|
||||
@@ -276,9 +276,9 @@ export class ItemListController
|
||||
),
|
||||
)
|
||||
|
||||
window.onresize = () => {
|
||||
window.onresize = debounce(() => {
|
||||
this.resetPagination(true)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
getPersistableValue = (): SelectionControllerPersistableValue => {
|
||||
@@ -325,7 +325,7 @@ export class ItemListController
|
||||
break
|
||||
}
|
||||
|
||||
case WebAppEvent.EditorFocused: {
|
||||
case WebAppEvent.EditorDidFocus: {
|
||||
this.setShowDisplayOptionsMenu(false)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,13 +5,18 @@ import {
|
||||
ItemManagerInterface,
|
||||
MutatorClientInterface,
|
||||
SessionsClientInterface,
|
||||
SyncMode,
|
||||
SyncServiceInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Deferred } from '@standardnotes/utils'
|
||||
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
|
||||
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 MinimumStatusChangeDuration = 400
|
||||
|
||||
export type NoteSaveFunctionParams = {
|
||||
title?: string
|
||||
@@ -22,13 +27,16 @@ export type NoteSaveFunctionParams = {
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
onLocalPropagationComplete?: () => void
|
||||
onRemoteSyncComplete?: () => void
|
||||
}
|
||||
|
||||
export class NoteSyncController {
|
||||
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(
|
||||
private item: SNNote,
|
||||
@@ -38,43 +46,123 @@ export class NoteSyncController {
|
||||
private sync: SyncServiceInterface,
|
||||
private alerts: AlertService,
|
||||
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) {
|
||||
this.item = item
|
||||
}
|
||||
|
||||
deinit() {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
if (this.syncTimeout) {
|
||||
clearTimeout(this.syncTimeout)
|
||||
}
|
||||
if (this.largeNoteSyncTimeout) {
|
||||
clearTimeout(this.largeNoteSyncTimeout)
|
||||
}
|
||||
if (this.statusChangeTimeout) {
|
||||
clearTimeout(this.statusChangeTimeout)
|
||||
}
|
||||
if (this.savingLocallyPromise) {
|
||||
this.savingLocallyPromise.reject()
|
||||
}
|
||||
this.savingLocallyPromise = null
|
||||
this.saveTimeout = undefined
|
||||
this.largeNoteSyncTimeout = undefined
|
||||
this.syncTimeout = undefined
|
||||
this.status = undefined
|
||||
this.statusChangeTimeout = 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> {
|
||||
this.savingLocallyPromise = Deferred<void>()
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
if (this.syncTimeout) {
|
||||
clearTimeout(this.syncTimeout)
|
||||
}
|
||||
|
||||
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
|
||||
|
||||
const syncDebouceMs = noDebounce
|
||||
const syncDebounceMs = noDebounce
|
||||
? EditorSaveTimeoutDebounce.ImmediateChange
|
||||
: this._isNativeMobileWeb.execute().getValue()
|
||||
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
||||
: EditorSaveTimeoutDebounce.Desktop
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.undebouncedSave({
|
||||
const isLargeNote = this.isLargeNote(params.text ? params.text : this.item.text)
|
||||
|
||||
if (isLargeNote) {
|
||||
this.showWaitingToSyncLargeNoteStatus()
|
||||
this.queueLargeNoteSyncIfNeeded()
|
||||
}
|
||||
|
||||
this.syncTimeout = setTimeout(() => {
|
||||
void this.undebouncedMutateAndSync({
|
||||
...params,
|
||||
localOnly: isLargeNote,
|
||||
onLocalPropagationComplete: () => {
|
||||
if (this.savingLocallyPromise) {
|
||||
this.savingLocallyPromise.resolve()
|
||||
@@ -82,11 +170,34 @@ export class NoteSyncController {
|
||||
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)) {
|
||||
void this.alerts.alert(InfoStrings.InvalidNote)
|
||||
return
|
||||
@@ -123,10 +234,17 @@ export class NoteSyncController {
|
||||
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
|
||||
)
|
||||
|
||||
void this.sync.sync().then(() => {
|
||||
params.onRemoteSyncComplete?.()
|
||||
})
|
||||
void this.sync.sync({ mode: params.localOnly ? SyncMode.LocalOnly : undefined })
|
||||
|
||||
this.queueLargeNoteSyncIfNeeded()
|
||||
|
||||
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-status-tooltip-container {
|
||||
.note-status-tooltip-container,
|
||||
#conflict-resolution-button {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#editor-title-bar:hover .note-view-options-buttons,
|
||||
#editor-title-bar:hover .note-status-tooltip-container {
|
||||
opacity: 1;
|
||||
#editor-title-bar:hover {
|
||||
.note-view-options-buttons,
|
||||
.note-status-tooltip-container,
|
||||
#conflict-resolution-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user