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

@@ -16,30 +16,24 @@ import {
ComponentArea,
ComponentViewerInterface,
ContentType,
EditorFontSize,
EditorLineHeight,
isPayloadSourceInternalChange,
isPayloadSourceRetrieved,
NoteType,
NoteViewController,
PayloadEmitSource,
PrefKey,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
SNComponent,
SNNote,
WebAppEvent,
} from '@standardnotes/snjs'
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { EditorEventSource } from '../../Types/EditorEventSource'
import { BlockEditor } from '../BlockEditor/BlockEditorComponent'
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 { getPlaintextFontSize } from '../../Utils/getPlaintextFontSize'
import NoteStatusIndicator, { NoteStatus } from './NoteStatusIndicator'
import NoteViewFileDropTarget from './NoteViewFileDropTarget'
import { NoteViewProps } from './NoteViewProps'
@@ -48,9 +42,10 @@ import {
transactionForDisassociateComponentWithCurrentNote,
} from './TransactionFunctions'
import { SuperEditorContentId } from '@standardnotes/blocks-editor'
import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
const NoteEditingDisabledText = 'Note editing disabled.'
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
@@ -63,7 +58,6 @@ type State = {
editorComponentViewerDidAlreadyReload?: boolean
editorStateDidLoad: boolean
editorTitle: string
editorText: string
isDesktop?: boolean
lockText: string
marginResizersEnabled?: boolean
@@ -75,21 +69,14 @@ type State = {
spellcheck: boolean
stackComponentViewers: ComponentViewerInterface[]
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
plaintextEditorFocused?: boolean
monospaceFont?: boolean
plainEditorFocused?: boolean
leftResizerWidth: number
leftResizerOffset: number
rightResizerWidth: number
rightResizerOffset: number
monospaceFont?: boolean
lineHeight?: EditorLineHeight
fontSize?: EditorFontSize
updateSavingIndicator?: boolean
editorFeatureIdentifier?: string
noteType?: NoteType
}
@@ -98,23 +85,17 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout
private lastEditorFocusEventSource?: EditorEventSource
onEditorComponentLoad?: () => void
private removeTrashKeyObserver?: () => void
private removeTabObserver?: () => void
private removeComponentStreamObserver?: () => void
private removeComponentManagerObserver?: () => void
private removeInnerNoteObserver?: () => void
private removeWebAppEventObserver: () => void
private needsAdjustMobileCursor = false
private isAdjustingMobileCursor = false
private protectionTimeoutId: ReturnType<typeof setTimeout> | null = null
private noteViewElementRef: RefObject<HTMLDivElement>
private editorContentRef: RefObject<HTMLDivElement>
private plainEditorRef?: RefObject<PlainEditorInterface>
constructor(props: NoteViewProps) {
super(props, props.application)
@@ -130,18 +111,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25)
this.textAreaChangeDebounceSave = debounce(this.textAreaChangeDebounceSave, TextareaDebounce)
this.removeWebAppEventObserver = props.application.addWebEventObserver((event) => {
if (event === WebAppEvent.MobileKeyboardWillChangeFrame) {
this.scrollMobileCursorIntoViewAfterWebviewResize()
}
})
this.state = {
availableStackComponents: [],
editorStateDidLoad: false,
editorText: '',
editorTitle: '',
isDesktop: isDesktopApplication(),
lockText: NoteEditingDisabledText,
@@ -152,7 +124,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
spellcheck: true,
stackComponentViewers: [],
syncTakingTooLong: false,
textareaUnloading: false,
leftResizerWidth: 0,
leftResizerOffset: 0,
rightResizerWidth: 0,
@@ -165,16 +136,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.editorContentRef = createRef<HTMLDivElement>()
}
scrollMobileCursorIntoViewAfterWebviewResize() {
if (this.needsAdjustMobileCursor) {
this.needsAdjustMobileCursor = false
this.isAdjustingMobileCursor = true
document.getElementById('note-text-editor')?.blur()
document.getElementById('note-text-editor')?.focus()
this.isAdjustingMobileCursor = false
}
}
override deinit() {
super.deinit()
;(this.controller as unknown) = undefined
@@ -194,28 +155,20 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.clearNoteProtectionInactivityTimer()
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
this.removeWebAppEventObserver?.()
;(this.removeWebAppEventObserver as unknown) = undefined
this.removeTabObserver?.()
this.removeTabObserver = undefined
this.onEditorComponentLoad = undefined
this.statusTimeout = undefined
;(this.onPanelResizeFinish as unknown) = undefined
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
;(this.editorComponentViewerRequestsReload as unknown) = undefined
;(this.onTextAreaChange as unknown) = undefined
;(this.onTitleEnter as unknown) = undefined
;(this.onTitleChange as unknown) = undefined
;(this.onContentFocus as unknown) = undefined
;(this.onPanelResizeFinish as unknown) = undefined
;(this.stackComponentExpanded as unknown) = undefined
;(this.toggleStackComponent as unknown) = undefined
;(this.onSystemEditorRef as unknown) = undefined
;(this.debounceReloadEditorComponent as unknown) = undefined
;(this.textAreaChangeDebounceSave as unknown) = undefined
;(this.editorContentRef as unknown) = undefined
;(this.plainEditorRef as unknown) = undefined
}
getState() {
@@ -271,9 +224,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
if (this.controller.isTemplateNote) {
setTimeout(() => {
if (this.controller.templateNoteOptions?.autofocusBehavior === 'editor') {
this.focusEditor()
} else {
if (this.controller.templateNoteOptions?.autofocusBehavior === 'title') {
this.focusTitle()
}
})
@@ -296,34 +247,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
throw Error('Editor received changes for non-current note')
}
let title = this.state.editorTitle,
text = this.state.editorText
let title = this.state.editorTitle
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,
@@ -334,7 +273,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({
editorFeatureIdentifier: note.editorIdentifier,
noteType: note.noteType,
editorText: note.text,
editorTitle: note.title,
})
@@ -625,36 +563,13 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}
}
onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
this.setState({
editorText: text,
})
this.textAreaChangeDebounceSave()
}
textAreaChangeDebounceSave = () => {
log(LoggingDomain.NoteView, 'Performing save after debounce')
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
isUserModified: true,
})
.catch(console.error)
}
onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
if (key !== KeyboardKey.Enter) {
return
}
currentTarget.blur()
this.focusEditor()
this.plainEditorRef?.current?.focus()
}
onTitleChange: ChangeEventHandler<HTMLInputElement> = ({ currentTarget }) => {
@@ -667,49 +582,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})
this.controller
.save({
editorValues: {
title: title,
text: this.state.editorText,
},
.saveAndAwaitLocalPropagation({
title: title,
isUserModified: true,
dontUpdatePreviews: true,
dontGeneratePreviews: true,
})
.catch(console.error)
}
focusEditor() {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
this.lastEditorFocusEventSource = EditorEventSource.Script
element.focus()
}
}
focusTitle() {
document.getElementById(ElementIds.NoteTitleEditor)?.focus()
}
onContentFocus = () => {
if (!this.isAdjustingMobileCursor) {
this.needsAdjustMobileCursor = true
}
if (this.lastEditorFocusEventSource) {
this.application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: this.lastEditorFocusEventSource })
}
this.lastEditorFocusEventSource = undefined
this.setState({ plaintextEditorFocused: true })
}
onContentBlur = () => {
if (this.lastEditorFocusEventSource) {
this.application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: this.lastEditorFocusEventSource })
}
this.lastEditorFocusEventSource = undefined
this.setState({ plaintextEditorFocused: false })
}
setShowProtectedOverlay(show: boolean) {
this.viewControllerManager.notesController.setShowProtectedWarning(show)
}
@@ -737,13 +621,11 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.performNoteDeletion(this.note)
} else {
this.controller
.save({
editorValues: {
title: this.state.editorTitle,
text: this.state.editorText,
},
.saveAndAwaitLocalPropagation({
title: this.state.editorTitle,
bypassDebouncer: true,
dontUpdatePreviews: true,
dontGeneratePreviews: true,
isUserModified: true,
customMutate: (mutator) => {
mutator.trashed = true
},
@@ -773,15 +655,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
async reloadSpellcheck() {
const spellcheck = this.viewControllerManager.notesController.getSpellcheckStateForNote(this.note)
if (spellcheck !== this.state.spellcheck) {
this.setState({ textareaUnloading: true })
this.setState({ textareaUnloading: false })
reloadFont(this.state.monospaceFont)
this.setState({
spellcheck,
})
this.setState({ spellcheck })
}
}
@@ -797,10 +673,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
PrefDefaults[PrefKey.EditorResizersEnabled],
)
const lineHeight = this.application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = this.application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
const updateSavingIndicator = this.application.getPreference(
PrefKey.UpdateSavingStatusIndicator,
PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
@@ -811,8 +683,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
this.setState({
monospaceFont,
marginResizersEnabled,
lineHeight,
fontSize,
updateSavingIndicator,
})
@@ -904,82 +775,20 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})
}
onSystemEditorRef = (ref: HTMLTextAreaElement | null) => {
if (this.removeTabObserver || !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
}
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)
},
})
const observer = new MutationObserver((records) => {
for (const record of records) {
record.removedNodes.forEach((node) => {
if (node === editor) {
this.removeTabObserver?.()
this.removeTabObserver = undefined
}
})
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
ensureNoteIsInsertedBeforeUIAction = async () => {
if (this.controller.isTemplateNote) {
await this.controller.insertTemplatedNote()
}
}
onPlainFocus = () => {
this.setState({ plainEditorFocused: true })
}
onPlainBlur = () => {
this.setState({ plainEditorFocused: false })
}
override render() {
if (this.controller.dealloced) {
return null
@@ -996,12 +805,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)
}
const renderHeaderOptions = isMobileScreen() ? !this.state.plaintextEditorFocused : true
const renderHeaderOptions = isMobileScreen() ? !this.state.plainEditorFocused : true
const editorMode =
this.note.noteType === NoteType.Blocks
? 'blocks'
: this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading
this.note.noteType === NoteType.Super
? 'super'
: this.state.editorStateDidLoad && !this.state.editorComponentViewer
? 'plain'
: this.state.editorComponentViewer
? 'component'
@@ -1095,7 +904,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
</div>
)}
</div>
{editorMode !== 'blocks' && (
{editorMode !== 'super' && (
<LinkedItemBubblesContainer linkingController={this.viewControllerManager.linkingController} />
)}
</div>
@@ -1103,7 +912,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<div
id={ElementIds.EditorContent}
className={`${ElementIds.EditorContent} z-editor-content overflow-scroll`}
className={`${ElementIds.EditorContent} z-editor-content overflow-auto`}
ref={this.editorContentRef}
>
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
@@ -1133,34 +942,26 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)}
{editorMode === 'plain' && (
<textarea
autoComplete="off"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={this.onTextAreaChange}
onFocus={this.onContentFocus}
onBlur={this.onContentBlur}
readOnly={this.state.noteLocked}
ref={(ref) => ref && this.onSystemEditorRef(ref)}
spellCheck={this.state.spellcheck}
value={this.state.editorText}
className={classNames(
'editable font-editor flex-grow',
this.state.lineHeight && `leading-${this.state.lineHeight.toLowerCase()}`,
this.state.fontSize && getPlaintextFontSize(this.state.fontSize),
)}
></textarea>
<PlainEditor
application={this.application}
spellcheck={this.state.spellcheck}
ref={this.plainEditorRef}
controller={this.controller}
locked={this.state.noteLocked}
onFocus={this.onPlainFocus}
onBlur={this.onPlainBlur}
/>
)}
{editorMode === 'blocks' && (
{editorMode === 'super' && (
<div className={classNames('blocks-editor w-full flex-grow overflow-hidden overflow-y-scroll')}>
<BlockEditor
<SuperEditor
key={this.note.uuid}
application={this.application}
note={this.note}
linkingController={this.viewControllerManager.linkingController}
filesController={this.viewControllerManager.filesController}
spellcheck={this.state.spellcheck}
controller={this.controller}
/>
</div>
)}