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

@@ -1,42 +0,0 @@
import { FileItem } from '@standardnotes/models'
import { ContentType } from '@standardnotes/common'
import { SNApplication } from '../Application/Application'
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
}
})
}
}

View File

@@ -1,127 +0,0 @@
import { FileItem, PrefKey, SNNote } from '@standardnotes/models'
import { removeFromArray } from '@standardnotes/utils'
import { ApplicationEvent } from '@standardnotes/services'
import { SNApplication } from '../Application/Application'
import { NoteViewController } from './NoteViewController'
import { FileViewController } from './FileViewController'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
type ItemControllerGroupChangeCallback = (activeController: NoteViewController | FileViewController | undefined) => void
type CreateItemControllerOptions = FileItem | SNNote | TemplateNoteViewControllerOptions
export class ItemGroupController {
public itemControllers: (NoteViewController | FileViewController)[] = []
private addTagHierarchy: boolean
changeObservers: ItemControllerGroupChangeCallback[] = []
eventObservers: (() => void)[] = []
constructor(private application: SNApplication) {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
this.eventObservers.push(
application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
this.addTagHierarchy = application.getPreference(PrefKey.NoteAddToParentFolders, true)
}),
)
}
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(options: CreateItemControllerOptions): Promise<NoteViewController | FileViewController> {
if (this.activeItemViewController) {
this.closeItemController(this.activeItemViewController, { notify: false })
}
let controller!: NoteViewController | FileViewController
if (options instanceof FileItem) {
const file = options
controller = new FileViewController(this.application, file)
} else if (options instanceof SNNote) {
const note = options
controller = new NoteViewController(this.application, note)
} else {
controller = new NoteViewController(this.application, undefined, options)
}
this.itemControllers.push(controller)
await controller.initialize(this.addTagHierarchy)
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)
}
}
}

View File

@@ -1,8 +0,0 @@
import { SNNote, FileItem } from '@standardnotes/models'
export interface ItemViewControllerInterface {
item: SNNote | FileItem
deinit: () => void
initialize(addTagHierarchy?: boolean): Promise<void>
}

View File

@@ -1,34 +0,0 @@
import { SNApplication } from './../Application/Application'
import { ContentType } from '@standardnotes/common'
import { MutatorService } from './../Services/Mutator/MutatorService'
import { SNComponentManager } from './../Services/ComponentManager/ComponentManager'
import { NoteType } from '@standardnotes/features'
import { NoteViewController } from './NoteViewController'
describe('note view controller', () => {
let application: SNApplication
beforeEach(() => {
application = {} as jest.Mocked<SNApplication>
application.streamItems = jest.fn()
const componentManager = {} as jest.Mocked<SNComponentManager>
componentManager.getDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
const mutator = {} as jest.Mocked<MutatorService>
mutator.createTemplateItem = jest.fn()
Object.defineProperty(application, 'mutator', { value: mutator })
})
it('should create notes with plaintext note type', async () => {
const controller = new NoteViewController(application)
await controller.initialize(false)
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Plain }),
expect.anything(),
)
})
})

View File

@@ -1,241 +0,0 @@
import { NoteType } from '@standardnotes/features'
import { InfoStrings } from '@standardnotes/services'
import {
NoteMutator,
SNNote,
SNTag,
NoteContent,
DecryptedItemInterface,
PayloadEmitSource,
} from '@standardnotes/models'
import { removeFromArray } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
import { UuidString } from '@Lib/Types/UuidString'
import { SNApplication } from '../Application/Application'
import { ItemViewControllerInterface } from './ItemViewControllerInterface'
import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerOptions'
export type EditorValues = {
title: string
text: string
}
const StringEllipses = '...'
const NotePreviewCharLimit = 160
const SaveTimeoutDebounc = {
Desktop: 350,
ImmediateChange: 100,
NativeMobileWeb: 700,
}
export class NoteViewController implements ItemViewControllerInterface {
public item!: SNNote
public dealloced = false
private innerValueChangeObservers: ((note: SNNote, source: PayloadEmitSource) => void)[] = []
private removeStreamObserver?: () => void
public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTagUuid: UuidString | undefined
private defaultTag?: SNTag
public runtimeId = `${Math.random()}`
constructor(
private application: SNApplication,
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
this.removeStreamObserver?.()
;(this.removeStreamObserver as unknown) = undefined
;(this.application as unknown) = undefined
;(this.item as unknown) = undefined
this.innerValueChangeObservers.length = 0
this.saveTimeout = undefined
}
async initialize(addTagHierarchy: boolean): Promise<void> {
if (!this.item) {
const editorIdentifier =
this.defaultTag?.preferences?.editorIdentifier ||
this.application.componentManager.getDefaultEditor()?.identifier
const defaultEditor = editorIdentifier
? this.application.componentManager.componentWithIdentifier(editorIdentifier)
: undefined
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
ContentType.Note,
{
text: '',
title: this.templateNoteOptions?.title || '',
noteType: defaultEditor?.noteType || NoteType.Plain,
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.removeStreamObserver = 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> {
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)
}
}
/**
* @param bypassDebouncer Calling save will debounce by default. You can pass true to save
* immediately.
* @param isUserModified This field determines if the item will be saved as a user
* modification, thus updating the user modified date displayed in the UI
* @param dontUpdatePreviews Whether this change should update the note's plain and HTML
* preview.
* @param customMutate A custom mutator function.
*/
public async save(dto: {
editorValues: EditorValues
bypassDebouncer?: boolean
isUserModified?: boolean
dontUpdatePreviews?: boolean
customMutate?: (mutator: NoteMutator) => void
}): Promise<void> {
const title = dto.editorValues.title
const text = dto.editorValues.text
const isTemplate = this.isTemplateNote
if (typeof document !== 'undefined' && document.hidden) {
void this.application.alertService.alert(InfoStrings.SavingWhileDocumentHidden)
return
}
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 (dto.customMutate) {
dto.customMutate(noteMutator)
}
noteMutator.title = title
noteMutator.text = text
if (!dto.dontUpdatePreviews) {
const noteText = text || ''
const truncate = noteText.length > NotePreviewCharLimit
const substring = noteText.substring(0, NotePreviewCharLimit)
const previewPlain = substring + (truncate ? StringEllipses : '')
// eslint-disable-next-line camelcase
noteMutator.preview_plain = previewPlain
// eslint-disable-next-line camelcase
noteMutator.preview_html = undefined
}
},
dto.isUserModified,
)
if (this.saveTimeout) {
clearTimeout(this.saveTimeout)
}
const noDebounce = dto.bypassDebouncer || this.application.noAccount()
const syncDebouceMs = noDebounce
? SaveTimeoutDebounc.ImmediateChange
: this.application.isNativeMobileWeb()
? SaveTimeoutDebounc.NativeMobileWeb
: SaveTimeoutDebounc.Desktop
this.saveTimeout = setTimeout(() => {
void this.application.sync.sync()
}, syncDebouceMs)
}
}

View File

@@ -1,10 +0,0 @@
import { UuidString } from '@Lib/Types/UuidString'
export type TemplateNoteViewControllerOptions = {
title?: string
tag?: UuidString
createdAt?: Date
autofocusBehavior?: TemplateNoteViewAutofocusBehavior
}
export type TemplateNoteViewAutofocusBehavior = 'title' | 'editor'

View File

@@ -1,5 +1 @@
export * from './NoteViewController'
export * from './FileViewController'
export * from './ItemGroupController'
export * from './ReactNativeToWebEvent'
export * from './TemplateNoteViewControllerOptions'

View File

@@ -619,7 +619,7 @@ export class SNComponentManager
return editor
}
}
const defaultEditor = this.getDefaultEditor()
const defaultEditor = this.legacyGetDefaultEditor()
if (defaultEditor && !defaultEditor.isExplicitlyDisabledForItem(note.uuid)) {
return defaultEditor
@@ -628,9 +628,9 @@ export class SNComponentManager
}
}
getDefaultEditor(): SNComponent | undefined {
legacyGetDefaultEditor(): SNComponent | undefined {
const editors = this.componentsForArea(ComponentArea.Editor)
return editors.filter((e) => e.isDefaultEditor())[0]
return editors.filter((e) => e.legacyIsDefaultEditor())[0]
}
permissionsStringForPermissions(permissions: ComponentPermission[], component: SNComponent): string {

View File

@@ -173,24 +173,19 @@ export class SNFeaturesService
public enableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to enable a feature user does not have access to.')
}
this.enabledExperimentalFeatures.push(identifier)
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
void this.mapRemoteNativeFeaturesToItems([feature])
if (feature) {
void this.mapRemoteNativeFeaturesToItems([feature])
}
void this.notifyEvent(FeaturesEvent.FeaturesUpdated)
}
public disableExperimentalFeature(identifier: FeaturesImports.FeatureIdentifier): void {
const feature = this.getUserFeature(identifier)
if (!feature) {
throw Error('Attempting to disable a feature user does not have access to.')
}
removeFromArray(this.enabledExperimentalFeatures, identifier)
void this.storageService.setValue(StorageKey.ExperimentalFeatures, this.enabledExperimentalFeatures)
@@ -486,6 +481,16 @@ export class SNFeaturesService
return FeatureStatus.Entitled
}
if (this.isExperimentalFeature(featureId)) {
const nativeFeature = FeaturesImports.FindNativeFeature(featureId)
if (nativeFeature) {
const hasRole = this.roles.some((role) => nativeFeature.availableInRoles?.includes(role))
if (hasRole) {
return FeatureStatus.Entitled
}
}
}
const isDeprecated = this.isFeatureDeprecated(featureId)
if (isDeprecated) {
if (this.hasPaidOnlineOrOfflineSubscription()) {