feat(labs): super editor (#2001)
This commit is contained in:
@@ -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'
|
||||
Reference in New Issue
Block a user