feat(labs): super editor (#2001)

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

View File

@@ -0,0 +1,5 @@
export const EditorSaveTimeoutDebounce = {
Desktop: 350,
ImmediateChange: 100,
NativeMobileWeb: 700,
}

View File

@@ -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
}
})
}
}

View File

@@ -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)
}
}
}

View File

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

View File

@@ -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())
})
})

View File

@@ -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?.()
})
}
}

View File

@@ -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'

View File

@@ -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

View File

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

View File

@@ -1,6 +1,5 @@
import { NoteViewController } from '@standardnotes/snjs'
import { WebApplication } from '@/Application/Application'
import { NoteViewController } from './Controller/NoteViewController'
export interface NoteViewProps {
application: WebApplication

View File

@@ -0,0 +1,253 @@
import { WebApplication } from '@/Application/Application'
import { usePrevious } from '@/Components/ContentListView/Calendar/usePrevious'
import { ElementIds } from '@/Constants/ElementIDs'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { log, LoggingDomain } from '@/Logging'
import { Disposer } from '@/Types/Disposer'
import { EditorEventSource } from '@/Types/EditorEventSource'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import {
ApplicationEvent,
EditorFontSize,
EditorLineHeight,
isPayloadSourceRetrieved,
PrefKey,
WebAppEvent,
} from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { ChangeEventHandler, forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { NoteViewController } from '../Controller/NoteViewController'
type Props = {
application: WebApplication
spellcheck: boolean
controller: NoteViewController
locked: boolean
onFocus: () => void
onBlur: () => void
}
export type PlainEditorInterface = {
focus: () => void
}
export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
({ application, spellcheck, controller, locked, onFocus, onBlur }: Props, ref) => {
const [editorText, setEditorText] = useState<string | undefined>()
const [textareaUnloading, setTextareaUnloading] = useState(false)
const [lineHeight, setLineHeight] = useState<EditorLineHeight | undefined>()
const [fontSize, setFontSize] = useState<EditorFontSize | undefined>()
const previousSpellcheck = usePrevious(spellcheck)
const lastEditorFocusEventSource = useRef<EditorEventSource | undefined>()
const needsAdjustMobileCursor = useRef(false)
const isAdjustingMobileCursor = useRef(false)
const note = useRef(controller.item)
const tabObserverDisposer = useRef<Disposer>()
useImperativeHandle(ref, () => ({
focus() {
focusEditor()
},
}))
useEffect(() => {
const disposer = controller.addNoteInnerValueChangeObserver((updatedNote, source) => {
if (updatedNote.uuid !== note.current.uuid) {
throw Error('Editor received changes for non-current note')
}
if (
isPayloadSourceRetrieved(source) ||
editorText == undefined ||
updatedNote.editorIdentifier !== note.current.editorIdentifier ||
updatedNote.noteType !== note.current.noteType
) {
setEditorText(updatedNote.text)
}
note.current = updatedNote
})
return disposer
}, [controller, editorText, controller.item.uuid, controller.item.editorIdentifier, controller.item.noteType])
const onTextAreaChange: ChangeEventHandler<HTMLTextAreaElement> = ({ currentTarget }) => {
const text = currentTarget.value
setEditorText(text)
void controller.saveAndAwaitLocalPropagation({ text: text, isUserModified: true })
}
const onContentFocus = useCallback(() => {
if (!isAdjustingMobileCursor.current) {
needsAdjustMobileCursor.current = true
}
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onFocus()
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
const onContentBlur = useCallback(() => {
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined
onBlur()
}, [application, lastEditorFocusEventSource, onBlur])
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
if (needsAdjustMobileCursor.current) {
needsAdjustMobileCursor.current = false
isAdjustingMobileCursor.current = true
document.getElementById('note-text-editor')?.blur()
document.getElementById('note-text-editor')?.focus()
isAdjustingMobileCursor.current = false
}
}, [needsAdjustMobileCursor])
useEffect(() => {
const disposer = application.addWebEventObserver((event) => {
if (event === WebAppEvent.MobileKeyboardWillChangeFrame) {
scrollMobileCursorIntoViewAfterWebviewResize()
}
})
return disposer
}, [application, scrollMobileCursorIntoViewAfterWebviewResize])
const focusEditor = useCallback(() => {
const element = document.getElementById(ElementIds.NoteTextEditor)
if (element) {
lastEditorFocusEventSource.current = EditorEventSource.Script
element.focus()
}
}, [])
useEffect(() => {
if (controller.isTemplateNote && controller.templateNoteOptions?.autofocusBehavior === 'editor') {
setTimeout(() => {
focusEditor()
})
}
}, [controller, focusEditor])
const reloadPreferences = useCallback(() => {
const lineHeight = application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
setLineHeight(lineHeight)
setFontSize(fontSize)
}, [application])
useEffect(() => {
reloadPreferences()
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
reloadPreferences()
})
}, [reloadPreferences, application])
useEffect(() => {
if (spellcheck !== previousSpellcheck) {
setTextareaUnloading(true)
setTimeout(() => {
setTextareaUnloading(false)
}, 0)
}
}, [spellcheck, previousSpellcheck])
const onRef = (ref: HTMLTextAreaElement | null) => {
if (tabObserverDisposer.current || !ref) {
return
}
log(LoggingDomain.NoteView, 'On system editor ref')
/**
* Insert 4 spaces when a tab key is pressed, only used when inside of the text editor.
* If the shift key is pressed first, this event is not fired.
*/
const editor = document.getElementById(ElementIds.NoteTextEditor) as HTMLInputElement
if (!editor) {
console.error('Editor is not yet mounted; unable to add tab observer.')
return
}
tabObserverDisposer.current = application.io.addKeyObserver({
element: editor,
key: KeyboardKey.Tab,
onKeyDown: (event) => {
if (document.hidden || note.current.locked || event.shiftKey) {
return
}
event.preventDefault()
/** Using document.execCommand gives us undo support */
const insertSuccessful = document.execCommand('insertText', false, '\t')
if (!insertSuccessful) {
/** document.execCommand works great on Chrome/Safari but not Firefox */
const start = editor.selectionStart || 0
const end = editor.selectionEnd || 0
const spaces = ' '
/** Insert 4 spaces */
editor.value = editor.value.substring(0, start) + spaces + editor.value.substring(end)
/** Place cursor 4 spaces away from where the tab key was pressed */
editor.selectionStart = editor.selectionEnd = start + 4
}
setEditorText(editor.value)
void controller.saveAndAwaitLocalPropagation({
text: editor.value,
bypassDebouncer: true,
isUserModified: true,
})
},
})
const observer = new MutationObserver((records) => {
for (const record of records) {
record.removedNodes.forEach((node) => {
if (node === editor) {
tabObserverDisposer.current?.()
tabObserverDisposer.current = undefined
}
})
}
})
observer.observe(editor.parentElement as HTMLElement, { childList: true })
}
if (textareaUnloading) {
return null
}
return (
<textarea
autoComplete="off"
dir="auto"
id={ElementIds.NoteTextEditor}
onChange={onTextAreaChange}
onFocus={onContentFocus}
onBlur={onContentBlur}
readOnly={locked}
ref={(ref) => ref && onRef(ref)}
spellCheck={spellcheck}
value={editorText}
className={classNames(
'editable font-editor flex-grow',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize && getPlaintextFontSize(fontSize),
)}
></textarea>
)
},
)

View File

@@ -0,0 +1,31 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { COMMAND_PRIORITY_EDITOR, KEY_MODIFIER_COMMAND, $getSelection } from 'lexical'
import { useEffect } from 'react'
import { TOGGLE_LINK_COMMAND } from '@lexical/link'
import { mergeRegister } from '@lexical/utils'
export default function AutoLinkPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand(
KEY_MODIFIER_COMMAND,
(event: KeyboardEvent) => {
const isCmdK = event.key === 'k' && !event.altKey && (event.metaKey || event.ctrlKey)
if (isCmdK) {
const selection = $getSelection()
if (selection) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, selection.getTextContent())
}
}
return false
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
}

View File

@@ -0,0 +1,36 @@
import Icon from '@/Components/Icon/Icon'
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
import { BlockPickerOption } from './BlockPickerOption'
export function BlockPickerMenuItem({
index,
isSelected,
onClick,
onMouseEnter,
option,
}: {
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: BlockPickerOption
}) {
return (
<li
key={option.key}
tabIndex={-1}
className={`border-bottom gap-3 border-[0.5px] border-border ${PopoverItemClassNames} ${
isSelected ? PopoverItemSelectedClassNames : ''
}`}
ref={option.setRefElement}
role="option"
aria-selected={isSelected}
id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
<Icon type={option.iconName} className="mt-1.5 h-5 w-5" />
<div className="text-editor">{option.title}</div>
</li>
)
}

View File

@@ -0,0 +1,28 @@
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { IconType } from '@standardnotes/snjs'
export class BlockPickerOption extends TypeaheadOption {
title: string
iconName: IconType | LexicalIconName
keywords: Array<string>
keyboardShortcut?: string
onSelect: (queryString: string) => void
constructor(
title: string,
options: {
iconName: IconType | LexicalIconName
keywords?: Array<string>
keyboardShortcut?: string
onSelect: (queryString: string) => void
},
) {
super(title)
this.title = title
this.keywords = options.keywords || []
this.iconName = options.iconName
this.keyboardShortcut = options.keyboardShortcut
this.onSelect = options.onSelect.bind(this)
}
}

View File

@@ -0,0 +1,144 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { TextNode } from 'lexical'
import { useCallback, useMemo, useState } from 'react'
import useModal from '@standardnotes/blocks-editor/src/Lexical/Hooks/useModal'
import { InsertTableDialog } from '@standardnotes/blocks-editor/src/Lexical/Plugins/TablePlugin'
import { BlockPickerOption } from './BlockPickerOption'
import { BlockPickerMenuItem } from './BlockPickerMenuItem'
import { GetNumberedListBlock } from './Blocks/NumberedList'
import { GetBulletedListBlock } from './Blocks/BulletedList'
import { GetChecklistBlock } from './Blocks/Checklist'
import { GetDividerBlock } from './Blocks/Divider'
import { GetCollapsibleBlock } from './Blocks/Collapsible'
import { GetParagraphBlock } from './Blocks/Paragraph'
import { GetHeadingsBlocks } from './Blocks/Headings'
import { GetQuoteBlock } from './Blocks/Quote'
import { GetAlignmentBlocks } from './Blocks/Alignment'
import { GetCodeBlock } from './Blocks/Code'
import { GetEmbedsBlocks } from './Blocks/Embeds'
import { GetDynamicTableBlocks, GetTableBlock } from './Blocks/Table'
import Popover from '@/Components/Popover/Popover'
import { PopoverClassNames } from '../ClassNames'
import { GetDatetimeBlocks } from './Blocks/DateTime'
export default function BlockPickerMenuPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext()
const [modal, showModal] = useModal()
const [queryString, setQueryString] = useState<string | null>(null)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const [popoverOpen, setPopoverOpen] = useState(true)
const options = useMemo(() => {
const baseOptions = [
GetParagraphBlock(editor),
...GetHeadingsBlocks(editor),
GetTableBlock(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetNumberedListBlock(editor),
GetBulletedListBlock(editor),
GetChecklistBlock(editor),
GetQuoteBlock(editor),
GetCodeBlock(editor),
GetDividerBlock(editor),
...GetDatetimeBlocks(editor),
...GetAlignmentBlocks(editor),
GetCollapsibleBlock(editor),
...GetEmbedsBlocks(editor),
]
const dynamicOptions = GetDynamicTableBlocks(editor, queryString || '')
return queryString
? [
...dynamicOptions,
...baseOptions.filter((option) => {
return new RegExp(queryString, 'gi').exec(option.title) || option.keywords != null
? option.keywords.some((keyword) => new RegExp(queryString, 'gi').exec(keyword))
: false
}),
]
: baseOptions
}, [editor, queryString, showModal])
const onSelectOption = useCallback(
(
selectedOption: BlockPickerOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => {
editor.update(() => {
if (nodeToRemove) {
nodeToRemove.remove()
}
selectedOption.onSelect(matchingString)
setPopoverOpen(false)
closeMenu()
})
},
[editor],
)
return (
<>
{modal}
<LexicalTypeaheadMenuPlugin<BlockPickerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
onClose={() => {
setPopoverOpen(false)
}}
onOpen={() => {
setPopoverOpen(true)
}}
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
if (!anchorElementRef.current || !options.length) {
return null
}
return (
<Popover
align="start"
anchorPoint={{
x: anchorElementRef.current.offsetLeft,
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
}}
open={popoverOpen}
togglePopover={() => {
setPopoverOpen((prevValue) => !prevValue)
}}
>
<div className={PopoverClassNames}>
<ul>
{options.map((option, i: number) => (
<BlockPickerMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>
</Popover>
)
}}
/>
</>
)
}

View File

@@ -0,0 +1,14 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { FORMAT_ELEMENT_COMMAND, LexicalEditor, ElementFormatType } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetAlignmentBlocks(editor: LexicalEditor) {
return ['left', 'center', 'right', 'justify'].map(
(alignment) =>
new BlockPickerOption(`Align ${alignment}`, {
iconName: `align-${alignment}` as LexicalIconName,
keywords: ['align', 'justify', alignment],
onSelect: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment as ElementFormatType),
}),
)
}

View File

@@ -0,0 +1,11 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_UNORDERED_LIST_COMMAND } from '@lexical/list'
export function GetBulletedListBlock(editor: LexicalEditor) {
return new BlockPickerOption('Bulleted List', {
iconName: 'list-ul',
keywords: ['bulleted list', 'unordered list', 'ul'],
onSelect: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
})
}

View File

@@ -0,0 +1,11 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_CHECK_LIST_COMMAND } from '@lexical/list'
export function GetChecklistBlock(editor: LexicalEditor) {
return new BlockPickerOption('Check List', {
iconName: 'check',
keywords: ['check list', 'todo list'],
onSelect: () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
})
}

View File

@@ -0,0 +1,25 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { $wrapNodes } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createCodeNode } from '@lexical/code'
export function GetCodeBlock(editor: LexicalEditor) {
return new BlockPickerOption('Code', {
iconName: 'lexical-code',
keywords: ['javascript', 'python', 'js', 'codeblock'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
if (selection.isCollapsed()) {
$wrapNodes(selection, () => $createCodeNode())
} else {
const textContent = selection.getTextContent()
const codeNode = $createCodeNode()
selection.insertNodes([codeNode])
selection.insertRawText(textContent)
}
}
}),
})
}

View File

@@ -0,0 +1,11 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_COLLAPSIBLE_COMMAND } from '@standardnotes/blocks-editor/src/Lexical/Plugins/CollapsiblePlugin'
export function GetCollapsibleBlock(editor: LexicalEditor) {
return new BlockPickerOption('Collapsible', {
iconName: 'caret-right-fill',
keywords: ['collapse', 'collapsible', 'toggle'],
onSelect: () => editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
})
}

View File

@@ -0,0 +1,23 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_DATETIME_COMMAND, INSERT_DATE_COMMAND, INSERT_TIME_COMMAND } from '../../Commands'
export function GetDatetimeBlocks(editor: LexicalEditor) {
return [
new BlockPickerOption('Current date and time', {
iconName: 'authenticator',
keywords: ['date'],
onSelect: () => editor.dispatchCommand(INSERT_DATETIME_COMMAND, 'datetime'),
}),
new BlockPickerOption('Current time', {
iconName: 'authenticator',
keywords: ['time'],
onSelect: () => editor.dispatchCommand(INSERT_TIME_COMMAND, 'datetime'),
}),
new BlockPickerOption('Current date', {
iconName: 'authenticator',
keywords: ['date'],
onSelect: () => editor.dispatchCommand(INSERT_DATE_COMMAND, 'datetime'),
}),
]
}

View File

@@ -0,0 +1,11 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
export function GetDividerBlock(editor: LexicalEditor) {
return new BlockPickerOption('Divider', {
iconName: 'horizontal-rule',
keywords: ['horizontal rule', 'divider', 'hr'],
onSelect: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
})
}

View File

@@ -0,0 +1,16 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin'
import { EmbedConfigs } from '@standardnotes/blocks-editor/src/Lexical/Plugins/AutoEmbedPlugin'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetEmbedsBlocks(editor: LexicalEditor) {
return EmbedConfigs.map(
(embedConfig) =>
new BlockPickerOption(`Embed ${embedConfig.contentName}`, {
iconName: embedConfig.iconName as LexicalIconName,
keywords: [...embedConfig.keywords, 'embed'],
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
}),
)
}

View File

@@ -0,0 +1,22 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { $wrapNodes } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createHeadingNode, HeadingTagType } from '@lexical/rich-text'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetHeadingsBlocks(editor: LexicalEditor) {
return Array.from({ length: 3 }, (_, i) => i + 1).map(
(n) =>
new BlockPickerOption(`Heading ${n}`, {
iconName: `h${n}` as LexicalIconName,
keywords: ['heading', 'header', `h${n}`],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode(`h${n}` as HeadingTagType))
}
}),
}),
)
}

View File

@@ -0,0 +1,11 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_ORDERED_LIST_COMMAND } from '@lexical/list'
export function GetNumberedListBlock(editor: LexicalEditor) {
return new BlockPickerOption('Numbered List', {
iconName: 'list-ol',
keywords: ['numbered list', 'ordered list', 'ol'],
onSelect: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
})
}

View File

@@ -0,0 +1,17 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { $wrapNodes } from '@lexical/selection'
import { $createParagraphNode, $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
export function GetParagraphBlock(editor: LexicalEditor) {
return new BlockPickerOption('Paragraph', {
iconName: 'paragraph',
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createParagraphNode())
}
}),
})
}

View File

@@ -0,0 +1,18 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { $wrapNodes } from '@lexical/selection'
import { $getSelection, $isRangeSelection, LexicalEditor } from 'lexical'
import { $createQuoteNode } from '@lexical/rich-text'
export function GetQuoteBlock(editor: LexicalEditor) {
return new BlockPickerOption('Quote', {
iconName: 'quote',
keywords: ['block quote'],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createQuoteNode())
}
}),
})
}

View File

@@ -0,0 +1,53 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_TABLE_COMMAND } from '@lexical/table'
export function GetTableBlock(onSelect: () => void) {
return new BlockPickerOption('Table', {
iconName: 'table',
keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
onSelect,
})
}
export function GetDynamicTableBlocks(editor: LexicalEditor, queryString: string) {
const options: Array<BlockPickerOption> = []
if (queryString == null) {
return options
}
const fullTableRegex = new RegExp(/^([1-9]|10)x([1-9]|10)$/)
const partialTableRegex = new RegExp(/^([1-9]|10)x?$/)
const fullTableMatch = fullTableRegex.exec(queryString)
const partialTableMatch = partialTableRegex.exec(queryString)
if (fullTableMatch) {
const [rows, columns] = fullTableMatch[0].split('x').map((n: string) => parseInt(n, 10))
options.push(
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () => editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
)
} else if (partialTableMatch) {
const rows = parseInt(partialTableMatch[0], 10)
options.push(
...Array.from({ length: 5 }, (_, i) => i + 1).map(
(columns) =>
new BlockPickerOption(`${rows}x${columns} Table`, {
iconName: 'table',
keywords: ['table'],
onSelect: () =>
editor.dispatchCommand(INSERT_TABLE_COMMAND, { columns: String(columns), rows: String(rows) }),
}),
),
)
}
return options
}

View File

@@ -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
}

View File

@@ -0,0 +1,13 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
export const PopoverClassNames = classNames(
'z-dropdown-menu w-full min-w-80',
'cursor-auto flex-col overflow-y-auto rounded bg-default md:h-auto md:max-w-xs h-auto overflow-y-scroll',
)
export const PopoverItemClassNames = classNames(
'flex w-full items-center text-base overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground',
'focus:bg-info-backdrop cursor-pointer m-0 focus:bg-contrast focus:text-foreground',
)
export const PopoverItemSelectedClassNames = classNames('bg-contrast text-foreground')

View File

@@ -0,0 +1,7 @@
import { createCommand, LexicalCommand } from 'lexical'
export const INSERT_FILE_COMMAND: LexicalCommand<string> = createCommand('INSERT_FILE_COMMAND')
export const INSERT_BUBBLE_COMMAND: LexicalCommand<string> = createCommand('INSERT_BUBBLE_COMMAND')
export const INSERT_TIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_TIME_COMMAND')
export const INSERT_DATE_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATE_COMMAND')
export const INSERT_DATETIME_COMMAND: LexicalCommand<string> = createCommand('INSERT_DATETIME_COMMAND')

View File

@@ -0,0 +1,103 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
COMMAND_PRIORITY_EDITOR,
$createTextNode,
$getSelection,
$isRangeSelection,
$createParagraphNode,
} from 'lexical'
import { useEffect } from 'react'
import { INSERT_DATETIME_COMMAND, INSERT_TIME_COMMAND, INSERT_DATE_COMMAND } from '../Commands'
import { mergeRegister } from '@lexical/utils'
import { $createHeadingNode } from '@lexical/rich-text'
import { formatDateAndTimeForNote, dateToHoursAndMinutesTimeString } from '@/Utils/DateUtils'
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'
export default function DatetimePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand<string>(
INSERT_DATETIME_COMMAND,
() => {
const now = new Date()
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const heading = $createHeadingNode('h1')
const dateString = $createTextNode(formatDateAndTimeForNote(now, false))
dateString.setFormat('italic')
heading.append(dateString)
const timeNode = $createTextNode(dateToHoursAndMinutesTimeString(now))
timeNode.toggleFormat('superscript')
timeNode.toggleFormat('italic')
heading.append(timeNode)
const newLineNode = $createParagraphNode()
selection.insertNodes([heading, newLineNode])
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand<string>(
INSERT_DATE_COMMAND,
() => {
const now = new Date()
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const heading = $createHeadingNode('h1')
const dateString = $createTextNode(formatDateAndTimeForNote(now, false))
dateString.setFormat('italic')
heading.append(dateString)
const newLineNode = $createParagraphNode()
selection.insertNodes([heading, newLineNode])
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined)
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand<string>(
INSERT_TIME_COMMAND,
() => {
const now = new Date()
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const heading = $createHeadingNode('h2')
const dateString = $createTextNode(dateToHoursAndMinutesTimeString(now))
dateString.setFormat('italic')
heading.append(dateString)
const newLineNode = $createParagraphNode()
selection.insertNodes([heading, newLineNode])
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
}

View File

@@ -0,0 +1,48 @@
import { INSERT_FILE_COMMAND } from '../Commands'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { FileNode } from './Nodes/FileNode'
import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
import { $createFileNode } from './Nodes/FileUtils'
import { $wrapNodeInElement } from '@lexical/utils'
import { useFilesController } from '@/Controllers/FilesControllerProvider'
import { FilesControllerEvent } from '@/Controllers/FilesController'
export default function FilePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const filesController = useFilesController()
useEffect(() => {
if (!editor.hasNodes([FileNode])) {
throw new Error('FilePlugin: FileNode not registered on editor')
}
return editor.registerCommand<string>(
INSERT_FILE_COMMAND,
(payload) => {
const fileNode = $createFileNode(payload)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor])
useEffect(() => {
const disposer = filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadedToNote) {
const fileUuid = data[FilesControllerEvent.FileUploadedToNote].uuid
editor.dispatchCommand(INSERT_FILE_COMMAND, fileUuid)
}
})
return disposer
}, [filesController, editor])
return null
}

View File

@@ -0,0 +1,31 @@
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { useMemo } from 'react'
import { ElementFormatType, NodeKey } from 'lexical'
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileItem } from '@standardnotes/snjs'
export type FileComponentProps = Readonly<{
className: Readonly<{
base: string
focus: string
}>
format: ElementFormatType | null
nodeKey: NodeKey
fileUuid: string
}>
export function FileComponent({ className, format, nodeKey, fileUuid }: FileComponentProps) {
const application = useApplication()
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
if (!file) {
return <div>Unable to find file {fileUuid}</div>
}
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<FilePreview file={file} application={application} />
</BlockWithAlignableContents>
)
}

View File

@@ -0,0 +1,78 @@
import { DOMConversionMap, DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { $createFileNode, convertToFileElement } from './FileUtils'
import { FileComponent } from './FileComponent'
import { SerializedFileNode } from './SerializedFileNode'
import { ItemNodeInterface } from '../../ItemNodeInterface'
export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
__id: string
static getType(): string {
return 'snfile'
}
static clone(node: FileNode): FileNode {
return new FileNode(node.__id, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedFileNode): FileNode {
const node = $createFileNode(serializedNode.fileUuid)
node.setFormat(serializedNode.format)
return node
}
exportJSON(): SerializedFileNode {
return {
...super.exportJSON(),
fileUuid: this.getId(),
version: 1,
type: 'snfile',
}
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {
div: (domNode: HTMLDivElement) => {
if (!domNode.hasAttribute('data-lexical-file-uuid')) {
return null
}
return {
conversion: convertToFileElement,
priority: 2,
}
},
}
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span')
element.setAttribute('data-lexical-file-uuid', this.__id)
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
getId(): string {
return this.__id
}
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `[File: ${this.__id}]`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {
base: embedBlockTheme.base || '',
focus: embedBlockTheme.focus || '',
}
return <FileComponent className={className} format={this.__format} nodeKey={this.getKey()} fileUuid={this.__id} />
}
}

View File

@@ -0,0 +1,20 @@
import type { DOMConversionOutput, LexicalNode } from 'lexical'
import { FileNode } from './FileNode'
export function convertToFileElement(domNode: HTMLDivElement): DOMConversionOutput | null {
const fileUuid = domNode.getAttribute('data-lexical-file-uuid')
if (fileUuid) {
const node = $createFileNode(fileUuid)
return { node }
}
return null
}
export function $createFileNode(fileUuid: string): FileNode {
return new FileNode(fileUuid)
}
export function $isFileNode(node: FileNode | LexicalNode | null | undefined): node is FileNode {
return node instanceof FileNode
}

View File

@@ -0,0 +1,11 @@
import { Spread } from 'lexical'
import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
export type SerializedFileNode = Spread<
{
fileUuid: string
version: 1
type: 'snfile'
},
SerializedDecoratorBlockNode
>

View File

@@ -0,0 +1,27 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { $convertFromMarkdownString, TRANSFORMERS } from '@lexical/markdown'
import { $generateNodesFromDOM } from '@lexical/html'
import { $createParagraphNode, $createRangeSelection } from 'lexical'
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
export default function ImportPlugin({ text, format }: { text: string; format: 'md' | 'html' }): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
if (format === 'md') {
$convertFromMarkdownString(text, [...TRANSFORMERS])
} else {
const parser = new DOMParser()
const dom = parser.parseFromString(text, 'text/html')
const nodes = $generateNodesFromDOM(editor, dom)
const selection = $createRangeSelection()
const newLineNode = $createParagraphNode()
selection.insertNodes([newLineNode, ...nodes])
}
})
}, [editor, text, format])
return null
}

View File

@@ -0,0 +1,33 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $wrapNodeInElement } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, $createParagraphNode, $insertNodes, $isRootOrShadowRoot } from 'lexical'
import { useEffect } from 'react'
import { INSERT_BUBBLE_COMMAND } from '../Commands'
import { BubbleNode } from './Nodes/BubbleNode'
import { $createBubbleNode } from './Nodes/BubbleUtils'
export default function ItemBubblePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([BubbleNode])) {
throw new Error('ItemBubblePlugin: BubbleNode not registered on editor')
}
return editor.registerCommand<string>(
INSERT_BUBBLE_COMMAND,
(payload) => {
const bubbleNode = $createBubbleNode(payload)
$insertNodes([bubbleNode])
if ($isRootOrShadowRoot(bubbleNode.getParentOrThrow())) {
$wrapNodeInElement(bubbleNode, $createParagraphNode).selectEnd()
}
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor])
return null
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useMemo } from 'react'
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
import LinkedItemBubble from '@/Components/LinkedItems/LinkedItemBubble'
import { createLinkFromItem } from '@/Utils/Items/Search/createLinkFromItem'
import { useLinkingController } from '@/Controllers/LinkingControllerProvider'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { useResponsiveAppPane } from '@/Components/ResponsivePane/ResponsivePaneProvider'
import { LexicalNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
export type BubbleComponentProps = Readonly<{
itemUuid: string
node: LexicalNode
}>
export function BubbleComponent({ itemUuid, node }: BubbleComponentProps) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const linkingController = useLinkingController()
const item = useMemo(() => application.items.findItem(itemUuid), [application, itemUuid])
const { toggleAppPane } = useResponsiveAppPane()
const activateItemAndTogglePane = useCallback(
async (item: LinkableItem) => {
const paneId = await linkingController.activateItem(item)
if (paneId) {
toggleAppPane(paneId)
}
},
[toggleAppPane, linkingController],
)
const unlinkPressed = useCallback(
async (itemToUnlink: LinkableItem) => {
linkingController.unlinkItemFromSelectedItem(itemToUnlink).catch(console.error)
editor.update(() => {
node.remove()
})
},
[linkingController, node, editor],
)
if (!item) {
return <div>Unable to find item {itemUuid}</div>
}
const link = createLinkFromItem(item, 'linked')
return (
<LinkedItemBubble
className="m-1"
link={link}
key={link.id}
activateItem={activateItemAndTogglePane}
unlinkItem={unlinkPressed}
isBidirectional={false}
inlineFlex={true}
/>
)
}

View File

@@ -0,0 +1,76 @@
import { DOMConversionMap, DOMExportOutput, ElementFormatType, LexicalEditor, NodeKey } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { $createBubbleNode, convertToBubbleElement } from './BubbleUtils'
import { BubbleComponent } from './BubbleComponent'
import { SerializedBubbleNode } from './SerializedBubbleNode'
import { ItemNodeInterface } from '../../ItemNodeInterface'
export class BubbleNode extends DecoratorBlockNode implements ItemNodeInterface {
__id: string
static getType(): string {
return 'snbubble'
}
static clone(node: BubbleNode): BubbleNode {
return new BubbleNode(node.__id, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedBubbleNode): BubbleNode {
const node = $createBubbleNode(serializedNode.itemUuid)
node.setFormat(serializedNode.format)
return node
}
exportJSON(): SerializedBubbleNode {
return {
...super.exportJSON(),
itemUuid: this.getId(),
version: 1,
type: 'snbubble',
}
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {
div: (domNode: HTMLDivElement) => {
if (!domNode.hasAttribute('data-lexical-item-uuid')) {
return null
}
return {
conversion: convertToBubbleElement,
priority: 2,
}
},
}
}
createDOM(): HTMLElement {
return document.createElement('span')
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span')
element.setAttribute('data-lexical-item-uuid', this.__id)
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
getId(): string {
return this.__id
}
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `[Item: ${this.__id}]`
}
decorate(_editor: LexicalEditor): JSX.Element {
return <BubbleComponent node={this} itemUuid={this.__id} />
}
}

View File

@@ -0,0 +1,20 @@
import type { DOMConversionOutput, LexicalNode } from 'lexical'
import { BubbleNode } from './BubbleNode'
export function convertToBubbleElement(domNode: HTMLDivElement): DOMConversionOutput | null {
const itemUuid = domNode.getAttribute('data-lexical-item-uuid')
if (itemUuid) {
const node = $createBubbleNode(itemUuid)
return { node }
}
return null
}
export function $createBubbleNode(itemUuid: string): BubbleNode {
return new BubbleNode(itemUuid)
}
export function $isBubbleNode(node: BubbleNode | LexicalNode | null | undefined): node is BubbleNode {
return node instanceof BubbleNode
}

View File

@@ -0,0 +1,11 @@
import { Spread } from 'lexical'
import { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
export type SerializedBubbleNode = Spread<
{
itemUuid: string
version: 1
type: 'snbubble'
},
SerializedDecoratorBlockNode
>

View File

@@ -0,0 +1,3 @@
export interface ItemNodeInterface {
getId(): string
}

View File

@@ -0,0 +1,17 @@
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
export class ItemOption extends TypeaheadOption {
constructor(
public item: LinkableItem | undefined,
public label: string,
public options: {
keywords?: Array<string>
keyboardShortcut?: string
onSelect: (queryString: string) => void
},
) {
super(label || '')
this.key = item?.uuid || label
}
}

View File

@@ -0,0 +1,38 @@
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
import { LinkedItemSearchResultsAddTagOption } from '@/Components/LinkedItems/LinkedItemSearchResultsAddTagOption'
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
import { ItemOption } from './ItemOption'
type Props = {
index: number
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
option: ItemOption
searchQuery: string
}
export function ItemSelectionItemComponent({ index, isSelected, onClick, onMouseEnter, option, searchQuery }: Props) {
return (
<li
key={option.key}
tabIndex={-1}
className={`gap-4 ${PopoverItemClassNames} ${isSelected ? PopoverItemSelectedClassNames : ''}`}
ref={option.setRefElement}
role="option"
aria-selected={isSelected}
id={'typeahead-item-' + index}
onMouseEnter={onMouseEnter}
onClick={onClick}
>
{option.item && <LinkedItemMeta item={option.item} searchQuery={searchQuery} />}
{!option.item && (
<LinkedItemSearchResultsAddTagOption
searchQuery={searchQuery}
onClickCallback={onClick}
isFocused={isSelected}
/>
)}
</li>
)
}

View File

@@ -0,0 +1,141 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { TextNode } from 'lexical'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { ItemSelectionItemComponent } from './ItemSelectionItemComponent'
import { ItemOption } from './ItemOption'
import { useApplication } from '@/Components/ApplicationView/ApplicationProvider'
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 { PopoverClassNames } from '../ClassNames'
type Props = {
currentNote: SNNote
}
export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const linkingController = useLinkingController()
const [queryString, setQueryString] = useState<string | null>('')
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', {
minLength: 0,
})
const [popoverOpen, setPopoverOpen] = useState(true)
const onSelectOption = useCallback(
(selectedOption: ItemOption, nodeToRemove: TextNode | null, closeMenu: () => void, matchingString: string) => {
editor.update(() => {
if (nodeToRemove) {
nodeToRemove.remove()
}
selectedOption.options.onSelect(matchingString)
setPopoverOpen(false)
closeMenu()
})
},
[editor],
)
const options = useMemo(() => {
const { linkedItems, unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(
queryString || '',
application,
currentNote,
{
returnEmptyIfQueryEmpty: false,
},
)
const items = [...linkedItems, ...unlinkedItems]
const options = items.map((item) => {
return new ItemOption(item, item.title || '', {
onSelect: (_queryString: string) => {
void linkingController.linkItems(currentNote, item)
if (item.content_type === ContentType.File) {
editor.dispatchCommand(INSERT_FILE_COMMAND, item.uuid)
} else {
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, item.uuid)
}
},
})
})
if (shouldShowCreateTag) {
options.push(
new ItemOption(undefined, '', {
onSelect: async (queryString: string) => {
const newTag = await linkingController.createAndAddNewTag(queryString || '')
editor.dispatchCommand(INSERT_BUBBLE_COMMAND, newTag.uuid)
},
}),
)
}
return options
}, [application, editor, currentNote, queryString, linkingController])
return (
<LexicalTypeaheadMenuPlugin<ItemOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
onClose={() => {
setPopoverOpen(false)
}}
onOpen={() => {
setPopoverOpen(true)
}}
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
if (!anchorElementRef.current || !options.length) {
return null
}
return (
<Popover
align="start"
anchorPoint={{
x: anchorElementRef.current.offsetLeft,
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
}}
open={popoverOpen}
togglePopover={() => {
setPopoverOpen((prevValue) => !prevValue)
}}
>
<div className={PopoverClassNames}>
<ul>
{options.map((option, i: number) => (
<ItemSelectionItemComponent
searchQuery={queryString || ''}
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>
</Popover>
)
}}
/>
)
}

View File

@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getNodeByKey, Klass, LexicalNode } from 'lexical'
import { ItemNodeInterface } from '../ItemNodeInterface'
type NodeKey = string
type ItemUuid = string
type ObserverProps = {
nodeType: Klass<LexicalNode>
onRemove: (itemUuid: string) => void
}
export function NodeObserverPlugin({ nodeType, onRemove }: ObserverProps) {
const [editor] = useLexicalComposerContext()
const map = useRef<Map<NodeKey, ItemUuid>>(new Map())
useEffect(() => {
const removeMutationListener = editor.registerMutationListener(nodeType, (mutatedNodes) => {
editor.getEditorState().read(() => {
for (const [nodeKey, mutation] of mutatedNodes) {
if (mutation === 'updated' || mutation === 'created') {
const node = $getNodeByKey(nodeKey) as unknown as ItemNodeInterface
if (node) {
const uuid = node.getId()
map.current.set(nodeKey, uuid)
}
} else if (mutation === 'destroyed') {
const uuid = map.current.get(nodeKey)
if (uuid) {
onRemove(uuid)
}
}
}
})
})
return () => {
removeMutationListener()
}
})
return null
}

View File

@@ -0,0 +1,127 @@
import { WebApplication } from '@/Application/Application'
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'
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 { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
import ItemBubblePlugin from './Plugins/ItemBubblePlugin/ItemBubblePlugin'
import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlugin'
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
controller: NoteViewController
linkingController: LinkingController
filesController: FilesController
spellcheck: boolean
}
export const SuperEditor: FunctionComponent<Props> = ({
application,
linkingController,
filesController,
spellcheck,
controller,
}) => {
const note = useRef(controller.item)
const changeEditorFunction = useRef<ChangeEditorFunction>()
const ignoreNextChange = useRef(false)
const handleChange = useCallback(
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],
)
const handleBubbleRemove = useCallback(
(itemUuid: string) => {
const item = application.items.findItem(itemUuid)
if (item) {
linkingController.unlinkItemFromSelectedItem(item).catch(console.error)
}
},
[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.current.locked}
initialValue={note.current.text}
nodes={[FileNode, BubbleNode]}
>
<BlocksEditor
onChange={handleChange}
ignoreFirstChange={true}
className="relative relative h-full resize-none text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={spellcheck}
>
<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>
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
</ErrorBoundary>
</div>
)
}

View File

@@ -0,0 +1,126 @@
import { WebApplication } from '@/Application/Application'
import { NoteType, SNNote } from '@standardnotes/snjs'
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'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
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(' ')
}
const NotePreviewCharLimit = 160
type Props = {
application: WebApplication
note: SNNote
closeDialog: () => void
onConvertComplete: () => void
}
export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application, closeDialog, onConvertComplete }) => {
const [lastValue, setLastValue] = useState({ text: '', previewPlain: '' })
const format =
!note.noteType || [NoteType.Plain, NoteType.Markdown, NoteType.Code, NoteType.Task].includes(note.noteType)
? 'md'
: 'html'
const handleChange = useCallback((value: string, preview: string) => {
setLastValue({ text: value, previewPlain: preview })
}, [])
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, performConvert, onConvertComplete, lastValue])
useEffect(() => {
if (note.text.length === 0) {
void confirmConvert()
}
}, [note, confirmConvert])
const convertAsIs = useCallback(async () => {
const confirmed = await application.alertService.confirm(
spaceSeparatedStrings(
"This option is useful if you switched this note's type from Super to another plaintext-based format, and want to return to Super.",
'To use this option, the preview in the convert window should display a language format known as JSON.',
'If this is not the case, cancel this prompt.',
),
'Are you sure?',
)
if (!confirmed) {
return
}
await performConvert(note.text, note.preview_plain)
closeDialog()
onConvertComplete()
}, [closeDialog, application, note, onConvertComplete, performConvert])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeDialog}>
Convert to Super note
<p className="text-sm font-normal text-neutral">
The following is a preview of how your note will look when converted to Super. Super notes use a custom format
under the hood. Converting your note will transition it from plaintext to the custom Super format.
</p>
</ModalDialogLabel>
<ModalDialogDescription>
<div className="relative w-full">
<ErrorBoundary>
<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}
>
<ImportPlugin text={note.text} format={format} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<div className="flex w-full justify-between">
<div>
<Button onClick={convertAsIs}>Convert As-Is</Button>
</div>
<div className="flex">
<Button onClick={closeDialog}>Cancel</Button>
<div className="min-w-3" />
<Button primary onClick={confirmConvert}>
Convert to Super
</Button>
</div>
</div>
</ModalDialogButtons>
</ModalDialog>
)
}