feat(labs): super editor (#2001)

This commit is contained in:
Mo
2022-11-16 05:54:32 -06:00
committed by GitHub
parent f0c9f899e9
commit 59f8547a8d
89 changed files with 1021 additions and 615 deletions

View File

@@ -0,0 +1,253 @@
import { WebApplication } from '@/Application/Application'
import { usePrevious } from '@/Components/ContentListView/Calendar/usePrevious'
import { ElementIds } from '@/Constants/ElementIDs'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { log, LoggingDomain } from '@/Logging'
import { Disposer } from '@/Types/Disposer'
import { EditorEventSource } from '@/Types/EditorEventSource'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import {
ApplicationEvent,
EditorFontSize,
EditorLineHeight,
isPayloadSourceRetrieved,
PrefKey,
WebAppEvent,
} from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { ChangeEventHandler, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { NoteViewController } from '../Controller/NoteViewController'
type Props = {
application: WebApplication
spellcheck: boolean
controller: NoteViewController
locked: boolean
onFocus: () => void
onBlur: () => void
}
export type PlainEditorInterface = {
focus: () => void
}
export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
({ application, spellcheck, controller, locked, onFocus, onBlur }: Props, ref) => {
const [editorText, setEditorText] = useState<string | undefined>()
const [textareaUnloading, setTextareaUnloading] = useState(false)
const [lineHeight, setLineHeight] = useState<EditorLineHeight | undefined>()
const [fontSize, setFontSize] = useState<EditorFontSize | undefined>()
const previousSpellcheck = usePrevious(spellcheck)
const lastEditorFocusEventSource = useRef<EditorEventSource | undefined>()
const needsAdjustMobileCursor = useRef(false)
const isAdjustingMobileCursor = useRef(false)
const note = useRef(controller.item)
const tabObserverDisposer = useRef<Disposer>()
useImperativeHandle(ref, () => ({
focus() {
focusEditor()
},
}))
useEffect(() => {
const disposer = controller.addNoteInnerValueChangeObserver((updatedNote, source) => {
if (updatedNote.uuid !== note.current.uuid) {
throw Error('Editor received changes for non-current note')
}
if (
isPayloadSourceRetrieved(source) ||
editorText == undefined ||
updatedNote.editorIdentifier !== note.current.editorIdentifier ||
updatedNote.noteType !== note.current.noteType
) {
setEditorText(updatedNote.text)
}
note.current = updatedNote
})
return disposer
}, [controller, editorText, controller.item.uuid, controller.item.editorIdentifier, controller.item.noteType])
const onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
setEditorText(text)
void controller.saveAndAwaitLocalPropagation({ text: text, isUserModified: true })
}
const onContentFocus = useCallback(() => {
if (!isAdjustingMobileCursor.current) {
needsAdjustMobileCursor.current = true
}
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onFocus()
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
const onContentBlur = useCallback(() => {
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onBlur()
}, [application, lastEditorFocusEventSource, onBlur])
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
if (needsAdjustMobileCursor.current) {
needsAdjustMobileCursor.current = false
isAdjustingMobileCursor.current = true
document.getElementById('note-text-editor')?.blur()
document.getElementById('note-text-editor')?.focus()
isAdjustingMobileCursor.current = false
}
}, [needsAdjustMobileCursor])
useEffect(() => {
const disposer = application.addWebEventObserver((event) => {
if (event === WebAppEvent.MobileKeyboardWillChangeFrame) {
scrollMobileCursorIntoViewAfterWebviewResize()
}
})
return disposer
}, [application, scrollMobileCursorIntoViewAfterWebviewResize])
const focusEditor = useCallback(() => {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
lastEditorFocusEventSource.current = EditorEventSource.Script
element.focus()
}
}, [])
useEffect(() => {
if (controller.isTemplateNote && controller.templateNoteOptions?.autofocusBehavior === 'editor') {
setTimeout(() => {
focusEditor()
})
}
}, [controller, focusEditor])
const reloadPreferences = useCallback(() => {
const lineHeight = application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
setLineHeight(lineHeight)
setFontSize(fontSize)
}, [application])
useEffect(() => {
reloadPreferences()
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
reloadPreferences()
})
}, [reloadPreferences, application])
useEffect(() => {
if (spellcheck !== previousSpellcheck) {
setTextareaUnloading(true)
setTimeout(() => {
setTextareaUnloading(false)
}, 0)
}
}, [spellcheck, previousSpellcheck])
const onRef = (ref: HTMLTextAreaElement | null) => {
if (tabObserverDisposer.current || !ref) {
return
}
log(LoggingDomain.NoteView, 'On system editor ref')
/**
* 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
}
tabObserverDisposer.current = application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || note.current.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
}
setEditorText(editor.value)
void controller.saveAndAwaitLocalPropagation({
text: editor.value,
bypassDebouncer: true,
isUserModified: true,
})
},
})
const observer = new MutationObserver((records) => {
for (const record of records) {
record.removedNodes.forEach((node) => {
if (node === editor) {
tabObserverDisposer.current?.()
tabObserverDisposer.current = undefined
}
})
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
if (textareaUnloading) {
return null
}
return (
<textarea
autoComplete="off"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={onTextAreaChange}
onFocus={onContentFocus}
onBlur={onContentBlur}
readOnly={locked}
ref={(ref) => ref && onRef(ref)}
spellCheck={spellcheck}
value={editorText}
className={classNames(
'editable font-editor flex-grow',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize && getPlaintextFontSize(fontSize),
)}
></textarea>
)
},
)