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:
Aman Harwara
2024-01-20 15:22:09 +05:30
committed by GitHub
parent c6060aaab3
commit 396ee3f449
32 changed files with 408 additions and 163 deletions

View 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

View File

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

View File

@@ -37,6 +37,7 @@ export type IconType =
| 'chevron-right'
| 'chevron-up'
| 'clear-circle-filled'
| 'clock'
| 'close'
| 'cloud-off'
| 'code-2'

View File

@@ -1,6 +1,6 @@
export enum WebAppEvent {
NewUpdateAvailable = 'NewUpdateAvailable',
EditorFocused = 'EditorFocused',
EditorDidFocus = 'EditorDidFocus',
BeganBackupDownload = 'BeganBackupDownload',
EndedBackupDownload = 'EndedBackupDownload',
PanelResized = 'PanelResized',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -44,6 +44,7 @@ type CommonPopoverProps = {
togglePopover?: () => void
disableMobileFullscreenTakeover?: boolean
disableFlip?: boolean
disableApplyingMobileWidth?: boolean
forceFullHeightOnMobile?: boolean
title: string
portal?: boolean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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