standardnotes-app/app/assets/javascripts/Components/NoteView/NoteView.tsx
2022-04-13 22:02:34 +05:30

1142 lines
36 KiB
TypeScript

import { WebApplication } from '@/UIModels/Application'
import { createRef, JSX, RefObject } from 'preact'
import {
ApplicationEvent,
isPayloadSourceRetrieved,
isPayloadSourceInternalChange,
ContentType,
SNComponent,
SNNote,
ComponentArea,
PrefKey,
ComponentMutator,
PayloadSource,
ComponentViewer,
TransactionalMutation,
ItemMutator,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
NoteViewController,
} from '@standardnotes/snjs'
import { debounce, isDesktopApplication } from '@/Utils'
import { KeyboardModifier, KeyboardKey } from '@/Services/IOService'
import { EventSource } from '@/UIModels/AppState'
import {
STRING_DELETE_PLACEHOLDER_ATTEMPT,
STRING_DELETE_LOCKED_ATTEMPT,
StringDeleteNote,
} from '@/Strings'
import { confirmDialog } from '@/Services/AlertService'
import { PureComponent } from '@/Components/Abstract/PureComponent'
import { ProtectedNoteOverlay } from '@/Components/ProtectedNoteOverlay'
import { Icon } from '@/Components/Icon'
import { PinNoteButton } from '@/Components/PinNoteButton'
import { NotesOptionsPanel } from '@/Components/NotesOptions/NotesOptionsPanel'
import { NoteTagsContainer } from '@/Components/NoteTags/NoteTagsContainer'
import { ComponentView } from '@/Components/ComponentView'
import { PanelSide, PanelResizer, PanelResizeType } from '@/Components/PanelResizer'
import { ElementIds } from '@/ElementIDs'
import { ChangeEditorButton } from '@/Components/ChangeEditor/ChangeEditorButton'
import { AttachedFilesButton } from '@/Components/AttachedFilesPopover/AttachedFilesButton'
const MINIMUM_STATUS_DURATION = 400
const TEXTAREA_DEBOUNCE = 100
type NoteStatus = {
message?: string
desc?: string
}
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
}
export const transactionForAssociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote,
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator
mutator.removeDisassociatedItemId(note.uuid)
mutator.associateWithItem(note.uuid)
},
}
return transaction
}
export const transactionForDisassociateComponentWithCurrentNote = (
component: SNComponent,
note: SNNote,
) => {
const transaction: TransactionalMutation = {
itemUuid: component.uuid,
mutate: (m: ItemMutator) => {
const mutator = m as ComponentMutator
mutator.removeAssociatedItemId(note.uuid)
mutator.disassociateWithItem(note.uuid)
},
}
return transaction
}
export const reloadFont = (monospaceFont?: boolean) => {
const root = document.querySelector(':root') as HTMLElement
const propertyName = '--sn-stylekit-editor-font-family'
if (monospaceFont) {
root.style.setProperty(propertyName, 'var(--sn-stylekit-monospace-font)')
} else {
root.style.setProperty(propertyName, 'var(--sn-stylekit-sans-serif-font)')
}
}
type State = {
availableStackComponents: SNComponent[]
editorComponentViewer?: ComponentViewer
editorComponentViewerDidAlreadyReload?: boolean
editorStateDidLoad: boolean
editorTitle: string
editorText: string
isDesktop?: boolean
lockText: string
marginResizersEnabled?: boolean
monospaceFont?: boolean
noteLocked: boolean
noteStatus?: NoteStatus
saveError?: boolean
showLockedIcon: boolean
showProtectedWarning: boolean
spellcheck: boolean
stackComponentViewers: ComponentViewer[]
syncTakingTooLong: boolean
/** Setting to true then false will allow the main content textarea to be destroyed
* then re-initialized. Used when reloading spellcheck status. */
textareaUnloading: boolean
leftResizerWidth: number
leftResizerOffset: number
rightResizerWidth: number
rightResizerOffset: number
}
interface Props {
application: WebApplication
controller: NoteViewController
}
export class NoteView extends PureComponent<Props, State> {
readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout
private lastEditorFocusEventSource?: EventSource
onEditorComponentLoad?: () => void
private scrollPosition = 0
private removeTrashKeyObserver?: () => void
private removeTabObserver?: () => void
private removeComponentStreamObserver?: () => void
private removeComponentManagerObserver?: () => void
private removeInnerNoteObserver?: () => void
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private editorContentRef: RefObject<HTMLDivElement>
constructor(props: Props) {
super(props, props.application)
this.controller = props.controller
this.onEditorComponentLoad = () => {
this.application.getDesktopService().redoSearch()
}
this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25)
this.textAreaChangeDebounceSave = debounce(this.textAreaChangeDebounceSave, TEXTAREA_DEBOUNCE)
this.state = {
availableStackComponents: [],
editorStateDidLoad: false,
editorText: '',
editorTitle: '',
isDesktop: isDesktopApplication(),
lockText: 'Note Editing Disabled',
noteStatus: undefined,
noteLocked: this.controller.note.locked,
showLockedIcon: true,
showProtectedWarning: false,
spellcheck: true,
stackComponentViewers: [],
syncTakingTooLong: false,
textareaUnloading: false,
leftResizerWidth: 0,
leftResizerOffset: 0,
rightResizerWidth: 0,
rightResizerOffset: 0,
}
this.editorContentRef = createRef<HTMLDivElement>()
}
override deinit() {
this.removeComponentStreamObserver?.()
;(this.removeComponentStreamObserver 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.removeTabObserver?.()
this.removeTabObserver = undefined
this.onEditorComponentLoad = undefined
this.statusTimeout = undefined
;(this.onPanelResizeFinish as unknown) = undefined
super.deinit()
}
getState() {
return this.state as State
}
get note() {
return this.controller.note
}
override componentDidMount(): void {
super.componentDidMount()
this.registerKeyboardShortcuts()
this.removeInnerNoteObserver = this.controller.addNoteInnerValueChangeObserver(
(note, source) => {
this.onNoteInnerChange(note, source)
},
)
this.autorun(() => {
this.setState({
showProtectedWarning: this.appState.notes.showProtectedWarning,
})
})
this.reloadEditorComponent().catch(console.error)
this.reloadStackComponents().catch(console.error)
const showProtectedWarning = this.note.protected && !this.application.hasProtectionSources()
this.setShowProtectedOverlay(showProtectedWarning)
this.reloadPreferences().catch(console.error)
if (this.controller.isTemplateNote) {
setTimeout(() => {
this.focusTitle()
})
}
}
override componentDidUpdate(_prevProps: Props, prevState: State): void {
if (
this.state.showProtectedWarning != undefined &&
prevState.showProtectedWarning !== this.state.showProtectedWarning
) {
this.reloadEditorComponent().catch(console.error)
}
}
private onNoteInnerChange(note: SNNote, source: PayloadSource): void {
if (note.uuid !== this.note.uuid) {
throw Error('Editor received changes for non-current note')
}
let title = this.state.editorTitle,
text = this.state.editorText
if (isPayloadSourceRetrieved(source)) {
title = note.title
text = note.text
}
if (!this.state.editorTitle) {
title = note.title
}
if (!this.state.editorText) {
text = note.text
}
if (title !== this.state.editorTitle) {
this.setState({
editorTitle: title,
})
}
if (text !== this.state.editorText) {
this.setState({
editorText: text,
})
}
if (note.locked !== this.state.noteLocked) {
this.setState({
noteLocked: note.locked,
})
}
this.reloadSpellcheck().catch(console.error)
const isTemplateNoteInsertedToBeInteractableWithEditor =
source === PayloadSource.Constructor && 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) {
switch (eventName) {
case ApplicationEvent.PreferencesChanged:
this.reloadPreferences().catch(console.error)
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({
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)
}
}
dismissProtectedWarning = 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.removeComponentStreamObserver = this.application.streamItems(
ContentType.Component,
async ({ source }) => {
if (
isPayloadSourceInternalChange(source) ||
source === PayloadSource.InitialObserverRegistrationPush
) {
return
}
if (!this.note) {
return
}
await this.reloadStackComponents()
this.debounceReloadEditorComponent()
},
)
}
private createComponentViewer(component: SNComponent) {
const viewer = this.application.componentManager.createComponentViewer(
component,
this.note.uuid,
)
return viewer
}
public editorComponentViewerRequestsReload = async (
viewer: ComponentViewer,
force?: boolean,
): Promise<void> => {
if (this.state.editorComponentViewerDidAlreadyReload && !force) {
return
}
const component = viewer.component
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,
})
}
}
private async reloadEditorComponent() {
if (this.state.showProtectedWarning) {
this.destroyCurrentEditorComponent()
return
}
const newEditor = this.application.componentManager.editorForNote(this.note)
/** Editors cannot interact with template notes so the note must be inserted */
if (newEditor && this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote()
this.associateComponentWithCurrentNote(newEditor).catch(console.error)
}
const currentComponentViewer = this.state.editorComponentViewer
if (currentComponentViewer?.componentUuid !== newEditor?.uuid) {
if (currentComponentViewer) {
this.destroyCurrentEditorComponent()
}
if (newEditor) {
this.setState({
editorComponentViewer: this.createComponentViewer(newEditor),
editorStateDidLoad: true,
})
}
reloadFont(this.state.monospaceFont)
} else {
this.setState({
editorStateDidLoad: true,
})
}
}
hasAvailableExtensions() {
return this.application.actionsManager.extensionsInContextOfItem(this.note).length > 0
}
showSavingStatus() {
this.setStatus({ message: 'Saving…' }, false)
}
showAllChangesSavedStatus() {
this.setState({
saveError: false,
syncTakingTooLong: false,
})
this.setStatus({
message: 'All changes saved' + (this.application.noAccount() ? ' offline' : ''),
})
}
showErrorStatus(error?: NoteStatus) {
if (!error) {
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,
})
}, MINIMUM_STATUS_DURATION)
} else {
this.setState({
noteStatus: status,
})
}
}
cancelPendingSetStatus() {
if (this.statusTimeout) {
clearTimeout(this.statusTimeout)
}
}
onTextAreaChange = ({ currentTarget }: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => {
const text = currentTarget.value
this.setState({
editorText: text,
})
this.textAreaChangeDebounceSave()
}
textAreaChangeDebounceSave = () => {
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
isUserModified: true,
})
.catch(console.error)
}
onTitleEnter = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
currentTarget.blur()
this.focusEditor()
}
onTitleChange = ({ currentTarget }: JSX.TargetedEvent<HTMLInputElement, Event>) => {
const title = currentTarget.value
this.setState({
editorTitle: title,
})
this.controller
.save({
editorValues: {
title: title,
text: this.state.editorText,
},
isUserModified: true,
dontUpdatePreviews: true,
})
.catch(console.error)
}
focusEditor() {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
this.lastEditorFocusEventSource = EventSource.Script
element.focus()
}
}
focusTitle() {
document.getElementById(ElementIds.NoteTitleEditor)?.focus()
}
onContentFocus = () => {
if (this.lastEditorFocusEventSource) {
this.application.getAppState().editorDidFocus(this.lastEditorFocusEventSource)
}
this.lastEditorFocusEventSource = undefined
}
setShowProtectedOverlay(show: boolean) {
this.appState.notes.setShowProtectedWarning(show)
}
async deleteNote(permanently: boolean) {
if (this.controller.isTemplateNote) {
this.application.alertService.alert(STRING_DELETE_PLACEHOLDER_ATTEMPT).catch(console.error)
return
}
if (this.note.locked) {
this.application.alertService.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
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
bypassDebouncer: true,
dontUpdatePreviews: true,
customMutate: (mutator) => {
mutator.trashed = true
},
})
.catch(console.error)
}
}
}
performNoteDeletion(note: SNNote) {
this.application.mutator.deleteItem(note).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.appState.notes.getSpellcheckStateForNote(this.note)
if (spellcheck !== this.state.spellcheck) {
this.setState({ textareaUnloading: true })
this.setState({ textareaUnloading: false })
reloadFont(this.state.monospaceFont)
this.setState({
spellcheck,
})
}
}
async reloadPreferences() {
const monospaceFont = this.application.getPreference(PrefKey.EditorMonospaceEnabled, true)
const marginResizersEnabled = this.application.getPreference(
PrefKey.EditorResizersEnabled,
true,
)
await this.reloadSpellcheck()
this.setState({
monospaceFont,
marginResizersEnabled,
})
reloadFont(monospaceFont)
if (marginResizersEnabled) {
const width = this.application.getPreference(PrefKey.EditorWidth, null)
if (width != null) {
this.setState({
leftResizerWidth: width,
rightResizerWidth: width,
})
}
const left = this.application.getPreference(PrefKey.EditorLeft, null)
if (left != null) {
this.setState({
leftResizerOffset: left,
rightResizerOffset: left,
})
}
}
}
/** @components */
async reloadStackComponents() {
const stackComponents = sortAlphabetically(
this.application.componentManager
.componentsForArea(ComponentArea.EditorStack)
.filter((component) => component.active),
)
const enabledComponents = stackComponents.filter((component) => {
return component.isExplicitlyEnabledForItem(this.note.uuid)
})
const needsNewViewer = enabledComponents.filter((component) => {
const hasExistingViewer = this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid,
)
return !hasExistingViewer
})
const needsDestroyViewer = this.state.stackComponentViewers.filter((viewer) => {
const viewerComponentExistsInEnabledComponents = enabledComponents.find((component) => {
return component.uuid === viewer.componentUuid
})
return !viewerComponentExistsInEnabledComponents
})
const newViewers: ComponentViewer[] = []
for (const component of needsNewViewer) {
newViewers.push(
this.application.componentManager.createComponentViewer(component, this.note.uuid),
)
}
for (const viewer of needsDestroyViewer) {
this.application.componentManager.destroyComponentViewer(viewer)
}
this.setState({
availableStackComponents: stackComponents,
stackComponentViewers: newViewers,
})
}
stackComponentExpanded = (component: SNComponent): boolean => {
return !!this.state.stackComponentViewers.find(
(viewer) => viewer.componentUuid === component.uuid,
)
}
toggleStackComponent = async (component: SNComponent) => {
if (!component.isExplicitlyEnabledForItem(this.note.uuid)) {
await this.associateComponentWithCurrentNote(component)
} else {
await this.disassociateComponentWithCurrentNote(component)
}
this.application.sync.sync().catch(console.error)
}
async disassociateComponentWithCurrentNote(component: SNComponent) {
return this.application.mutator.runTransactionalMutation(
transactionForDisassociateComponentWithCurrentNote(component, this.note),
)
}
async associateComponentWithCurrentNote(component: SNComponent) {
return this.application.mutator.runTransactionalMutation(
transactionForAssociateComponentWithCurrentNote(component, this.note),
)
}
registerKeyboardShortcuts() {
this.removeTrashKeyObserver = this.application.io.addKeyObserver({
key: KeyboardKey.Backspace,
notTags: ['INPUT', 'TEXTAREA'],
modifiers: [KeyboardModifier.Meta],
onKeyDown: () => {
this.deleteNote(false).catch(console.error)
},
})
}
setScrollPosition = () => {
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
this.scrollPosition = editor.scrollTop
}
resetScrollPosition = () => {
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
editor.scrollTop = this.scrollPosition
}
onSystemEditorLoad = (ref: HTMLTextAreaElement | null) => {
if (this.removeTabObserver || !ref) {
return
}
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
* If the shift key is pressed first, this event is
* not fired.
*/
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
if (!editor) {
console.error('Editor is not yet mounted; unable to add tab observer.')
return
}
this.removeTabObserver = this.application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || this.note.locked || event.shiftKey) {
return
}
event.preventDefault()
/** Using document.execCommand gives us undo support */
const insertSuccessful = document.execCommand('insertText', false, '\t')
if (!insertSuccessful) {
/** document.execCommand works great on Chrome/Safari but not Firefox */
const start = editor.selectionStart || 0
const end = editor.selectionEnd || 0
const spaces = ' '
/** Insert 4 spaces */
editor.value = editor.value.substring(0, start) + spaces + editor.value.substring(end)
/** Place cursor 4 spaces away from where the tab key was pressed */
editor.selectionStart = editor.selectionEnd = start + 4
}
this.setState({
editorText: editor.value,
})
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
bypassDebouncer: true,
})
.catch(console.error)
},
})
editor.addEventListener('scroll', this.setScrollPosition)
editor.addEventListener('input', this.resetScrollPosition)
const observer = new MutationObserver((records) => {
for (const record of records) {
const removedNodes = record.removedNodes.values()
for (const node of removedNodes) {
if (node === editor) {
this.removeTabObserver?.()
this.removeTabObserver = undefined
editor.removeEventListener('scroll', this.setScrollPosition)
editor.removeEventListener('scroll', this.resetScrollPosition)
this.scrollPosition = 0
}
}
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
ensureNoteIsInsertedBeforeUIAction = async () => {
if (this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote()
}
}
override render() {
if (this.state.showProtectedWarning) {
return (
<div aria-label="Note" className="section editor sn-component">
{this.state.showProtectedWarning && (
<div className="h-full flex justify-center items-center">
<ProtectedNoteOverlay
appState={this.appState}
hasProtectionSources={this.application.hasProtectionSources()}
onViewNote={this.dismissProtectedWarning}
/>
</div>
)}
</div>
)
}
return (
<div aria-label="Note" className="section editor sn-component">
<div className="flex-grow flex flex-col">
<div className="sn-component">
{this.state.noteLocked && (
<div
className="sk-app-bar no-edges"
onMouseLeave={() => {
this.setState({
lockText: 'Note Editing Disabled',
showLockedIcon: true,
})
}}
onMouseOver={() => {
this.setState({
lockText: 'Enable editing',
showLockedIcon: false,
})
}}
>
<div
onClick={() => this.appState.notes.setLockSelectedNotes(!this.state.noteLocked)}
className="sk-app-bar-item"
>
<div className="sk-label warning flex items-center">
{this.state.showLockedIcon && (
<Icon type="pencil-off" className="flex fill-current mr-2" />
)}
{this.state.lockText}
</div>
</div>
</div>
)}
</div>
{this.note && (
<div id="editor-title-bar" className="section-title-bar w-full">
<div className="flex items-center justify-between h-8">
<div className={(this.state.noteLocked ? 'locked' : '') + ' flex-grow'}>
<div className="title overflow-auto">
<input
className="input"
disabled={this.state.noteLocked}
id={ElementIds.NoteTitleEditor}
onChange={this.onTitleChange}
onFocus={(event) => {
;(event.target as HTMLTextAreaElement).select()
}}
onKeyUp={(event) => event.keyCode == 13 && this.onTitleEnter(event)}
spellcheck={false}
value={this.state.editorTitle}
autocomplete="off"
/>
</div>
</div>
<div className="flex items-center">
<div id="save-status-container">
<div id="save-status">
<div
className={
(this.state.syncTakingTooLong ? 'warning sk-bold ' : '') +
(this.state.saveError ? 'danger sk-bold ' : '') +
' message'
}
>
{this.state.noteStatus?.message}
</div>
{this.state.noteStatus?.desc && (
<div className="desc">{this.state.noteStatus.desc}</div>
)}
</div>
</div>
{window.enabledUnfinishedFeatures && (
<div className="mr-3">
<AttachedFilesButton
application={this.application}
appState={this.appState}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
)}
<div className="mr-3">
<ChangeEditorButton
application={this.application}
appState={this.appState}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
<div className="mr-3">
<PinNoteButton
appState={this.appState}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
<NotesOptionsPanel
application={this.application}
appState={this.appState}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
</div>
<NoteTagsContainer appState={this.appState} />
</div>
)}
<div
id={ElementIds.EditorContent}
className={ElementIds.EditorContent}
ref={this.editorContentRef}
>
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
<PanelResizer
minWidth={300}
hoverable={true}
collapsable={false}
panel={this.editorContentRef.current}
side={PanelSide.Left}
type={PanelResizeType.OffsetAndWidth}
left={this.state.leftResizerOffset}
width={this.state.leftResizerWidth}
resizeFinishCallback={this.onPanelResizeFinish}
/>
) : null}
{this.state.editorComponentViewer && (
<div className="component-view">
<ComponentView
componentViewer={this.state.editorComponentViewer}
onLoad={this.onEditorComponentLoad}
requestReload={this.editorComponentViewerRequestsReload}
application={this.application}
appState={this.appState}
/>
</div>
)}
{this.state.editorStateDidLoad &&
!this.state.editorComponentViewer &&
!this.state.textareaUnloading && (
<textarea
autocomplete="off"
className="editable font-editor"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={this.onTextAreaChange}
value={this.state.editorText}
readonly={this.state.noteLocked}
onFocus={this.onContentFocus}
spellcheck={this.state.spellcheck}
ref={(ref) => this.onSystemEditorLoad(ref)}
></textarea>
)}
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
<PanelResizer
minWidth={300}
hoverable={true}
collapsable={false}
panel={this.editorContentRef.current}
side={PanelSide.Right}
type={PanelResizeType.OffsetAndWidth}
left={this.state.rightResizerOffset}
width={this.state.rightResizerWidth}
resizeFinishCallback={this.onPanelResizeFinish}
/>
) : null}
</div>
<div id="editor-pane-component-stack">
{this.state.availableStackComponents.length > 0 && (
<div id="component-stack-menu-bar" className="sk-app-bar no-edges">
<div className="left">
{this.state.availableStackComponents.map((component) => {
return (
<div
key={component.uuid}
onClick={() => {
this.toggleStackComponent(component).catch(console.error)
}}
className="sk-app-bar-item"
>
<div className="sk-app-bar-item-column">
<div
className={
(this.stackComponentExpanded(component) && component.active
? 'info '
: '') +
(!this.stackComponentExpanded(component) ? 'neutral ' : '') +
' sk-circle small'
}
/>
</div>
<div className="sk-app-bar-item-column">
<div className="sk-label">{component.name}</div>
</div>
</div>
)
})}
</div>
</div>
)}
<div className="sn-component">
{this.state.stackComponentViewers.map((viewer) => {
return (
<div className="component-view component-stack-item">
<ComponentView
key={viewer.identifier}
componentViewer={viewer}
manualDealloc={true}
application={this.application}
appState={this.appState}
/>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}
}