import { AbstractComponent } from '@/Components/Abstract/PureComponent' import ChangeEditorButton from '@/Components/ChangeEditor/ChangeEditorButton' import IframeFeatureView from '@/Components/ComponentView/IframeFeatureView' import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel' import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton' import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay' import { ElementIds } from '@/Constants/ElementIDs' import { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings' import { log, LoggingDomain } from '@/Logging' import { debounce, isDesktopApplication, isMobileScreen } from '@/Utils' import { classNames, compareArrayReferences, pluralize } from '@standardnotes/utils' import { ApplicationEvent, ComponentArea, ComponentInterface, UIFeature, ComponentViewerInterface, ContentType, EditorLineWidth, IframeComponentFeatureDescription, isUIFeatureAnIframeFeature, isPayloadSourceRetrieved, NoteType, PayloadEmitSource, PrefDefaults, PrefKey, ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction, SNNote, } from '@standardnotes/snjs' import { confirmDialog, DELETE_NOTE_KEYBOARD_COMMAND, KeyboardKey } from '@standardnotes/ui-services' import { ChangeEventHandler, createRef, CSSProperties, KeyboardEventHandler, RefObject } from 'react' import { SuperEditor } from '../SuperEditor/SuperEditor' import IndicatorCircle from '../IndicatorCircle/IndicatorCircle' import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer' import LinkedItemsButton from '../LinkedItems/LinkedItemsButton' import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton' import EditingDisabledBanner from './EditingDisabledBanner' import { reloadFont } from './FontFunctions' import NoteViewFileDropTarget from './NoteViewFileDropTarget' import { NoteViewProps } from './NoteViewProps' import { transactionForAssociateComponentWithCurrentNote, transactionForDisassociateComponentWithCurrentNote, } from './TransactionFunctions' import { SuperEditorContentId } from '../SuperEditor/Constants' import { NoteViewController } from './Controller/NoteViewController' import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor' import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths' import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator' import CollaborationInfoHUD from './CollaborationInfoHUD' import Button from '../Button/Button' 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)) } type State = { availableStackComponents: ComponentInterface[] editorComponentViewer?: ComponentViewerInterface editorComponentViewerDidAlreadyReload?: boolean editorStateDidLoad: boolean editorTitle: string isDesktop?: boolean editorLineWidth: EditorLineWidth noteLocked: boolean noteStatus?: NoteStatus saveError?: boolean showProtectedWarning: boolean spellcheck: boolean stackComponentViewers: ComponentViewerInterface[] syncTakingTooLong: boolean monospaceFont?: boolean plainEditorFocused?: boolean paneGestureEnabled?: boolean noteLastEditedByUuid?: string updateSavingIndicator?: boolean editorFeatureIdentifier?: string noteType?: NoteType conflictedNotes: SNNote[] showConflictResolutionModal: boolean } class NoteView extends AbstractComponent { readonly controller!: NoteViewController private statusTimeout?: NodeJS.Timeout onEditorComponentLoad?: () => void private removeTrashKeyObserver?: () => void private removeNoteStreamObserver?: () => void private removeComponentManagerObserver?: () => void private removeInnerNoteObserver?: () => void private protectionTimeoutId: ReturnType | null = null private noteViewElementRef: RefObject private editorContentRef: RefObject private plainEditorRef?: PlainEditorInterface constructor(props: NoteViewProps) { super(props, props.application) this.controller = props.controller this.onEditorComponentLoad = () => { if (!this.controller || this.controller.dealloced) { return } this.application.desktopManager?.redoSearch() } this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25) this.state = { availableStackComponents: [], editorStateDidLoad: false, editorTitle: '', editorLineWidth: PrefDefaults[PrefKey.EditorLineWidth], isDesktop: isDesktopApplication(), noteStatus: undefined, noteLocked: this.controller.item.locked, showProtectedWarning: false, spellcheck: true, stackComponentViewers: [], syncTakingTooLong: false, editorFeatureIdentifier: this.controller.item.editorIdentifier, noteType: this.controller.item.noteType, conflictedNotes: [], showConflictResolutionModal: false, } this.noteViewElementRef = createRef() this.editorContentRef = createRef() } override deinit() { super.deinit() ;(this.controller as unknown) = undefined this.removeNoteStreamObserver?.() ;(this.removeNoteStreamObserver as unknown) = undefined this.removeInnerNoteObserver?.() ;(this.removeInnerNoteObserver as unknown) = undefined this.removeComponentManagerObserver?.() ;(this.removeComponentManagerObserver as unknown) = undefined this.removeTrashKeyObserver?.() this.removeTrashKeyObserver = undefined this.clearNoteProtectionInactivityTimer() ;(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 ;(this.onTitleEnter as unknown) = undefined ;(this.onTitleChange as unknown) = undefined ;(this.onPanelResizeFinish as unknown) = undefined ;(this.stackComponentExpanded as unknown) = undefined ;(this.toggleStackComponent as unknown) = undefined ;(this.debounceReloadEditorComponent as unknown) = undefined ;(this.editorContentRef as unknown) = undefined ;(this.plainEditorRef as unknown) = undefined } getState() { return this.state as State } get note() { return this.controller.item } override shouldComponentUpdate(_nextProps: Readonly, nextState: Readonly): boolean { for (const key of Object.keys(nextState) as (keyof State)[]) { const prevValue = this.state[key] const nextValue = nextState[key] if (Array.isArray(prevValue) && Array.isArray(nextValue)) { const areEqual = compareArrayReferences(prevValue, nextValue) if (!areEqual) { log(LoggingDomain.NoteView, 'Rendering due to array state change', key, prevValue, nextValue) return true } continue } if (prevValue !== nextValue) { log(LoggingDomain.NoteView, 'Rendering due to state change', key, prevValue, nextValue) return true } } return false } override componentDidMount(): void { super.componentDidMount() this.registerKeyboardShortcuts() this.removeInnerNoteObserver = this.controller.addNoteInnerValueChangeObserver((note, source) => { this.onNoteInnerChange(note, source) }) this.autorun(() => { this.setState({ showProtectedWarning: this.application.notesController.showProtectedWarning, }) }) this.reloadEditorComponent().catch(console.error) this.reloadStackComponents().catch(console.error) const showProtectedWarning = this.note.protected && (!this.application.hasProtectionSources() || !this.application.protections.hasUnprotectedAccessSession()) this.setShowProtectedOverlay(showProtectedWarning) this.reloadPreferences().catch(console.error) if (this.controller.isTemplateNote) { setTimeout(() => { if (this.controller.templateNoteOptions?.autofocusBehavior === 'title') { this.focusTitle() } }) } } setPlainEditorRef = (ref: PlainEditorInterface | null) => { this.plainEditorRef = ref || undefined } override componentDidUpdate(_prevProps: NoteViewProps, prevState: State): void { if ( this.state.showProtectedWarning != undefined && prevState.showProtectedWarning !== this.state.showProtectedWarning ) { this.reloadEditorComponent().catch(console.error) } } onNoteInnerChange(note: SNNote, source: PayloadEmitSource): void { log(LoggingDomain.NoteView, 'On inner note change', PayloadEmitSource[source]) if (note.uuid !== this.note.uuid) { throw Error('Editor received changes for non-current note') } let title = this.state.editorTitle if (isPayloadSourceRetrieved(source)) { title = note.title } if (!this.state.editorTitle) { title = note.title } if (title !== this.state.editorTitle) { this.setState({ editorTitle: title, }) } if (note.last_edited_by_uuid !== this.state.noteLastEditedByUuid) { this.setState({ noteLastEditedByUuid: note.last_edited_by_uuid, }) } if (note.locked !== this.state.noteLocked) { this.setState({ noteLocked: note.locked, }) } if (note.editorIdentifier !== this.state.editorFeatureIdentifier || note.noteType !== this.state.noteType) { this.setState({ editorFeatureIdentifier: note.editorIdentifier, noteType: note.noteType, editorTitle: note.title, }) void this.reloadEditorComponent() } this.reloadSpellcheck().catch(console.error) this.reloadLineWidth() const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadEmitSource.LocalInserted && note.dirty if (isTemplateNoteInsertedToBeInteractableWithEditor) { return } if (note.lastSyncBegan || note.dirty) { if (note.lastSyncEnd) { const shouldShowSavingStatus = 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() } } else { this.showSavingStatus() } } } override componentWillUnmount(): void { if (this.state.editorComponentViewer) { this.application.componentManager?.destroyComponentViewer(this.state.editorComponentViewer) } super.componentWillUnmount() } override async onAppLaunch() { await super.onAppLaunch() this.streamItems() } override async onAppEvent(eventName: ApplicationEvent) { if (this.controller?.dealloced) { return } switch (eventName) { case ApplicationEvent.PreferencesChanged: void this.reloadPreferences() void this.reloadStackComponents() break case ApplicationEvent.HighLatencySync: this.setState({ syncTakingTooLong: true }) break case ApplicationEvent.CompletedFullSync: { this.setState({ syncTakingTooLong: false }) 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() } break } case ApplicationEvent.FailedSync: /** * Only show error status in editor if the note is dirty. * Otherwise, it means the originating sync came from somewhere else * and we don't want to display an error here. */ if (this.note.dirty) { this.showErrorStatus() } break case ApplicationEvent.LocalDatabaseWriteError: this.showErrorStatus({ type: 'error', message: 'Offline Saving Issue', desc: 'Changes not saved', }) break case ApplicationEvent.UnprotectedSessionBegan: { this.setShowProtectedOverlay(false) break } case ApplicationEvent.UnprotectedSessionExpired: { if (this.note.protected) { this.hideProtectedNoteIfInactive() } break } } } getSecondsElapsedSinceLastEdit(): number { return (Date.now() - this.note.userModifiedDate.getTime()) / 1000 } hideProtectedNoteIfInactive(): void { const secondsElapsedSinceLastEdit = this.getSecondsElapsedSinceLastEdit() if (secondsElapsedSinceLastEdit >= ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction) { this.setShowProtectedOverlay(true) } else { const secondsUntilTheNextCheck = ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction - secondsElapsedSinceLastEdit this.startNoteProtectionInactivityTimer(secondsUntilTheNextCheck) } } startNoteProtectionInactivityTimer(timerDurationInSeconds: number): void { this.clearNoteProtectionInactivityTimer() this.protectionTimeoutId = setTimeout(() => { this.hideProtectedNoteIfInactive() }, timerDurationInSeconds * 1000) } clearNoteProtectionInactivityTimer(): void { if (this.protectionTimeoutId) { clearTimeout(this.protectionTimeoutId) } } authorizeAndDismissProtectedWarning = async () => { let showNoteContents = true if (this.application.hasProtectionSources()) { showNoteContents = await this.application.authorizeNoteAccess(this.note) } if (!showNoteContents) { return } this.setShowProtectedOverlay(false) this.focusTitle() } streamItems() { this.removeNoteStreamObserver = this.application.items.streamItems(ContentType.TYPES.Note, async () => { if (!this.note) { return } this.setState({ conflictedNotes: this.application.items.conflictsOf(this.note.uuid) as SNNote[], }) }) } private createComponentViewer(component: UIFeature) { if (!component) { throw Error('Cannot create component viewer for undefined component') } const viewer = this.application.componentManager.createComponentViewer(component, { uuid: this.note.uuid }) return viewer } public editorComponentViewerRequestsReload = async ( viewer: ComponentViewerInterface, force?: boolean, ): Promise => { if (this.state.editorComponentViewerDidAlreadyReload && !force) { return } const component = viewer.getComponentOrFeatureItem() this.application.componentManager.destroyComponentViewer(viewer) this.setState( { editorComponentViewer: undefined, editorComponentViewerDidAlreadyReload: true, }, () => { this.setState({ editorComponentViewer: this.createComponentViewer(component), editorStateDidLoad: true, }) }, ) } /** * Calling reloadEditorComponent successively without waiting for state to settle * can result in componentViewers being dealloced twice */ debounceReloadEditorComponent() { this.reloadEditorComponent().catch(console.error) } private destroyCurrentEditorComponent() { const currentComponentViewer = this.state.editorComponentViewer if (currentComponentViewer) { this.application.componentManager.destroyComponentViewer(currentComponentViewer) this.setState({ editorComponentViewer: undefined, }) } } async reloadEditorComponent(): Promise { log(LoggingDomain.NoteView, 'Reload editor component') if (this.state.showProtectedWarning) { this.destroyCurrentEditorComponent() return } const newUIFeature = this.application.componentManager.editorForNote(this.note) /** Component editors cannot interact with template notes so the note must be inserted */ if (isUIFeatureAnIframeFeature(newUIFeature) && this.controller.isTemplateNote) { await this.controller.insertTemplatedNote() } const currentComponentViewer = this.state.editorComponentViewer if (currentComponentViewer) { const needsDestroy = currentComponentViewer.componentUniqueIdentifier !== newUIFeature.uniqueIdentifier if (needsDestroy) { this.destroyCurrentEditorComponent() } } if (isUIFeatureAnIframeFeature(newUIFeature)) { this.setState({ editorComponentViewer: this.createComponentViewer(newUIFeature), editorStateDidLoad: true, }) } else { reloadFont(this.state.monospaceFont) this.setState({ editorStateDidLoad: true, }) } } hasAvailableExtensions() { 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 = ({ key, currentTarget }) => { if (key !== KeyboardKey.Enter) { return } currentTarget.blur() this.plainEditorRef?.focus() } onTitleChange: ChangeEventHandler = ({ currentTarget }) => { log(LoggingDomain.NoteView, 'Performing save after title change') const title = currentTarget.value this.setState({ editorTitle: title, }) this.controller .saveAndAwaitLocalPropagation({ title: title, isUserModified: true, dontGeneratePreviews: true, }) .catch(console.error) } focusTitle() { document.getElementById(ElementIds.NoteTitleEditor)?.focus() } setShowProtectedOverlay(show: boolean) { this.application.notesController.setShowProtectedWarning(show) } async deleteNote(permanently: boolean) { if (this.controller.isTemplateNote) { this.application.alerts.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT).catch(console.error) return } if (this.note.locked) { this.application.alerts.alert(STRING_DELETE_LOCKED_ATTEMPT).catch(console.error) return } const title = this.note.title.length ? `'${this.note.title}'` : 'this note' const text = StringDeleteNote(title, permanently) if ( await confirmDialog({ text, confirmButtonStyle: 'danger', }) ) { if (permanently) { this.performNoteDeletion(this.note) } else { this.controller .saveAndAwaitLocalPropagation({ title: this.state.editorTitle, bypassDebouncer: true, dontGeneratePreviews: true, isUserModified: true, customMutate: (mutator) => { mutator.trashed = true }, }) .catch(console.error) } } } performNoteDeletion(note: SNNote) { this.application.mutator .deleteItem(note) .then(() => this.application.sync.sync()) .catch(console.error) } onPanelResizeFinish = async (width: number, left: number, isMaxWidth: boolean) => { if (isMaxWidth) { await this.application.setPreference(PrefKey.EditorWidth, null) } else { if (width !== undefined && width !== null) { await this.application.setPreference(PrefKey.EditorWidth, width) } } if (left !== undefined && left !== null) { await this.application.setPreference(PrefKey.EditorLeft, left) } this.application.sync.sync().catch(console.error) } async reloadSpellcheck() { const spellcheck = this.application.notesController.getSpellcheckStateForNote(this.note) if (spellcheck !== this.state.spellcheck) { reloadFont(this.state.monospaceFont) this.setState({ spellcheck }) } } reloadLineWidth() { const editorLineWidth = this.application.notesController.getEditorWidthForNote(this.note) this.setState({ editorLineWidth, }) } async reloadPreferences() { log(LoggingDomain.NoteView, 'Reload preferences') const monospaceFont = this.application.getPreference( PrefKey.EditorMonospaceEnabled, PrefDefaults[PrefKey.EditorMonospaceEnabled], ) const updateSavingIndicator = this.application.getPreference( PrefKey.UpdateSavingStatusIndicator, PrefDefaults[PrefKey.UpdateSavingStatusIndicator], ) const paneGestureEnabled = this.application.getPreference( PrefKey.PaneGesturesEnabled, PrefDefaults[PrefKey.PaneGesturesEnabled], ) await this.reloadSpellcheck() this.reloadLineWidth() this.setState({ monospaceFont, updateSavingIndicator, paneGestureEnabled, }) reloadFont(monospaceFont) } async reloadStackComponents() { log(LoggingDomain.NoteView, 'Reload stack components') const enabledComponents = sortAlphabetically( this.application.componentManager .thirdPartyComponentsForArea(ComponentArea.EditorStack) .filter((component) => this.application.componentManager.isComponentActive(component)), ) const needsNewViewer = enabledComponents.filter((component) => { const hasExistingViewer = this.state.stackComponentViewers.find( (viewer) => viewer.componentUniqueIdentifier.value === component.uuid, ) return !hasExistingViewer }) const needsDestroyViewer = this.state.stackComponentViewers.filter((viewer) => { const viewerComponentExistsInEnabledComponents = enabledComponents.find((component) => { return component.uuid === viewer.componentUniqueIdentifier.value }) return !viewerComponentExistsInEnabledComponents }) const newViewers: ComponentViewerInterface[] = [] for (const component of needsNewViewer) { newViewers.push( this.application.componentManager.createComponentViewer( new UIFeature(component), { uuid: this.note.uuid, }, ), ) } for (const viewer of needsDestroyViewer) { this.application.componentManager.destroyComponentViewer(viewer) } this.setState({ availableStackComponents: enabledComponents, stackComponentViewers: newViewers, }) } stackComponentExpanded = (component: ComponentInterface): boolean => { return !!this.state.stackComponentViewers.find( (viewer) => viewer.componentUniqueIdentifier.value === component.uuid, ) } toggleStackComponent = async (component: ComponentInterface) => { if (!component.isExplicitlyEnabledForItem(this.note.uuid)) { await this.application.mutator.runTransactionalMutation( transactionForAssociateComponentWithCurrentNote(component, this.note), ) } else { await this.application.mutator.runTransactionalMutation( transactionForDisassociateComponentWithCurrentNote(component, this.note), ) } this.application.sync.sync().catch(console.error) } registerKeyboardShortcuts() { this.removeTrashKeyObserver = this.application.keyboardService.addCommandHandler({ command: DELETE_NOTE_KEYBOARD_COMMAND, notTags: ['INPUT', 'TEXTAREA'], notElementIds: [SuperEditorContentId], onKeyDown: () => { this.deleteNote(false).catch(console.error) }, }) } ensureNoteIsInsertedBeforeUIAction = async () => { if (this.controller.isTemplateNote) { await this.controller.insertTemplatedNote() } } onPlainFocus = () => { this.setState({ plainEditorFocused: true }) } onPlainBlur = () => { this.setState({ plainEditorFocused: false }) } toggleConflictResolutionModal = () => { this.setState((state) => ({ showConflictResolutionModal: !state.showConflictResolutionModal, })) } override render() { if (this.controller.dealloced) { return null } if (this.state.showProtectedWarning || !this.application.isAuthorizedToRenderItem(this.note)) { return ( this.application.showAccountMenu()} hasProtectionSources={this.application.hasProtectionSources()} onViewItem={this.authorizeAndDismissProtectedWarning} itemType={'note'} /> ) } const renderHeaderOptions = isMobileScreen() ? !this.state.plainEditorFocused : true const editorMode = this.note.noteType === NoteType.Super ? 'super' : this.state.editorStateDidLoad && !this.state.editorComponentViewer ? 'plain' : this.state.editorComponentViewer ? 'component' : 'plain' const shouldShowConflictsButton = this.state.conflictedNotes.length > 0 return (
{this.note && ( )} {this.state.noteLocked && ( this.application.notesController.setLockSelectedNotes(!this.state.noteLocked)} noteLocked={this.state.noteLocked} /> )} {this.note && (
{ event.target.select() }} onKeyUp={this.onTitleEnter} spellCheck={false} value={this.state.editorTitle} autoComplete="off" />
{shouldShowConflictsButton && ( )} {renderHeaderOptions && (
)}
)}
*]:mx-[var(--editor-margin)] [&>*]:max-w-[var(--editor-max-width)]', )} style={ { '--editor-margin': EditorMargins[this.state.editorLineWidth], '--editor-max-width': EditorMaxWidths[this.state.editorLineWidth], } as CSSProperties } ref={this.editorContentRef} > {editorMode === 'component' && this.state.editorComponentViewer && (
{this.state.paneGestureEnabled &&
}
)} {editorMode === 'plain' && ( )} {editorMode === 'super' && (
)}
{this.state.availableStackComponents.length > 0 && (
{this.state.availableStackComponents.map((component) => { const active = this.application.componentManager.isComponentActive(component) return (
{ this.toggleStackComponent(component).catch(console.error) }} className="flex flex-grow cursor-pointer items-center justify-center [&:not(:first-child)]:ml-3" >
{this.stackComponentExpanded(component) && active && } {!this.stackComponentExpanded(component) && }
{component.name}
) })}
)}
{this.state.stackComponentViewers.map((viewer) => { return (
) })}
) } } export default NoteView