feat(labs): super editor (#2001)
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteMutator, SNNote } from '@standardnotes/snjs'
|
||||
|
||||
export class BlockEditorController {
|
||||
constructor(private note: SNNote, private application: WebApplication) {
|
||||
this.note = note
|
||||
this.application = application
|
||||
}
|
||||
|
||||
deinit() {
|
||||
;(this.note as unknown) = undefined
|
||||
;(this.application as unknown) = undefined
|
||||
}
|
||||
|
||||
async save(values: { text: string; previewPlain: string; previewHtml?: string }): Promise<void> {
|
||||
await this.application.mutator.changeAndSaveItem<NoteMutator>(this.note, (mutator) => {
|
||||
mutator.text = values.text
|
||||
mutator.preview_plain = values.previewPlain
|
||||
mutator.preview_html = values.previewHtml
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
|
||||
import { createEditorMenuGroups } from '../../Utils/createEditorMenuGroups'
|
||||
import { reloadFont } from '../NoteView/FontFunctions'
|
||||
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
|
||||
import { SuperNoteImporter } from '../BlockEditor/SuperNoteImporter'
|
||||
import { SuperNoteImporter } from '../NoteView/SuperEditor/SuperNoteImporter'
|
||||
|
||||
type ChangeEditorMenuProps = {
|
||||
application: WebApplication
|
||||
@@ -114,7 +114,7 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (itemToBeSelected.noteType === NoteType.Blocks) {
|
||||
if (itemToBeSelected.noteType === NoteType.Super) {
|
||||
setPendingSuperItem(itemToBeSelected)
|
||||
handleDisableClickoutsideRequest?.()
|
||||
setShowSuperImporter(true)
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
ComponentArea,
|
||||
ComponentMutator,
|
||||
FeatureIdentifier,
|
||||
NewNoteTitleFormat,
|
||||
PrefKey,
|
||||
SNComponent,
|
||||
TagPreferences,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FeatureIdentifier, NewNoteTitleFormat, PrefKey, EditorIdentifier, TagPreferences } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
@@ -16,31 +8,14 @@ import { WebApplication } from '@/Application/Application'
|
||||
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
|
||||
import { PreferenceMode } from './PreferenceMode'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDropdownItemsForAllEditors, PlainEditorType } from '@/Utils/DropdownItemsForEditors'
|
||||
import { EditorOption, getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { NoteTitleFormatOptions } from './NoteTitleFormatOptions'
|
||||
|
||||
const PrefChangeDebounceTimeInMs = 25
|
||||
|
||||
const HelpPageUrl = 'https://day.js.org/docs/en/display/format#list-of-all-available-formats'
|
||||
|
||||
const NoteTitleFormatOptions = [
|
||||
{
|
||||
label: 'Current date and time',
|
||||
value: NewNoteTitleFormat.CurrentDateAndTime,
|
||||
},
|
||||
{
|
||||
label: 'Current note count',
|
||||
value: NewNoteTitleFormat.CurrentNoteCount,
|
||||
},
|
||||
{
|
||||
label: 'Custom format',
|
||||
value: NewNoteTitleFormat.CustomFormat,
|
||||
},
|
||||
{
|
||||
label: 'Empty',
|
||||
value: NewNoteTitleFormat.Empty,
|
||||
},
|
||||
]
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
selectedTag: AnyTag
|
||||
@@ -57,22 +32,24 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
disabled,
|
||||
}: Props) => {
|
||||
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
|
||||
const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState<string>(PlainEditorType)
|
||||
const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState<EditorIdentifier>(
|
||||
FeatureIdentifier.PlainEditor,
|
||||
)
|
||||
const [newNoteTitleFormat, setNewNoteTitleFormat] = useState<NewNoteTitleFormat>(
|
||||
NewNoteTitleFormat.CurrentDateAndTime,
|
||||
)
|
||||
const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('')
|
||||
|
||||
const getGlobalEditorDefault = useCallback((): SNComponent | undefined => {
|
||||
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
|
||||
const getGlobalEditorDefaultIdentifier = useCallback((): string => {
|
||||
return application.geDefaultEditorIdentifier()
|
||||
}, [application])
|
||||
|
||||
const reloadPreferences = useCallback(() => {
|
||||
if (mode === 'tag' && selectedTag.preferences?.editorIdentifier) {
|
||||
setDefaultEditorIdentifier(selectedTag.preferences?.editorIdentifier)
|
||||
} else {
|
||||
const globalDefault = getGlobalEditorDefault()
|
||||
setDefaultEditorIdentifier(globalDefault?.identifier || PlainEditorType)
|
||||
const globalDefault = getGlobalEditorDefaultIdentifier()
|
||||
setDefaultEditorIdentifier(globalDefault)
|
||||
}
|
||||
|
||||
if (mode === 'tag' && selectedTag.preferences?.newNoteTitleFormat) {
|
||||
@@ -82,7 +59,14 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]),
|
||||
)
|
||||
}
|
||||
}, [mode, selectedTag, application, getGlobalEditorDefault, setDefaultEditorIdentifier, setNewNoteTitleFormat])
|
||||
}, [
|
||||
mode,
|
||||
selectedTag,
|
||||
application,
|
||||
getGlobalEditorDefaultIdentifier,
|
||||
setDefaultEditorIdentifier,
|
||||
setNewNoteTitleFormat,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'tag' && selectedTag.preferences?.customNoteTitleFormat) {
|
||||
@@ -107,52 +91,22 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const removeEditorGlobalDefault = (application: WebApplication, component: SNComponent) => {
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.defaultEditor = false
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
const makeEditorGlobalDefault = (
|
||||
application: WebApplication,
|
||||
component: SNComponent,
|
||||
currentDefault?: SNComponent,
|
||||
) => {
|
||||
if (currentDefault) {
|
||||
removeEditorGlobalDefault(application, currentDefault)
|
||||
}
|
||||
application.mutator
|
||||
.changeAndSaveItem(component, (m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.defaultEditor = true
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setEditorItems(getDropdownItemsForAllEditors(application))
|
||||
}, [application])
|
||||
|
||||
const setDefaultEditor = (value: string) => {
|
||||
setDefaultEditorIdentifier(value as FeatureIdentifier)
|
||||
const setDefaultEditor = useCallback(
|
||||
(value: EditorOption['value']) => {
|
||||
setDefaultEditorIdentifier(value as FeatureIdentifier)
|
||||
|
||||
if (mode === 'global') {
|
||||
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
|
||||
const currentDefault = getGlobalEditorDefault()
|
||||
|
||||
if (value !== PlainEditorType) {
|
||||
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
|
||||
makeEditorGlobalDefault(application, editorComponent, currentDefault)
|
||||
} else if (currentDefault) {
|
||||
removeEditorGlobalDefault(application, currentDefault)
|
||||
if (mode === 'global') {
|
||||
void application.setPreference(PrefKey.DefaultEditorIdentifier, value)
|
||||
} else {
|
||||
void changePreferencesCallback({ editorIdentifier: value })
|
||||
}
|
||||
} else {
|
||||
void changePreferencesCallback({ editorIdentifier: value })
|
||||
}
|
||||
}
|
||||
},
|
||||
[application, changePreferencesCallback, mode],
|
||||
)
|
||||
|
||||
const debounceTimeoutRef = useRef<number>()
|
||||
|
||||
@@ -187,7 +141,7 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
label="Select the default note type"
|
||||
items={editorItems}
|
||||
value={defaultEditorIdentifier}
|
||||
onChange={setDefaultEditor}
|
||||
onChange={(value) => setDefaultEditor(value as EditorOption['value'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,7 +165,10 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
<div className="mt-2">
|
||||
<input
|
||||
disabled={disabled}
|
||||
className="w-full min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm focus-within:ring-2 focus-within:ring-info"
|
||||
className={classNames(
|
||||
'w-full min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm',
|
||||
'focus-within:ring-2 focus-within:ring-info',
|
||||
)}
|
||||
placeholder="e.g. YYYY-MM-DD"
|
||||
value={customNoteTitleFormat}
|
||||
onChange={handleCustomFormatInputChange}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NewNoteTitleFormat } from '@standardnotes/snjs'
|
||||
|
||||
export const NoteTitleFormatOptions = [
|
||||
{
|
||||
label: 'Current date and time',
|
||||
value: NewNoteTitleFormat.CurrentDateAndTime,
|
||||
},
|
||||
{
|
||||
label: 'Current note count',
|
||||
value: NewNoteTitleFormat.CurrentNoteCount,
|
||||
},
|
||||
{
|
||||
label: 'Custom format',
|
||||
value: NewNoteTitleFormat.CustomFormat,
|
||||
},
|
||||
{
|
||||
label: 'Empty',
|
||||
value: NewNoteTitleFormat.Empty,
|
||||
},
|
||||
]
|
||||
@@ -25,9 +25,6 @@ const ListItemNotePreviewText: FunctionComponent<Props> = ({ item, hidePreview,
|
||||
{!item.preview_html && item.preview_plain && (
|
||||
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.preview_plain}</div>
|
||||
)}
|
||||
{!item.preview_html && !item.preview_plain && item.text && (
|
||||
<div className={`leading-1.3 line-clamp-${lineLimit} mt-1 overflow-hidden`}>{item.text}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
import { isFile, SNNote } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||
@@ -36,9 +35,8 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||
|
||||
const listItemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const editorForNote = application.componentManager.editorForNote(item as SNNote)
|
||||
const editorName = editorForNote?.name ?? PLAIN_EDITOR_NAME
|
||||
const [icon, tint] = getIconAndTintForNoteType(editorForNote?.package_info.note_type)
|
||||
const noteType = item.noteType || application.componentManager.editorForNote(item)?.package_info.note_type
|
||||
const [icon, tint] = getIconAndTintForNoteType(noteType)
|
||||
const hasFiles = application.items.itemsReferencingItem(item).filter(isFile).length > 0
|
||||
|
||||
const openNoteContextMenu = (posX: number, posY: number) => {
|
||||
@@ -93,7 +91,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
|
||||
>
|
||||
{!hideIcon ? (
|
||||
<div className="mr-0 flex flex-col items-center justify-between p-4 pr-4">
|
||||
<Icon ariaLabel={`Icon for ${editorName}`} type={icon} className={`text-accessory-tint-${tint}`} />
|
||||
<Icon type={icon} className={`text-accessory-tint-${tint}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="pr-4" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileItem, FileViewController, NoteViewController } from '@standardnotes/snjs'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { AbstractComponent } from '@/Components/Abstract/PureComponent'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import MultipleSelectedNotes from '@/Components/MultipleSelectedNotes/MultipleSelectedNotes'
|
||||
@@ -8,6 +8,8 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
|
||||
import ResponsivePaneContent from '../ResponsivePane/ResponsivePaneContent'
|
||||
import FileView from '../FileView/FileView'
|
||||
import NoteView from '../NoteView/NoteView'
|
||||
import { NoteViewController } from '../NoteView/Controller/NoteViewController'
|
||||
import { FileViewController } from '../NoteView/Controller/FileViewController'
|
||||
|
||||
type State = {
|
||||
showMultipleSelectedNotes: boolean
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const EditorSaveTimeoutDebounce = {
|
||||
Desktop: 350,
|
||||
ImmediateChange: 100,
|
||||
NativeMobileWeb: 700,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FileItem } from '@standardnotes/models'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { SNApplication } from '@standardnotes/snjs'
|
||||
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
|
||||
|
||||
export class FileViewController implements ItemViewControllerInterface {
|
||||
public dealloced = false
|
||||
private removeStreamObserver?: () => void
|
||||
public runtimeId = `${Math.random()}`
|
||||
|
||||
constructor(private application: SNApplication, public item: FileItem) {}
|
||||
|
||||
deinit() {
|
||||
this.dealloced = true
|
||||
this.removeStreamObserver?.()
|
||||
;(this.removeStreamObserver as unknown) = undefined
|
||||
;(this.application as unknown) = undefined
|
||||
;(this.item as unknown) = undefined
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.streamItems()
|
||||
}
|
||||
|
||||
private streamItems() {
|
||||
this.removeStreamObserver = this.application.streamItems<FileItem>(ContentType.File, ({ changed, inserted }) => {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
|
||||
const files = changed.concat(inserted)
|
||||
|
||||
const matchingFile = files.find((item) => {
|
||||
return item.uuid === this.item.uuid
|
||||
})
|
||||
|
||||
if (matchingFile) {
|
||||
this.item = matchingFile
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { FileItem, SNNote } from '@standardnotes/snjs'
|
||||
import { NoteViewController } from './NoteViewController'
|
||||
import { FileViewController } from './FileViewController'
|
||||
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
|
||||
|
||||
type ItemControllerGroupChangeCallback = (activeController: NoteViewController | FileViewController | undefined) => void
|
||||
|
||||
export class ItemGroupController {
|
||||
public itemControllers: (NoteViewController | FileViewController)[] = []
|
||||
changeObservers: ItemControllerGroupChangeCallback[] = []
|
||||
eventObservers: (() => void)[] = []
|
||||
|
||||
constructor(private application: WebApplication) {}
|
||||
|
||||
public deinit(): void {
|
||||
;(this.application as unknown) = undefined
|
||||
|
||||
this.eventObservers.forEach((removeObserver) => {
|
||||
removeObserver()
|
||||
})
|
||||
|
||||
this.changeObservers.length = 0
|
||||
|
||||
for (const controller of this.itemControllers) {
|
||||
this.closeItemController(controller, { notify: false })
|
||||
}
|
||||
|
||||
this.itemControllers.length = 0
|
||||
}
|
||||
|
||||
async createItemController(context: {
|
||||
file?: FileItem
|
||||
note?: SNNote
|
||||
templateOptions?: TemplateNoteViewControllerOptions
|
||||
}): Promise<NoteViewController | FileViewController> {
|
||||
if (this.activeItemViewController) {
|
||||
this.closeItemController(this.activeItemViewController, { notify: false })
|
||||
}
|
||||
|
||||
let controller!: NoteViewController | FileViewController
|
||||
|
||||
if (context.file) {
|
||||
controller = new FileViewController(this.application, context.file)
|
||||
} else if (context.note) {
|
||||
controller = new NoteViewController(this.application, context.note)
|
||||
} else if (context.templateOptions) {
|
||||
controller = new NoteViewController(this.application, undefined, context.templateOptions)
|
||||
} else {
|
||||
throw Error('Invalid input to createItemController')
|
||||
}
|
||||
|
||||
this.itemControllers.push(controller)
|
||||
|
||||
await controller.initialize()
|
||||
|
||||
this.notifyObservers()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
public closeItemController(
|
||||
controller: NoteViewController | FileViewController,
|
||||
{ notify = true }: { notify: boolean } = { notify: true },
|
||||
): void {
|
||||
controller.deinit()
|
||||
|
||||
removeFromArray(this.itemControllers, controller)
|
||||
|
||||
if (notify) {
|
||||
this.notifyObservers()
|
||||
}
|
||||
}
|
||||
|
||||
closeActiveItemController(): void {
|
||||
const activeController = this.activeItemViewController
|
||||
|
||||
if (activeController) {
|
||||
this.closeItemController(activeController, { notify: true })
|
||||
}
|
||||
}
|
||||
|
||||
closeAllItemControllers(): void {
|
||||
for (const controller of this.itemControllers) {
|
||||
this.closeItemController(controller, { notify: false })
|
||||
}
|
||||
|
||||
this.notifyObservers()
|
||||
}
|
||||
|
||||
get activeItemViewController(): NoteViewController | FileViewController | undefined {
|
||||
return this.itemControllers[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Notifies observer when the active controller has changed.
|
||||
*/
|
||||
public addActiveControllerChangeObserver(callback: ItemControllerGroupChangeCallback): () => void {
|
||||
this.changeObservers.push(callback)
|
||||
|
||||
if (this.activeItemViewController) {
|
||||
callback(this.activeItemViewController)
|
||||
}
|
||||
|
||||
const thislessChangeObservers = this.changeObservers
|
||||
return () => {
|
||||
removeFromArray(thislessChangeObservers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyObservers(): void {
|
||||
for (const observer of this.changeObservers) {
|
||||
observer(this.activeItemViewController)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SNNote, FileItem } from '@standardnotes/models'
|
||||
|
||||
export interface ItemViewControllerInterface {
|
||||
item: SNNote | FileItem
|
||||
|
||||
deinit: () => void
|
||||
initialize(addTagHierarchy?: boolean): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import {
|
||||
MutatorService,
|
||||
SNComponentManager,
|
||||
SNComponent,
|
||||
SNTag,
|
||||
ItemsClientInterface,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
|
||||
import { NoteViewController } from './NoteViewController'
|
||||
|
||||
describe('note view controller', () => {
|
||||
let application: WebApplication
|
||||
let componentManager: SNComponentManager
|
||||
|
||||
beforeEach(() => {
|
||||
application = {} as jest.Mocked<WebApplication>
|
||||
application.streamItems = jest.fn()
|
||||
application.getPreference = jest.fn().mockReturnValue(true)
|
||||
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
|
||||
|
||||
componentManager = {} as jest.Mocked<SNComponentManager>
|
||||
componentManager.legacyGetDefaultEditor = jest.fn()
|
||||
Object.defineProperty(application, 'componentManager', { value: componentManager })
|
||||
|
||||
const mutator = {} as jest.Mocked<MutatorService>
|
||||
mutator.createTemplateItem = jest.fn().mockReturnValue({} as SNNote)
|
||||
Object.defineProperty(application, 'mutator', { value: mutator })
|
||||
})
|
||||
|
||||
it('should create notes with plaintext note type', async () => {
|
||||
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
|
||||
|
||||
const controller = new NoteViewController(application)
|
||||
await controller.initialize()
|
||||
|
||||
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
|
||||
ContentType.Note,
|
||||
expect.objectContaining({ noteType: NoteType.Plain }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should create notes with markdown note type', async () => {
|
||||
componentManager.legacyGetDefaultEditor = jest.fn().mockReturnValue({
|
||||
identifier: FeatureIdentifier.MarkdownProEditor,
|
||||
} as SNComponent)
|
||||
|
||||
componentManager.componentWithIdentifier = jest.fn().mockReturnValue({
|
||||
identifier: FeatureIdentifier.MarkdownProEditor,
|
||||
} as SNComponent)
|
||||
|
||||
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.MarkdownProEditor)
|
||||
|
||||
const controller = new NoteViewController(application)
|
||||
await controller.initialize()
|
||||
|
||||
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
|
||||
ContentType.Note,
|
||||
expect.objectContaining({ noteType: NoteType.Markdown }),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
it('should add tag to note if default tag is set', async () => {
|
||||
application.geDefaultEditorIdentifier = jest.fn().mockReturnValue(FeatureIdentifier.PlainEditor)
|
||||
|
||||
const tag = {
|
||||
uuid: 'tag-uuid',
|
||||
} as jest.Mocked<SNTag>
|
||||
|
||||
application.items.findItem = jest.fn().mockReturnValue(tag)
|
||||
application.items.addTagToNote = jest.fn()
|
||||
|
||||
const controller = new NoteViewController(application, undefined, { tag: tag.uuid })
|
||||
await controller.initialize()
|
||||
|
||||
expect(controller['defaultTag']).toEqual(tag)
|
||||
expect(application.items.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { noteTypeForEditorIdentifier } from '@standardnotes/features'
|
||||
import { InfoStrings } from '@standardnotes/services'
|
||||
import {
|
||||
NoteMutator,
|
||||
SNNote,
|
||||
SNTag,
|
||||
NoteContent,
|
||||
DecryptedItemInterface,
|
||||
PayloadEmitSource,
|
||||
PrefKey,
|
||||
} from '@standardnotes/models'
|
||||
import { UuidString } from '@standardnotes/snjs'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
|
||||
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
|
||||
import { EditorSaveTimeoutDebounce } from './EditorSaveTimeoutDebounce'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
|
||||
export type EditorValues = {
|
||||
title: string
|
||||
text: string
|
||||
}
|
||||
|
||||
const StringEllipses = '...'
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
export class NoteViewController implements ItemViewControllerInterface {
|
||||
public item!: SNNote
|
||||
public dealloced = false
|
||||
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
|
||||
private disposers: (() => void)[] = []
|
||||
public isTemplateNote = false
|
||||
private saveTimeout?: ReturnType<typeof setTimeout>
|
||||
private defaultTagUuid: UuidString | undefined
|
||||
private defaultTag?: SNTag
|
||||
public runtimeId = `${Math.random()}`
|
||||
public needsInit = true
|
||||
|
||||
constructor(
|
||||
private application: WebApplication,
|
||||
item?: SNNote,
|
||||
public templateNoteOptions?: TemplateNoteViewControllerOptions,
|
||||
) {
|
||||
if (item) {
|
||||
this.item = item
|
||||
}
|
||||
|
||||
if (templateNoteOptions) {
|
||||
this.defaultTagUuid = templateNoteOptions.tag
|
||||
}
|
||||
|
||||
if (this.defaultTagUuid) {
|
||||
this.defaultTag = this.application.items.findItem(this.defaultTagUuid) as SNTag
|
||||
}
|
||||
}
|
||||
|
||||
deinit(): void {
|
||||
this.dealloced = true
|
||||
for (const disposer of this.disposers) {
|
||||
disposer()
|
||||
}
|
||||
this.disposers.length = 0
|
||||
;(this.application as unknown) = undefined
|
||||
;(this.item as unknown) = undefined
|
||||
|
||||
this.innerValueChangeObservers.length = 0
|
||||
|
||||
this.saveTimeout = undefined
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.needsInit) {
|
||||
throw Error('NoteViewController already initialized')
|
||||
}
|
||||
|
||||
log(LoggingDomain.NoteView, 'Initializing NoteViewController')
|
||||
|
||||
this.needsInit = false
|
||||
|
||||
const addTagHierarchy = this.application.getPreference(PrefKey.NoteAddToParentFolders, true)
|
||||
|
||||
if (!this.item) {
|
||||
log(LoggingDomain.NoteView, 'Initializing as template note')
|
||||
|
||||
const editorIdentifier = this.application.geDefaultEditorIdentifier(this.defaultTag)
|
||||
|
||||
const noteType = noteTypeForEditorIdentifier(editorIdentifier)
|
||||
|
||||
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
|
||||
ContentType.Note,
|
||||
{
|
||||
text: '',
|
||||
title: this.templateNoteOptions?.title || '',
|
||||
noteType: noteType,
|
||||
editorIdentifier: editorIdentifier,
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
created_at: this.templateNoteOptions?.createdAt || new Date(),
|
||||
},
|
||||
)
|
||||
|
||||
this.isTemplateNote = true
|
||||
this.item = note
|
||||
|
||||
if (this.defaultTagUuid) {
|
||||
const tag = this.application.items.findItem(this.defaultTagUuid) as SNTag
|
||||
await this.application.items.addTagToNote(note, tag, addTagHierarchy)
|
||||
}
|
||||
|
||||
this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush)
|
||||
}
|
||||
|
||||
this.streamItems()
|
||||
}
|
||||
|
||||
private notifyObservers(note: SNNote, source: PayloadEmitSource): void {
|
||||
for (const observer of this.innerValueChangeObservers) {
|
||||
observer(note, source)
|
||||
}
|
||||
}
|
||||
|
||||
private streamItems() {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
|
||||
this.disposers.push(
|
||||
this.application.streamItems<SNNote>(ContentType.Note, ({ changed, inserted, source }) => {
|
||||
if (this.dealloced) {
|
||||
return
|
||||
}
|
||||
|
||||
const notes = changed.concat(inserted)
|
||||
|
||||
const matchingNote = notes.find((item) => {
|
||||
return item.uuid === this.item.uuid
|
||||
})
|
||||
|
||||
if (matchingNote) {
|
||||
this.isTemplateNote = false
|
||||
this.item = matchingNote
|
||||
this.notifyObservers(matchingNote, source)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
public insertTemplatedNote(): Promise<DecryptedItemInterface> {
|
||||
log(LoggingDomain.NoteView, 'Inserting template note')
|
||||
this.isTemplateNote = false
|
||||
return this.application.mutator.insertItem(this.item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register to be notified when the controller's note's inner values change
|
||||
* (and thus a new object reference is created)
|
||||
*/
|
||||
public addNoteInnerValueChangeObserver(callback: (note: SNNote, source: PayloadEmitSource) => void): () => void {
|
||||
this.innerValueChangeObservers.push(callback)
|
||||
|
||||
if (this.item) {
|
||||
callback(this.item, PayloadEmitSource.InitialObserverRegistrationPush)
|
||||
}
|
||||
|
||||
const thislessChangeObservers = this.innerValueChangeObservers
|
||||
return () => {
|
||||
removeFromArray(thislessChangeObservers, callback)
|
||||
}
|
||||
}
|
||||
|
||||
public async saveAndAwaitLocalPropagation(params: {
|
||||
title?: string
|
||||
text?: string
|
||||
isUserModified: boolean
|
||||
bypassDebouncer?: boolean
|
||||
dontGeneratePreviews?: boolean
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
}): Promise<void> {
|
||||
if (this.needsInit) {
|
||||
throw Error('NoteViewController not initialized')
|
||||
}
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
|
||||
const noDebounce = params.bypassDebouncer || this.application.noAccount()
|
||||
|
||||
const syncDebouceMs = noDebounce
|
||||
? EditorSaveTimeoutDebounce.ImmediateChange
|
||||
: this.application.isNativeMobileWeb()
|
||||
? EditorSaveTimeoutDebounce.NativeMobileWeb
|
||||
: EditorSaveTimeoutDebounce.Desktop
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.undebouncedSave({ ...params, onLocalPropagationComplete: resolve })
|
||||
}, syncDebouceMs)
|
||||
})
|
||||
}
|
||||
|
||||
private async undebouncedSave(params: {
|
||||
title?: string
|
||||
text?: string
|
||||
bypassDebouncer?: boolean
|
||||
isUserModified?: boolean
|
||||
dontGeneratePreviews?: boolean
|
||||
previews?: { previewPlain: string; previewHtml?: string }
|
||||
customMutate?: (mutator: NoteMutator) => void
|
||||
onLocalPropagationComplete?: () => void
|
||||
onRemoteSyncComplete?: () => void
|
||||
}): Promise<void> {
|
||||
log(LoggingDomain.NoteView, 'Saving note', params)
|
||||
|
||||
const isTemplate = this.isTemplateNote
|
||||
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
void this.application.alertService.alert(InfoStrings.SavingWhileDocumentHidden)
|
||||
}
|
||||
|
||||
if (isTemplate) {
|
||||
await this.insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (!this.application.items.findItem(this.item.uuid)) {
|
||||
void this.application.alertService.alert(InfoStrings.InvalidNote)
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.mutator.changeItem(
|
||||
this.item,
|
||||
(mutator) => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
if (params.customMutate) {
|
||||
params.customMutate(noteMutator)
|
||||
}
|
||||
|
||||
if (params.title != undefined) {
|
||||
noteMutator.title = params.title
|
||||
}
|
||||
|
||||
if (params.text != undefined) {
|
||||
noteMutator.text = params.text
|
||||
}
|
||||
|
||||
if (params.previews) {
|
||||
noteMutator.preview_plain = params.previews.previewPlain
|
||||
noteMutator.preview_html = params.previews.previewHtml
|
||||
} else if (!params.dontGeneratePreviews && params.text != undefined) {
|
||||
const noteText = params.text || ''
|
||||
const truncate = noteText.length > NotePreviewCharLimit
|
||||
const substring = noteText.substring(0, NotePreviewCharLimit)
|
||||
const previewPlain = substring + (truncate ? StringEllipses : '')
|
||||
noteMutator.preview_plain = previewPlain
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
params.isUserModified,
|
||||
)
|
||||
|
||||
params.onLocalPropagationComplete?.()
|
||||
|
||||
void this.application.sync.sync().then(() => {
|
||||
params.onRemoteSyncComplete?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UuidString } from '@standardnotes/snjs'
|
||||
|
||||
export type TemplateNoteViewControllerOptions = {
|
||||
title?: string
|
||||
tag?: UuidString
|
||||
createdAt?: Date
|
||||
autofocusBehavior?: TemplateNoteViewAutofocusBehavior
|
||||
}
|
||||
|
||||
export type TemplateNoteViewAutofocusBehavior = 'title' | 'editor'
|
||||
@@ -8,12 +8,12 @@ import { NotesController } from '@/Controllers/NotesController'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
|
||||
NoteViewController,
|
||||
SNNote,
|
||||
NoteType,
|
||||
PayloadEmitSource,
|
||||
} from '@standardnotes/snjs'
|
||||
import NoteView from './NoteView'
|
||||
import { NoteViewController } from './Controller/NoteViewController'
|
||||
|
||||
describe('NoteView', () => {
|
||||
let noteViewController: NoteViewController
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NoteViewController } from '@standardnotes/snjs'
|
||||
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteViewController } from './Controller/NoteViewController'
|
||||
|
||||
export interface NoteViewProps {
|
||||
application: WebApplication
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export type ChangeEditorFunction = (jsonContent: string) => void
|
||||
type ChangeEditorFunctionProvider = (changeEditorFunction: ChangeEditorFunction) => void
|
||||
|
||||
export function ChangeContentCallbackPlugin({
|
||||
providerCallback,
|
||||
}: {
|
||||
providerCallback: ChangeEditorFunctionProvider
|
||||
}): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const changeContents: ChangeEditorFunction = (jsonContent: string) => {
|
||||
editor.update(() => {
|
||||
const editorState = editor.parseEditorState(jsonContent)
|
||||
editor.setEditorState(editorState)
|
||||
})
|
||||
}
|
||||
|
||||
providerCallback(changeContents)
|
||||
}, [editor, providerCallback])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INSERT_FILE_COMMAND } from './../Commands'
|
||||
import { INSERT_FILE_COMMAND } from '../Commands'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
@@ -9,7 +9,7 @@ import { ContentType, SNNote } from '@standardnotes/snjs'
|
||||
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
|
||||
import Popover from '@/Components/Popover/Popover'
|
||||
import { INSERT_BUBBLE_COMMAND, INSERT_FILE_COMMAND } from '../Commands'
|
||||
import { useLinkingController } from '../../../../Controllers/LinkingControllerProvider'
|
||||
import { useLinkingController } from '../../../../../Controllers/LinkingControllerProvider'
|
||||
import { PopoverClassNames } from '../ClassNames'
|
||||
|
||||
type Props = {
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useRef } from 'react'
|
||||
import { BlockEditorController } from './BlockEditorController'
|
||||
import { isPayloadSourceRetrieved } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useEffect, useRef } from 'react'
|
||||
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
|
||||
import { ItemSelectionPlugin } from './Plugins/ItemSelectionPlugin/ItemSelectionPlugin'
|
||||
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||
@@ -9,7 +8,7 @@ import FilePlugin from './Plugins/EncryptedFilePlugin/FilePlugin'
|
||||
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
|
||||
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
import LinkingControllerProvider from '../../Controllers/LinkingControllerProvider'
|
||||
import LinkingControllerProvider from '../../../Controllers/LinkingControllerProvider'
|
||||
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
|
||||
import ItemBubblePlugin from './Plugins/ItemBubblePlugin/ItemBubblePlugin'
|
||||
import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlugin'
|
||||
@@ -17,29 +16,48 @@ import { FilesController } from '@/Controllers/FilesController'
|
||||
import FilesControllerProvider from '@/Controllers/FilesControllerProvider'
|
||||
import DatetimePlugin from './Plugins/DateTimePlugin/DateTimePlugin'
|
||||
import AutoLinkPlugin from './Plugins/AutoLinkPlugin/AutoLinkPlugin'
|
||||
import { NoteViewController } from '../Controller/NoteViewController'
|
||||
import {
|
||||
ChangeContentCallbackPlugin,
|
||||
ChangeEditorFunction,
|
||||
} from './Plugins/ChangeContentCallback/ChangeContentCallback'
|
||||
|
||||
const NotePreviewCharLimit = 160
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
controller: NoteViewController
|
||||
linkingController: LinkingController
|
||||
filesController: FilesController
|
||||
spellcheck: boolean
|
||||
}
|
||||
|
||||
export const BlockEditor: FunctionComponent<Props> = ({
|
||||
note,
|
||||
export const SuperEditor: FunctionComponent<Props> = ({
|
||||
application,
|
||||
linkingController,
|
||||
filesController,
|
||||
spellcheck,
|
||||
controller,
|
||||
}) => {
|
||||
const controller = useRef(new BlockEditorController(note, application))
|
||||
const note = useRef(controller.item)
|
||||
const changeEditorFunction = useRef<ChangeEditorFunction>()
|
||||
const ignoreNextChange = useRef(false)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string, preview: string) => {
|
||||
void controller.current.save({ text: value, previewPlain: preview, previewHtml: undefined })
|
||||
async (value: string, preview: string) => {
|
||||
if (ignoreNextChange.current === true) {
|
||||
ignoreNextChange.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void controller.saveAndAwaitLocalPropagation({
|
||||
text: value,
|
||||
isUserModified: true,
|
||||
previews: {
|
||||
previewPlain: preview,
|
||||
previewHtml: undefined,
|
||||
},
|
||||
})
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
@@ -54,24 +72,49 @@ export const BlockEditor: FunctionComponent<Props> = ({
|
||||
[linkingController, application],
|
||||
)
|
||||
|
||||
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)) {
|
||||
ignoreNextChange.current = true
|
||||
changeEditorFunction.current?.(updatedNote.text)
|
||||
}
|
||||
|
||||
note.current = updatedNote
|
||||
})
|
||||
|
||||
return disposer
|
||||
}, [controller, controller.item.uuid])
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full px-5 py-4">
|
||||
<ErrorBoundary>
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<FilesControllerProvider controller={filesController}>
|
||||
<BlocksEditorComposer readonly={note.locked} initialValue={note.text} nodes={[FileNode, BubbleNode]}>
|
||||
<BlocksEditorComposer
|
||||
readonly={note.current.locked}
|
||||
initialValue={note.current.text}
|
||||
nodes={[FileNode, BubbleNode]}
|
||||
>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
ignoreFirstChange={true}
|
||||
className="relative relative h-full resize-none text-base focus:shadow-none focus:outline-none"
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={spellcheck}
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note} />
|
||||
<ItemSelectionPlugin currentNote={note.current} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<DatetimePlugin />
|
||||
<AutoLinkPlugin />
|
||||
<ChangeContentCallbackPlugin
|
||||
providerCallback={(callback) => (changeEditorFunction.current = callback)}
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
</BlocksEditor>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteType, SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useState } from 'react'
|
||||
import { BlockEditorController } from './BlockEditorController'
|
||||
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
|
||||
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
|
||||
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
|
||||
import ModalDialog from '@/Components/Shared/ModalDialog'
|
||||
@@ -10,6 +9,7 @@ import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
|
||||
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
|
||||
import Button from '@/Components/Button/Button'
|
||||
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
|
||||
import { NoteViewController } from '../Controller/NoteViewController'
|
||||
|
||||
export function spaceSeparatedStrings(...strings: string[]): string {
|
||||
return strings.join(' ')
|
||||
@@ -36,12 +36,31 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
|
||||
setLastValue({ text: value, previewPlain: preview })
|
||||
}, [])
|
||||
|
||||
const confirmConvert = useCallback(() => {
|
||||
const controller = new BlockEditorController(note, application)
|
||||
void controller.save({ text: lastValue.text, previewPlain: lastValue.previewPlain, previewHtml: undefined })
|
||||
const performConvert = useCallback(
|
||||
async (text: string, previewPlain: string) => {
|
||||
const controller = new NoteViewController(application, note)
|
||||
await controller.initialize()
|
||||
await controller.saveAndAwaitLocalPropagation({
|
||||
text: text,
|
||||
previews: { previewPlain: previewPlain, previewHtml: undefined },
|
||||
isUserModified: true,
|
||||
bypassDebouncer: true,
|
||||
})
|
||||
},
|
||||
[application, note],
|
||||
)
|
||||
|
||||
const confirmConvert = useCallback(async () => {
|
||||
await performConvert(lastValue.text, lastValue.previewPlain)
|
||||
closeDialog()
|
||||
onConvertComplete()
|
||||
}, [closeDialog, application, lastValue, note, onConvertComplete])
|
||||
}, [closeDialog, performConvert, onConvertComplete, lastValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (note.text.length === 0) {
|
||||
void confirmConvert()
|
||||
}
|
||||
}, [note, confirmConvert])
|
||||
|
||||
const convertAsIs = useCallback(async () => {
|
||||
const confirmed = await application.alertService.confirm(
|
||||
@@ -56,11 +75,11 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new BlockEditorController(note, application)
|
||||
void controller.save({ text: note.text, previewPlain: note.preview_plain, previewHtml: undefined })
|
||||
await performConvert(note.text, note.preview_plain)
|
||||
|
||||
closeDialog()
|
||||
onConvertComplete()
|
||||
}, [closeDialog, application, note, onConvertComplete])
|
||||
}, [closeDialog, application, note, onConvertComplete, performConvert])
|
||||
|
||||
return (
|
||||
<ModalDialog>
|
||||
@@ -77,6 +96,7 @@ export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application,
|
||||
<BlocksEditorComposer readonly initialValue={''}>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
ignoreFirstChange={false}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
previewLength={NotePreviewCharLimit}
|
||||
spellcheck={note.spellcheck}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlainEditorType } from '@/Utils/DropdownItemsForEditors'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { NoteType, PredicateCompoundOperator, PredicateJsonForm } from '@standardnotes/snjs'
|
||||
import { makeObservable, observable, action } from 'mobx'
|
||||
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
|
||||
@@ -59,7 +59,7 @@ export class CompoundPredicateBuilderController {
|
||||
this.setPredicate(index, { value: Object.values(NoteType)[0] })
|
||||
break
|
||||
case 'editorIdentifier':
|
||||
this.setPredicate(index, { value: PlainEditorType })
|
||||
this.setPredicate(index, { value: FeatureIdentifier.PlainEditor })
|
||||
break
|
||||
case 'date':
|
||||
this.setPredicate(index, { value: '1.days.ago' })
|
||||
|
||||
Reference in New Issue
Block a user