diff --git a/common.eslintrc.js b/common.eslintrc.js index e4f588ebb..19799cb64 100644 --- a/common.eslintrc.js +++ b/common.eslintrc.js @@ -38,7 +38,7 @@ module.exports = { 'no-constructor-return': 'error', 'no-duplicate-imports': 'error', 'no-self-compare': 'error', - 'no-console': ['error', { allow: ['warn', 'error'] }], + 'no-console': ['warn', { allow: ['warn', 'error'] }], 'no-unmodified-loop-condition': 'error', 'no-unused-private-class-members': 'error', 'object-curly-spacing': ['error', 'always'], diff --git a/packages/features/src/Domain/Component/NoteType.ts b/packages/features/src/Domain/Component/NoteType.ts index 5fa80e761..af22ca9ef 100644 --- a/packages/features/src/Domain/Component/NoteType.ts +++ b/packages/features/src/Domain/Component/NoteType.ts @@ -7,4 +7,5 @@ export enum NoteType { Task = 'task', Plain = 'plain-text', Blocks = 'blocks', + Unknown = 'unknown', } diff --git a/packages/models/src/Domain/Syncable/Note/Note.spec.ts b/packages/models/src/Domain/Syncable/Note/Note.spec.ts index 98f5e2946..5365ca043 100644 --- a/packages/models/src/Domain/Syncable/Note/Note.spec.ts +++ b/packages/models/src/Domain/Syncable/Note/Note.spec.ts @@ -1,3 +1,4 @@ +import { NoteType } from '@standardnotes/features' import { createNote } from './../../Utilities/Test/SpecUtils' describe('SNNote Tests', () => { @@ -34,4 +35,61 @@ describe('SNNote Tests', () => { expect(note.noteType).toBe(undefined) }) + + it('should getBlock', () => { + const note = createNote({ + text: 'some text', + blocksItem: { + blocks: [ + { + id: '123', + type: NoteType.Authentication, + editorIdentifier: '456', + content: 'foo', + }, + ], + }, + }) + + expect(note.getBlock('123')).toStrictEqual({ + id: '123', + type: NoteType.Authentication, + editorIdentifier: '456', + content: 'foo', + }) + }) + + it('should getBlock with no blocks', () => { + const note = createNote({ + text: 'some text', + }) + + expect(note.getBlock('123')).toBe(undefined) + }) + + it('should getBlock with no blocksItem', () => { + const note = createNote({ + text: 'some text', + }) + + expect(note.getBlock('123')).toBe(undefined) + }) + + it('should get indexOfBlock', () => { + const note = createNote({ + text: 'some text', + blocksItem: { + blocks: [ + { + id: '123', + type: NoteType.Authentication, + editorIdentifier: '456', + content: 'foo', + }, + ], + }, + }) + + expect(note.indexOfBlock({ id: '123' })).toBe(0) + }) }) diff --git a/packages/models/src/Domain/Syncable/Note/Note.ts b/packages/models/src/Domain/Syncable/Note/Note.ts index f624e81d4..bd48d279c 100644 --- a/packages/models/src/Domain/Syncable/Note/Note.ts +++ b/packages/models/src/Domain/Syncable/Note/Note.ts @@ -48,4 +48,17 @@ export class SNNote extends DecryptedItem implements NoteContentSpe getBlock(id: string): NoteBlock | undefined { return this.blocksItem?.blocks.find((block) => block.id === id) } + + indexOfBlock(block: { id: string }): number | undefined { + if (!this.blocksItem) { + return undefined + } + + const index = this.blocksItem.blocks.findIndex((b) => b.id === block.id) + if (index === -1) { + return undefined + } + + return index + } } diff --git a/packages/models/src/Domain/Syncable/Note/NoteBlocks.ts b/packages/models/src/Domain/Syncable/Note/NoteBlocks.ts index b8a4e5861..4f0fcdd01 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteBlocks.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteBlocks.ts @@ -4,41 +4,10 @@ export type NoteBlock = { id: string type: NoteType editorIdentifier: string - size?: { width: number; height: number } content: string + size?: { width: number; height: number } } export interface NoteBlocks { blocks: NoteBlock[] } - -export function bracketSyntaxForBlock(block: { id: NoteBlock['id'] }): { open: string; close: string } { - return { - open: ``, - close: ``, - } -} - -export function stringIndexOfBlock( - text: string, - block: { id: NoteBlock['id'] }, -): { begin: number; end: number } | undefined { - const brackets = bracketSyntaxForBlock(block) - - const startIndex = text.indexOf(brackets.open) - if (startIndex === -1) { - return undefined - } - - const endIndex = text.indexOf(brackets.close) + brackets.close.length - - return { - begin: startIndex, - end: endIndex, - } -} - -export function blockContentToNoteTextRendition(block: { id: NoteBlock['id'] }, content: string): string { - const brackets = bracketSyntaxForBlock(block) - return `${brackets.open}${content}${brackets.close}` -} diff --git a/packages/models/src/Domain/Syncable/Note/NoteMutator.spec.ts b/packages/models/src/Domain/Syncable/Note/NoteMutator.spec.ts index 310bb8f3a..52b7bee4f 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteMutator.spec.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteMutator.spec.ts @@ -21,4 +21,99 @@ describe('note mutator', () => { expect(result.content.editorIdentifier).toEqual(FeatureIdentifier.MarkdownProEditor) }) + + it('should addBlock to new note', () => { + const note = createNote({}) + const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) + mutator.addBlock({ type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }) + const result = mutator.getResult() + + expect(result.content.blocksItem).toEqual({ + blocks: [{ type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }], + }) + }) + + it('should addBlock to existing note', () => { + const note = createNote({ + blocksItem: { + blocks: [{ type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }], + }, + }) + const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) + mutator.addBlock({ type: NoteType.RichText, id: '456', editorIdentifier: 'richy', content: 'test' }) + const result = mutator.getResult() + + expect(result.content.blocksItem).toEqual({ + blocks: [ + { type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }, + { type: NoteType.RichText, id: '456', editorIdentifier: 'richy', content: 'test' }, + ], + }) + }) + + it('should removeBlock', () => { + const note = createNote({ + blocksItem: { + blocks: [ + { type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }, + { type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'test' }, + ], + }, + }) + const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) + mutator.removeBlock({ type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }) + const result = mutator.getResult() + + expect(result.content.blocksItem).toEqual({ + blocks: [{ type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'test' }], + }) + }) + + it('should changeBlockContent', () => { + const note = createNote({ + blocksItem: { + blocks: [ + { type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'old content 1' }, + { type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'old content 2' }, + ], + }, + }) + const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) + mutator.changeBlockContent('123', 'new content') + const result = mutator.getResult() + + expect(result.content.blocksItem).toEqual({ + blocks: [ + { type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'new content' }, + { type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'old content 2' }, + ], + }) + }) + + it('should changeBlockSize', () => { + const note = createNote({ + blocksItem: { + blocks: [ + { type: NoteType.Code, id: '123', editorIdentifier: 'markdown', content: 'test' }, + { type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'test' }, + ], + }, + }) + const mutator = new NoteMutator(note, MutationType.NoUpdateUserTimestamps) + mutator.changeBlockSize('123', { width: 10, height: 20 }) + const result = mutator.getResult() + + expect(result.content.blocksItem).toEqual({ + blocks: [ + { + type: NoteType.Code, + id: '123', + editorIdentifier: 'markdown', + content: 'test', + size: { width: 10, height: 20 }, + }, + { type: NoteType.Code, id: '456', editorIdentifier: 'markdown', content: 'test' }, + ], + }) + }) }) diff --git a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts index 21b67aabd..f181ba302 100644 --- a/packages/models/src/Domain/Syncable/Note/NoteMutator.ts +++ b/packages/models/src/Domain/Syncable/Note/NoteMutator.ts @@ -5,8 +5,8 @@ import { NoteToNoteReference } from '../../Abstract/Reference/NoteToNoteReferenc import { ContentType } from '@standardnotes/common' import { ContentReferenceType } from '../../Abstract/Item' import { FeatureIdentifier, NoteType } from '@standardnotes/features' -import { blockContentToNoteTextRendition, bracketSyntaxForBlock, NoteBlock, stringIndexOfBlock } from './NoteBlocks' -import { removeFromArray } from '@standardnotes/utils' +import { NoteBlock } from './NoteBlocks' +import { filterFromArray } from '@standardnotes/utils' export class NoteMutator extends DecryptedItemMutator { set title(title: string) { @@ -51,10 +51,6 @@ export class NoteMutator extends DecryptedItemMutator { } this.mutableContent.blocksItem.blocks.push(block) - - const brackets = bracketSyntaxForBlock(block) - - this.text += `${brackets.open}${block.content}${brackets.close}` } removeBlock(block: NoteBlock): void { @@ -62,37 +58,24 @@ export class NoteMutator extends DecryptedItemMutator { return } - removeFromArray(this.mutableContent.blocksItem.blocks, block) - - const location = stringIndexOfBlock(this.mutableContent.text, block) - - if (location) { - this.mutableContent.text = this.mutableContent.text.slice(location.begin, location.end) - } + filterFromArray(this.mutableContent.blocksItem.blocks, { id: block.id }) } changeBlockContent(blockId: string, content: string): void { - const block = this.mutableContent.blocksItem?.blocks.find((b) => b.id === blockId) + const blockIndex = this.mutableContent.blocksItem?.blocks.findIndex((b) => { + return b.id === blockId + }) + + if (blockIndex == null || blockIndex === -1) { + return + } + + const block = this.mutableContent.blocksItem?.blocks[blockIndex] if (!block) { return } block.content = content - - const location = stringIndexOfBlock(this.mutableContent.text, block) - - if (location) { - const replaceRange = (s: string, start: number, end: number, substitute: string) => { - return s.substring(0, start) + substitute + s.substring(end) - } - - this.mutableContent.text = replaceRange( - this.mutableContent.text, - location.begin, - location.end, - blockContentToNoteTextRendition({ id: blockId }, content), - ) - } } changeBlockSize(blockId: string, size: { width: number; height: number }): void { diff --git a/packages/snjs/lib/Services/ComponentManager/BlocksComponentViewer.ts b/packages/snjs/lib/Services/ComponentManager/BlocksComponentViewer.ts index 6cc8dbd99..79287d34c 100644 --- a/packages/snjs/lib/Services/ComponentManager/BlocksComponentViewer.ts +++ b/packages/snjs/lib/Services/ComponentManager/BlocksComponentViewer.ts @@ -74,7 +74,7 @@ export class BlocksComponentViewer implements ComponentViewerInterface { public sessionKey?: string private note: SNNote - private lastBlockSent?: NoteBlock + private lastBlockContentSent?: string constructor( public readonly component: SNComponent, @@ -261,8 +261,12 @@ export class BlocksComponentViewer implements ComponentViewerInterface { sendNoteToEditor(source?: PayloadEmitSource): void { const block = this.note.getBlock(this.blockId) + if (!block) { + return + } - if (this.lastBlockSent && this.lastBlockSent.content === block?.content) { + if (this.lastBlockContentSent && this.lastBlockContentSent === block.content) { + this.log('Not sending note to editor, content has not changed') return } @@ -295,7 +299,7 @@ export class BlocksComponentViewer implements ComponentViewerInterface { : this.preferencesSerivce.getValue(PrefKey.EditorSpellcheck, true) params.content = { - text: block?.content || '', + text: block.content, spellcheck, } as NoteContent @@ -307,9 +311,11 @@ export class BlocksComponentViewer implements ComponentViewerInterface { item: params, } - this.replyToMessage(this.streamContextItemOriginalMessage as ComponentMessage, response) + const sent = this.replyToMessage(this.streamContextItemOriginalMessage as ComponentMessage, response) - this.lastBlockSent = block + if (sent) { + this.lastBlockContentSent = block.content + } } private log(message: string, ...args: unknown[]): void { @@ -318,23 +324,24 @@ export class BlocksComponentViewer implements ComponentViewerInterface { } } - private replyToMessage(originalMessage: ComponentMessage, replyData: MessageReplyData): void { + private replyToMessage(originalMessage: ComponentMessage, replyData: MessageReplyData): boolean { const reply: MessageReply = { action: ComponentAction.Reply, original: originalMessage, data: replyData, } - this.sendMessage(reply) + + return this.sendMessage(reply) } /** * @param essential If the message is non-essential, no alert will be shown * if we can no longer find the window. */ - sendMessage(message: ComponentMessage | MessageReply, essential = true): void { + sendMessage(message: ComponentMessage | MessageReply, essential = true): boolean { if (!this.window && message.action === ComponentAction.Reply) { this.log('Component has been deallocated in between message send and reply', this.component, message) - return + return false } this.log('Send message to component', this.component, 'message: ', message) @@ -347,7 +354,7 @@ export class BlocksComponentViewer implements ComponentViewerInterface { 'but an error is occurring. Please restart this extension and try again.', ) } - return + return false } if (!origin.startsWith('http') && !origin.startsWith('file')) { @@ -357,6 +364,8 @@ export class BlocksComponentViewer implements ComponentViewerInterface { /* Mobile messaging requires json */ this.window.postMessage(this.isMobile ? JSON.stringify(message) : message, origin) + + return true } public getWindow(): Window | undefined { @@ -459,6 +468,11 @@ export class BlocksComponentViewer implements ComponentViewerInterface { this.note, (mutator) => { mutator.changeBlockContent(this.blockId, text) + + if (this.note.indexOfBlock({ id: this.blockId }) === 0) { + mutator.preview_html = content.preview_html + mutator.preview_plain = content.preview_plain || '' + } }, MutationType.UpdateUserTimestamps, PayloadEmitSource.ComponentRetrieved, diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx index 4e714c577..a6b8c9434 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockEditor.tsx @@ -2,7 +2,7 @@ import { WebApplication } from '@/Application/Application' import { SNNote } from '@standardnotes/snjs' import { FunctionComponent, useCallback, useRef } from 'react' import { BlockEditorController } from './BlockEditorController' -import { AddBlockButton } from './AddButton' +import { AddBlockButton } from './BlockMenu/AddButton' import { MultiBlockRenderer } from './BlockRender/MultiBlockRenderer' import { BlockOption } from './BlockMenu/BlockOption' diff --git a/packages/web/src/javascripts/Components/BlockEditor/AddButton.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/AddButton.tsx similarity index 75% rename from packages/web/src/javascripts/Components/BlockEditor/AddButton.tsx rename to packages/web/src/javascripts/Components/BlockEditor/BlockMenu/AddButton.tsx index 6c56d854e..99c3152a2 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/AddButton.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/AddButton.tsx @@ -1,9 +1,9 @@ import { WebApplication } from '@/Application/Application' import { classNames } from '@/Utils/ConcatenateClassNames' import { FunctionComponent, useCallback, useState } from 'react' -import Icon from '../Icon/Icon' -import { BlockMenu } from './BlockMenu/BlockMenu' -import { BlockOption } from './BlockMenu/BlockOption' +import Icon from '../../Icon/Icon' +import { BlockMenu } from './BlockMenu' +import { BlockOption } from './BlockOption' type AddButtonProps = { application: WebApplication @@ -11,12 +11,20 @@ type AddButtonProps = { } export const AddBlockButton: FunctionComponent = ({ application, onSelectOption }) => { - const [showMenu, setShowMenu] = useState(true) + const [showMenu, setShowMenu] = useState(false) const toggleMenu = useCallback(() => { setShowMenu((prevValue) => !prevValue) }, []) + const handleSelection = useCallback( + (option: BlockOption) => { + onSelectOption(option) + setShowMenu(false) + }, + [onSelectOption], + ) + return (
- {showMenu && } + {showMenu && }
) } diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/BlockMenuOption.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/BlockMenuOption.tsx index 75fe7405e..4d7465c50 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/BlockMenuOption.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/BlockMenuOption.tsx @@ -10,8 +10,8 @@ export type BlockMenuOptionProps = { export const BlockMenuOption: FunctionComponent = ({ option, onSelect }) => { return (
onSelect} + className={'flex w-full cursor-pointer flex-row items-center border-[1px] border-b border-border p-4'} + onClick={() => onSelect(option)} >
{option.label}
diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/componentToBlockOption.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/componentToBlockOption.tsx index 2e472ea11..423d15e83 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/componentToBlockOption.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockMenu/componentToBlockOption.tsx @@ -10,5 +10,6 @@ export function componentToBlockOption(component: SNComponent, iconsController: label: component.name, icon: iconType, iconTint: tint, + component: component, } } diff --git a/packages/web/src/javascripts/Components/BlockEditor/BlockRender/SingleBlockRenderer.tsx b/packages/web/src/javascripts/Components/BlockEditor/BlockRender/SingleBlockRenderer.tsx index 7143b91b1..252d8716c 100644 --- a/packages/web/src/javascripts/Components/BlockEditor/BlockRender/SingleBlockRenderer.tsx +++ b/packages/web/src/javascripts/Components/BlockEditor/BlockRender/SingleBlockRenderer.tsx @@ -94,7 +94,7 @@ export const SingleBlockRenderer: FunctionComponent = )} onClick={onRemoveBlock} > - + )} diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx index 845cc90ff..26595d33a 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorMenu.tsx @@ -5,21 +5,11 @@ import { MenuItemType } from '@/Components/Menu/MenuItemType' import { usePremiumModal } from '@/Hooks/usePremiumModal' import { STRING_EDIT_LOCKED_ATTEMPT } from '@/Constants/Strings' import { WebApplication } from '@/Application/Application' -import { - ComponentArea, - ItemMutator, - NoteMutator, - NoteType, - PrefKey, - SNComponent, - SNNote, - TransactionalMutation, -} from '@standardnotes/snjs' +import { ComponentArea, NoteMutator, PrefKey, SNComponent, SNNote } from '@standardnotes/snjs' import { Fragment, FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react' import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' -import { createEditorMenuGroups } from './createEditorMenuGroups' -import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { createEditorMenuGroups } from '../../Utils/createEditorMenuGroups' import { reloadFont } from '../NoteView/FontFunctions' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' @@ -48,107 +38,96 @@ const ChangeEditorMenu: FunctionComponent = ({ [application.componentManager], ) const groups = useMemo(() => createEditorMenuGroups(application, editors), [application, editors]) - const [currentEditor, setCurrentEditor] = useState() + const [currentComponent, setCurrentComponent] = useState() useEffect(() => { if (note) { - setCurrentEditor(application.componentManager.editorForNote(note)) + setCurrentComponent(application.componentManager.editorForNote(note)) } }, [application, note]) const premiumModal = usePremiumModal() - const isSelectedEditor = useCallback( + const isSelected = useCallback( (item: EditorMenuItem) => { - if (currentEditor) { - if (item?.component?.identifier === currentEditor.identifier) { - return true - } - } else if (item.name === PLAIN_EDITOR_NAME) { - return true + if (currentComponent) { + return item.component?.identifier === currentComponent.identifier } - return false + + return item.noteType === note?.noteType }, - [currentEditor], + [currentComponent, note], ) const selectComponent = useCallback( - async (component: SNComponent | null, note: SNNote) => { - if (component) { - if (component.conflictOf) { - application.mutator - .changeAndSaveItem(component, (mutator) => { - mutator.conflictOf = undefined - }) - .catch(console.error) - } + async (component: SNComponent, note: SNNote) => { + if (component.conflictOf) { + void application.mutator.changeAndSaveItem(component, (mutator) => { + mutator.conflictOf = undefined + }) } - const transactions: TransactionalMutation[] = [] - await application.getViewControllerManager().itemListController.insertCurrentIfTemplate() - if (note.locked) { - application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error) - return - } + await application.mutator.changeAndSaveItem(note, (mutator) => { + const noteMutator = mutator as NoteMutator + noteMutator.noteType = component.noteType + noteMutator.editorIdentifier = component.identifier + }) - if (!component) { - transactions.push({ - itemUuid: note.uuid, - mutate: (m: ItemMutator) => { - const noteMutator = m as NoteMutator - noteMutator.noteType = NoteType.Plain - noteMutator.editorIdentifier = undefined - }, - }) - reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) - } else { - transactions.push({ - itemUuid: note.uuid, - mutate: (m: ItemMutator) => { - const noteMutator = m as NoteMutator - noteMutator.noteType = component.noteType - noteMutator.editorIdentifier = component.identifier - }, - }) - } - - await application.mutator.runTransactionalMutations(transactions) - application.sync.sync().catch(console.error) - setCurrentEditor(application.componentManager.editorForNote(note)) + setCurrentComponent(application.componentManager.editorForNote(note)) }, [application], ) - const selectEditor = useCallback( + const selectNonComponent = useCallback( + async (item: EditorMenuItem, note: SNNote) => { + await application.getViewControllerManager().itemListController.insertCurrentIfTemplate() + + reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled)) + + await application.mutator.changeAndSaveItem(note, (mutator) => { + const noteMutator = mutator as NoteMutator + noteMutator.noteType = item.noteType + noteMutator.editorIdentifier = undefined + }) + + setCurrentComponent(undefined) + }, + [application], + ) + + const selectItem = useCallback( async (itemToBeSelected: EditorMenuItem) => { if (!itemToBeSelected.isEntitled) { premiumModal.activate(itemToBeSelected.name) return } - const areBothEditorsPlain = !currentEditor && !itemToBeSelected.component - - if (areBothEditorsPlain) { + if (note?.locked) { + application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT).catch(console.error) return } - let shouldSelectEditor = true + let shouldMakeSelection = true if (itemToBeSelected.component) { const changeRequiresAlert = application.componentManager.doesEditorChangeRequireAlert( - currentEditor, + currentComponent, itemToBeSelected.component, ) if (changeRequiresAlert) { - shouldSelectEditor = await application.componentManager.showEditorChangeAlert() + shouldMakeSelection = await application.componentManager.showEditorChangeAlert() } } - if (shouldSelectEditor && note) { - selectComponent(itemToBeSelected.component ?? null, note).catch(console.error) + if (shouldMakeSelection && note) { + if (itemToBeSelected.component) { + selectComponent(itemToBeSelected.component, note).catch(console.error) + } else { + selectNonComponent(itemToBeSelected, note).catch(console.error) + } } closeMenu() @@ -157,7 +136,7 @@ const ChangeEditorMenu: FunctionComponent = ({ onSelect(itemToBeSelected.component) } }, - [application.componentManager, closeMenu, currentEditor, note, onSelect, premiumModal, selectComponent], + [application, closeMenu, currentComponent, note, onSelect, premiumModal, selectComponent, selectNonComponent], ) return ( @@ -172,7 +151,7 @@ const ChangeEditorMenu: FunctionComponent = ({
{group.items.map((item) => { const onClickEditorItem = () => { - selectEditor(item).catch(console.error) + selectItem(item).catch(console.error) } return ( = ({ type={MenuItemType.RadioButton} onClick={onClickEditorItem} className={'flex-row-reverse py-2'} - checked={item.isEntitled ? isSelectedEditor(item) : undefined} + checked={item.isEntitled ? isSelected(item) : undefined} >
diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index 687de2524..c02b7b304 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -8,8 +8,9 @@ import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedIte import { ElementIds } from '@/Constants/ElementIDs' import { PrefDefaults } from '@/Constants/PrefDefaults' import { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings' +import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' import { log, LoggingDomain } from '@/Logging' -import { debounce, isDesktopApplication, isDev, isMobileScreen, isTabletScreen } from '@/Utils' +import { debounce, isDesktopApplication, isMobileScreen, isTabletScreen } from '@/Utils' import { classNames } from '@/Utils/ConcatenateClassNames' import { ApplicationEvent, @@ -55,8 +56,6 @@ function sortAlphabetically(array: SNComponent[]): SNComponent[] { return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)) } -const IsBlocksEnabled = isDev - type State = { availableStackComponents: SNComponent[] editorComponentViewer?: ComponentViewerInterface @@ -1028,7 +1027,7 @@ class NoteView extends AbstractComponent { const renderHeaderOptions = isMobileScreen() ? !this.state.plaintextEditorFocused : true const editorMode = - IsBlocksEnabled && this.note.title.toLowerCase().includes('blocks') + featureTrunkEnabled(FeatureTrunkName.Blocks) && this.note.noteType === NoteType.Blocks ? 'blocks' : this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading ? 'plain' diff --git a/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx b/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx index d39a2850d..78e257c67 100644 --- a/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx +++ b/packages/web/src/javascripts/Components/NotesOptions/EditorMenuItem.tsx @@ -1,7 +1,8 @@ -import { SNComponent } from '@standardnotes/snjs' +import { NoteType, SNComponent } from '@standardnotes/snjs' export type EditorMenuItem = { name: string component?: SNComponent isEntitled: boolean + noteType: NoteType } diff --git a/packages/web/src/javascripts/Constants/Constants.ts b/packages/web/src/javascripts/Constants/Constants.ts index 2038c0070..9c1f1a27e 100644 --- a/packages/web/src/javascripts/Constants/Constants.ts +++ b/packages/web/src/javascripts/Constants/Constants.ts @@ -22,6 +22,7 @@ export const TAG_FOLDERS_FEATURE_TOOLTIP = 'A Plus or Pro plan is required to en export const SMART_TAGS_FEATURE_NAME = 'Smart Tags' export const PLAIN_EDITOR_NAME = 'Plain Text' +export const BLOCKS_EDITOR_NAME = 'Blocks' export const SYNC_TIMEOUT_DEBOUNCE = 350 export const SYNC_TIMEOUT_NO_DEBOUNCE = 100 diff --git a/packages/web/src/javascripts/FeatureTrunk.ts b/packages/web/src/javascripts/FeatureTrunk.ts new file mode 100644 index 000000000..e05a74e6e --- /dev/null +++ b/packages/web/src/javascripts/FeatureTrunk.ts @@ -0,0 +1,13 @@ +import { isDev } from '@/Utils' + +export enum FeatureTrunkName { + Blocks, +} + +export const FeatureTrunkStatus: Record = { + [FeatureTrunkName.Blocks]: isDev && true, +} + +export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean { + return FeatureTrunkStatus[trunk] +} diff --git a/packages/web/src/javascripts/Logging.ts b/packages/web/src/javascripts/Logging.ts index 941f71117..1c7c01198 100644 --- a/packages/web/src/javascripts/Logging.ts +++ b/packages/web/src/javascripts/Logging.ts @@ -21,6 +21,7 @@ const LoggingStatus: Record = { [LoggingDomain.BlockEditor]: true, } +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function log(domain: LoggingDomain, ...args: any[]): void { if (!isDev || !LoggingStatus[domain]) { return diff --git a/packages/web/src/javascripts/Utils/DropdownItemsForEditors.ts b/packages/web/src/javascripts/Utils/DropdownItemsForEditors.ts index 654c5e992..7a5a6fe6e 100644 --- a/packages/web/src/javascripts/Utils/DropdownItemsForEditors.ts +++ b/packages/web/src/javascripts/Utils/DropdownItemsForEditors.ts @@ -1,39 +1,50 @@ import { ComponentArea, FeatureIdentifier } from '@standardnotes/features' import { DropdownItem } from '@/Components/Dropdown/DropdownItem' import { WebApplication } from '@/Application/Application' -import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants' +import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' export const PlainEditorType = 'plain-editor' +export const BlocksType = 'blocks-editor' export type EditorOption = DropdownItem & { - value: FeatureIdentifier | typeof PlainEditorType + value: FeatureIdentifier | typeof PlainEditorType | typeof BlocksType } export function getDropdownItemsForAllEditors(application: WebApplication) { - const options = application.componentManager - .componentsForArea(ComponentArea.Editor) - .map((editor): EditorOption => { - const identifier = editor.package_info.identifier - const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type) + const plaintextOption: EditorOption = { + icon: 'plain-text', + iconClassName: 'text-accessory-tint-1', + label: PLAIN_EDITOR_NAME, + value: PlainEditorType, + } - return { - label: editor.displayName, - value: identifier, - ...(iconType ? { icon: iconType } : null), - ...(tint ? { iconClassName: `text-accessory-tint-${tint}` } : null), - } - }) - .concat([ - { - icon: 'plain-text', - iconClassName: 'text-accessory-tint-1', - label: PLAIN_EDITOR_NAME, - value: PlainEditorType, - }, - ]) - .sort((a, b) => { - return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1 + const options = application.componentManager.componentsForArea(ComponentArea.Editor).map((editor): EditorOption => { + const identifier = editor.package_info.identifier + const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type) + + return { + label: editor.displayName, + value: identifier, + ...(iconType ? { icon: iconType } : null), + ...(tint ? { iconClassName: `text-accessory-tint-${tint}` } : null), + } + }) + + options.push(plaintextOption) + + if (featureTrunkEnabled(FeatureTrunkName.Blocks)) { + options.push({ + icon: 'dashboard', + iconClassName: 'text-accessory-tint-1', + label: BLOCKS_EDITOR_NAME, + value: BlocksType, }) + } + + options.sort((a, b) => { + return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1 + }) return options } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts similarity index 50% rename from packages/web/src/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts rename to packages/web/src/javascripts/Utils/createEditorMenuGroups.ts index 04b23b910..c9c7ca7f1 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/createEditorMenuGroups.ts +++ b/packages/web/src/javascripts/Utils/createEditorMenuGroups.ts @@ -1,3 +1,4 @@ +import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk' import { WebApplication } from '@/Application/Application' import { ContentType, @@ -12,9 +13,9 @@ import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup' import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem' import { PLAIN_EDITOR_NAME } from '@/Constants/Constants' -type EditorGroup = NoteType | 'others' +type NoteTypeToEditorRowsMap = Record -const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => { +const getNoteTypeForFeatureDescription = (featureDescription: FeatureDescription): NoteType => { if (featureDescription.note_type) { return featureDescription.note_type } else if (featureDescription.file_type) { @@ -23,106 +24,154 @@ const getEditorGroup = (featureDescription: FeatureDescription): EditorGroup => return NoteType.RichText case 'md': return NoteType.Markdown - default: - return 'others' } } - return 'others' + return NoteType.Unknown } -export const createEditorMenuGroups = (application: WebApplication, editors: SNComponent[]) => { - const editorItems: Record = { - 'plain-text': [ - { - name: PLAIN_EDITOR_NAME, - isEntitled: true, - }, - ], - 'rich-text': [], - markdown: [], - task: [], - code: [], - spreadsheet: [], - authentication: [], - others: [], - blocks: [], - } - +const insertNonInstalledNativeComponentsInMap = ( + map: NoteTypeToEditorRowsMap, + components: SNComponent[], + application: WebApplication, +): void => { GetFeatures() .filter((feature) => feature.content_type === ContentType.Component && feature.area === ComponentArea.Editor) .forEach((editorFeature) => { - const notInstalled = !editors.find((editor) => editor.identifier === editorFeature.identifier) + const notInstalled = !components.find((editor) => editor.identifier === editorFeature.identifier) const isExperimental = application.features.isExperimentalFeature(editorFeature.identifier) const isDeprecated = editorFeature.deprecated const isShowable = notInstalled && !isExperimental && !isDeprecated + if (isShowable) { - editorItems[getEditorGroup(editorFeature)].push({ + const noteType = getNoteTypeForFeatureDescription(editorFeature) + map[noteType].push({ name: editorFeature.name as string, isEntitled: false, + noteType, }) } }) +} + +const insertInstalledComponentsInMap = ( + map: NoteTypeToEditorRowsMap, + components: SNComponent[], + application: WebApplication, +) => { + components.forEach((editor) => { + const noteType = getNoteTypeForFeatureDescription(editor.package_info) - editors.forEach((editor) => { const editorItem: EditorMenuItem = { name: editor.displayName, component: editor, isEntitled: application.features.getFeatureStatus(editor.identifier) === FeatureStatus.Entitled, + noteType, } - editorItems[getEditorGroup(editor.package_info)].push(editorItem) + map[noteType].push(editorItem) }) +} - const editorMenuGroups: EditorMenuGroup[] = [ +const createGroupsFromMap = (map: NoteTypeToEditorRowsMap): EditorMenuGroup[] => { + const groups: EditorMenuGroup[] = [ { icon: 'plain-text', iconClassName: 'text-accessory-tint-1', title: 'Plain text', - items: editorItems['plain-text'], + items: map[NoteType.Plain], }, { icon: 'rich-text', iconClassName: 'text-accessory-tint-1', title: 'Rich text', - items: editorItems['rich-text'], + items: map[NoteType.RichText], }, { icon: 'markdown', iconClassName: 'text-accessory-tint-2', title: 'Markdown text', - items: editorItems.markdown, + items: map[NoteType.Markdown], }, { icon: 'tasks', iconClassName: 'text-accessory-tint-3', title: 'Todo', - items: editorItems.task, + items: map[NoteType.Task], }, { icon: 'code', iconClassName: 'text-accessory-tint-4', title: 'Code', - items: editorItems.code, + items: map[NoteType.Code], }, { icon: 'spreadsheets', iconClassName: 'text-accessory-tint-5', title: 'Spreadsheet', - items: editorItems.spreadsheet, + items: map[NoteType.Spreadsheet], }, { icon: 'authenticator', iconClassName: 'text-accessory-tint-6', title: 'Authentication', - items: editorItems.authentication, + items: map[NoteType.Authentication], }, { icon: 'editor', iconClassName: 'text-neutral', title: 'Others', - items: editorItems.others, + items: map[NoteType.Unknown], }, ] - return editorMenuGroups + if (featureTrunkEnabled(FeatureTrunkName.Blocks)) { + groups.splice(1, 0, { + icon: 'dashboard', + iconClassName: 'text-accessory-tint-1', + title: 'Blocks', + items: map[NoteType.Blocks], + }) + } + + return groups +} + +const createBaselineMap = (): NoteTypeToEditorRowsMap => { + const map: NoteTypeToEditorRowsMap = { + [NoteType.Plain]: [ + { + name: PLAIN_EDITOR_NAME, + isEntitled: true, + noteType: NoteType.Plain, + }, + ], + [NoteType.Blocks]: [], + [NoteType.RichText]: [], + [NoteType.Markdown]: [], + [NoteType.Task]: [], + [NoteType.Code]: [], + [NoteType.Spreadsheet]: [], + [NoteType.Authentication]: [], + [NoteType.Unknown]: [], + } + + if (featureTrunkEnabled(FeatureTrunkName.Blocks)) { + map[NoteType.Blocks].push({ + name: 'Blocks', + isEntitled: true, + noteType: NoteType.Blocks, + }) + } + + return map +} + +export const createEditorMenuGroups = (application: WebApplication, components: SNComponent[]) => { + const map = createBaselineMap() + + insertNonInstalledNativeComponentsInMap(map, components, application) + + insertInstalledComponentsInMap(map, components, application) + + return createGroupsFromMap(map) }