refactor: extract components to plugin repo (#1933)
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
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'
|
||||
|
||||
type AddButtonProps = {
|
||||
application: WebApplication
|
||||
onSelectOption: (option: BlockOption) => void
|
||||
}
|
||||
|
||||
export const AddBlockButton: FunctionComponent<AddButtonProps> = ({ application, onSelectOption }) => {
|
||||
const [showMenu, setShowMenu] = useState(true)
|
||||
|
||||
const toggleMenu = useCallback(() => {
|
||||
setShowMenu((prevValue) => !prevValue)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-row flex-wrap">
|
||||
<button
|
||||
className={classNames(
|
||||
'fixed bottom-6 right-6 z-editor-title-bar ml-3 flex h-15 w-15 cursor-pointer items-center',
|
||||
`justify-center rounded-full border border-solid border-transparent ${'bg-info text-info-contrast'}`,
|
||||
'hover:brightness-125 md:static md:h-8 md:w-8',
|
||||
)}
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<Icon type="add" size="custom" className="h-8 w-8 md:h-5 md:w-5" />
|
||||
</button>
|
||||
|
||||
{showMenu && <BlockMenu application={application} onSelectOption={onSelectOption} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { MultiBlockRenderer } from './BlockRender/MultiBlockRenderer'
|
||||
import { BlockOption } from './BlockMenu/BlockOption'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
}
|
||||
export const BlockEditor: FunctionComponent<Props> = ({ note, application }) => {
|
||||
const controller = useRef(new BlockEditorController(note, application))
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(option: BlockOption) => {
|
||||
void controller.current.addNewBlock(option)
|
||||
},
|
||||
[controller],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<AddBlockButton application={application} onSelectOption={onSelectOption} />
|
||||
{note.blocksItem && (
|
||||
<MultiBlockRenderer
|
||||
controller={controller.current}
|
||||
key={note.uuid}
|
||||
blocksItem={note.blocksItem}
|
||||
note={note}
|
||||
application={application}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteBlock, NoteMutator, SNComponent, SNNote } from '@standardnotes/snjs'
|
||||
import { BlockOption } from './BlockMenu/BlockOption'
|
||||
|
||||
export class BlockEditorController {
|
||||
constructor(private note: SNNote, private application: WebApplication) {
|
||||
this.note = note
|
||||
this.application = application
|
||||
}
|
||||
|
||||
deinit() {
|
||||
;(this.note as unknown) = undefined
|
||||
;(this.application as unknown) = undefined
|
||||
}
|
||||
|
||||
createBlockItem(editor: SNComponent): NoteBlock {
|
||||
const id = this.application.generateUuid()
|
||||
const block: NoteBlock = {
|
||||
id: id,
|
||||
editorIdentifier: editor.identifier,
|
||||
type: editor.noteType,
|
||||
content: '',
|
||||
}
|
||||
|
||||
return block
|
||||
}
|
||||
|
||||
async addNewBlock(option: BlockOption): Promise<void> {
|
||||
if (!option.component) {
|
||||
throw new Error('Non-component block options are not supported yet')
|
||||
}
|
||||
|
||||
const block = this.createBlockItem(option.component)
|
||||
await this.application.mutator.changeAndSaveItem<NoteMutator>(this.note, (mutator) => {
|
||||
mutator.addBlock(block)
|
||||
})
|
||||
}
|
||||
|
||||
async removeBlock(block: NoteBlock): Promise<void> {
|
||||
await this.application.mutator.changeAndSaveItem<NoteMutator>(this.note, (mutator) => {
|
||||
mutator.removeBlock(block)
|
||||
})
|
||||
}
|
||||
|
||||
async saveBlockSize(block: NoteBlock, size: { width: number; height: number }): Promise<void> {
|
||||
if (block.size?.height === size.height) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.mutator.changeAndSaveItem<NoteMutator>(this.note, (mutator) => {
|
||||
mutator.changeBlockSize(block.id, size)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ComponentArea } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'react'
|
||||
import { BlockMenuOption } from './BlockMenuOption'
|
||||
import { BlockOption } from './BlockOption'
|
||||
import { componentToBlockOption } from './componentToBlockOption'
|
||||
|
||||
type BlockMenuProps = {
|
||||
application: WebApplication
|
||||
onSelectOption: (row: BlockOption) => void
|
||||
}
|
||||
|
||||
export const BlockMenu: FunctionComponent<BlockMenuProps> = ({ application, onSelectOption }) => {
|
||||
const components = application.componentManager.componentsForArea(ComponentArea.Editor)
|
||||
const options = components.map((component) => componentToBlockOption(component, application.iconsController))
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap">
|
||||
{options.map((option) => {
|
||||
return <BlockMenuOption option={option} key={option.identifier} onSelect={onSelectOption} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { FunctionComponent } from 'react'
|
||||
import { BlockOption } from './BlockOption'
|
||||
|
||||
export type BlockMenuOptionProps = {
|
||||
option: BlockOption
|
||||
onSelect: (option: BlockOption) => void
|
||||
}
|
||||
|
||||
export const BlockMenuOption: FunctionComponent<BlockMenuOptionProps> = ({ option, onSelect }) => {
|
||||
return (
|
||||
<div
|
||||
className={'flex w-full flex-row items-center border-[1px] border-b border-border p-4'}
|
||||
onClick={() => onSelect}
|
||||
>
|
||||
<Icon type={option.icon} size={'large'} />
|
||||
<div className={'ml-3 text-base'}>{option.label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IconType, SNComponent } from '@standardnotes/snjs'
|
||||
|
||||
export type BlockOption = {
|
||||
editorIdentifier: string
|
||||
label: string
|
||||
identifier: string
|
||||
icon: IconType | string
|
||||
iconTint: number
|
||||
component?: SNComponent
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IconsController, SNComponent } from '@standardnotes/snjs'
|
||||
import { BlockOption } from './BlockOption'
|
||||
|
||||
export function componentToBlockOption(component: SNComponent, iconsController: IconsController): BlockOption {
|
||||
const [iconType, tint] = iconsController.getIconAndTintForNoteType(component.package_info.note_type)
|
||||
|
||||
return {
|
||||
identifier: component.uuid,
|
||||
editorIdentifier: component.identifier,
|
||||
label: component.name,
|
||||
icon: iconType,
|
||||
iconTint: tint,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const MaxBlockHeight: Record<string, number> = {
|
||||
'org.standardnotes.standard-sheets': 300,
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { NoteBlocks, SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent } from 'react'
|
||||
import { BlockEditorController } from '../BlockEditorController'
|
||||
import { SingleBlockRenderer } from './SingleBlockRenderer'
|
||||
|
||||
type MultiBlockRendererProps = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
blocksItem: NoteBlocks
|
||||
controller: BlockEditorController
|
||||
}
|
||||
|
||||
export const MultiBlockRenderer: FunctionComponent<MultiBlockRendererProps> = ({
|
||||
blocksItem,
|
||||
controller,
|
||||
note,
|
||||
application,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{blocksItem.blocks.map((block) => {
|
||||
return (
|
||||
<SingleBlockRenderer
|
||||
key={block.id}
|
||||
block={block}
|
||||
note={note}
|
||||
application={application}
|
||||
controller={controller}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { ComponentAction, NoteBlock, SNNote } from '@standardnotes/snjs'
|
||||
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import ComponentView from '../../ComponentView/ComponentView'
|
||||
import { MaxBlockHeight } from './MaxBlockHeight'
|
||||
import { BlockEditorController } from '../BlockEditorController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
|
||||
type SingleBlockRendererProps = {
|
||||
application: WebApplication
|
||||
block: NoteBlock
|
||||
note: SNNote
|
||||
controller: BlockEditorController
|
||||
}
|
||||
|
||||
export const SingleBlockRenderer: FunctionComponent<SingleBlockRendererProps> = ({
|
||||
block,
|
||||
application,
|
||||
note,
|
||||
controller,
|
||||
}) => {
|
||||
const [height, setHeight] = useState<number | undefined>(block.size?.height)
|
||||
const [showCloseButton, setShowCloseButton] = useState(false)
|
||||
|
||||
const component = useMemo(
|
||||
() => application.componentManager.componentWithIdentifier(block.editorIdentifier),
|
||||
[block, application],
|
||||
)
|
||||
|
||||
const viewer = useMemo(
|
||||
() => component && application.componentManager.createBlockComponentViewer(component, note.uuid, block.id),
|
||||
[application, component, note.uuid, block.id],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const disposer = viewer?.addActionObserver((action, data) => {
|
||||
if (action === ComponentAction.SetSize) {
|
||||
if (data.height && data.height > 0) {
|
||||
const height = Math.min(Number(data.height), MaxBlockHeight[block.editorIdentifier] ?? Number(data.height))
|
||||
log(LoggingDomain.BlockEditor, `Received block height ${height}`)
|
||||
setHeight(height)
|
||||
void controller.saveBlockSize(block, { width: 0, height })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return disposer
|
||||
}, [viewer, block, controller])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewer) {
|
||||
application.componentManager.destroyComponentViewer(viewer)
|
||||
}
|
||||
}
|
||||
}, [application, viewer])
|
||||
|
||||
const onHoverEnter = useCallback(() => {
|
||||
setShowCloseButton(true)
|
||||
}, [])
|
||||
|
||||
const onHoverExit = useCallback(() => {
|
||||
setShowCloseButton(false)
|
||||
}, [])
|
||||
|
||||
const onRemoveBlock = useCallback(() => {
|
||||
void controller.removeBlock(block)
|
||||
}, [block, controller])
|
||||
|
||||
if (!component || !viewer) {
|
||||
return <div>Unable to find component {block.editorIdentifier}</div>
|
||||
}
|
||||
|
||||
const styles: Record<string, unknown> = {}
|
||||
if (height) {
|
||||
styles['height'] = height
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={onHoverEnter}
|
||||
onMouseLeave={onHoverExit}
|
||||
className="w-full border-info hover:border-[1px]"
|
||||
style={styles}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
className={classNames(
|
||||
'fixed bottom-6 right-6 z-editor-title-bar ml-3 flex h-15 w-15 cursor-pointer items-center',
|
||||
`justify-center rounded-full border border-solid border-transparent ${'bg-info text-info-contrast'}`,
|
||||
'hover:brightness-125 md:static md:h-8 md:w-8',
|
||||
)}
|
||||
onClick={onRemoveBlock}
|
||||
>
|
||||
<Icon type="remove" size="custom" className="h-8 w-8 md:h-5 md:w-5" />
|
||||
</button>
|
||||
)}
|
||||
<ComponentView key={viewer.identifier} componentViewer={viewer} application={application} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const createEditorMenuGroups = (application: WebApplication, editors: SNC
|
||||
spreadsheet: [],
|
||||
authentication: [],
|
||||
others: [],
|
||||
blocks: [],
|
||||
}
|
||||
|
||||
GetFeatures()
|
||||
|
||||
@@ -200,6 +200,7 @@ const ComponentView: FunctionComponent<IProps> = ({ application, onLoad, compone
|
||||
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
|
||||
{component.uuid && isComponentValid && (
|
||||
<iframe
|
||||
className="h-full w-full grow bg-transparent"
|
||||
ref={iframeRef}
|
||||
onLoad={onIframeLoad}
|
||||
data-component-viewer-id={componentViewer.identifier}
|
||||
|
||||
@@ -13,16 +13,10 @@ import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import Dropdown from '@/Components/Dropdown/Dropdown'
|
||||
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
|
||||
import { PreferenceMode } from './PreferenceMode'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const PlainEditorType = 'plain-editor'
|
||||
|
||||
type EditorOption = DropdownItem & {
|
||||
value: FeatureIdentifier | typeof PlainEditorType
|
||||
}
|
||||
import { getDropdownItemsForAllEditors, PlainEditorType } from '@/Utils/DropdownItemsForEditors'
|
||||
|
||||
const PrefChangeDebounceTimeInMs = 25
|
||||
|
||||
@@ -139,32 +133,7 @@ const NewNotePreferences: FunctionComponent<Props> = ({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const editors = 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),
|
||||
}
|
||||
})
|
||||
.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
|
||||
})
|
||||
|
||||
setEditorItems(editors)
|
||||
setEditorItems(getDropdownItemsForAllEditors(application))
|
||||
}, [application])
|
||||
|
||||
const setDefaultEditor = (value: string) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ElementIds } from '@/Constants/ElementIDs'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
import { StringDeleteNote, STRING_DELETE_LOCKED_ATTEMPT, STRING_DELETE_PLACEHOLDER_ATTEMPT } from '@/Constants/Strings'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { debounce, isDesktopApplication, isMobileScreen, isTabletScreen } from '@/Utils'
|
||||
import { debounce, isDesktopApplication, isDev, isMobileScreen, isTabletScreen } from '@/Utils'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { confirmDialog, KeyboardKey, KeyboardModifier } from '@standardnotes/ui-services'
|
||||
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
|
||||
import { EditorEventSource } from '../../Types/EditorEventSource'
|
||||
import { BlockEditor } from '../BlockEditor/BlockEditor'
|
||||
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
|
||||
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
|
||||
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
|
||||
@@ -54,6 +55,8 @@ 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
|
||||
@@ -1024,6 +1027,15 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
const renderHeaderOptions = isMobileScreen() ? !this.state.plaintextEditorFocused : true
|
||||
|
||||
const editorMode =
|
||||
IsBlocksEnabled && this.note.title.toLowerCase().includes('blocks')
|
||||
? 'blocks'
|
||||
: this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading
|
||||
? 'plain'
|
||||
: this.state.editorComponentViewer
|
||||
? 'component'
|
||||
: 'plain'
|
||||
|
||||
return (
|
||||
<div aria-label="Note" className="section editor sn-component h-full md:max-h-full" ref={this.noteViewElementRef}>
|
||||
{this.note && (
|
||||
@@ -1117,7 +1129,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
<div
|
||||
id={ElementIds.EditorContent}
|
||||
className={`${ElementIds.EditorContent} z-editor-content`}
|
||||
className={`${ElementIds.EditorContent} z-editor-content overflow-scroll`}
|
||||
ref={this.editorContentRef}
|
||||
>
|
||||
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
|
||||
@@ -1134,7 +1146,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{this.state.editorComponentViewer && (
|
||||
{editorMode === 'component' && this.state.editorComponentViewer && (
|
||||
<div className="component-view">
|
||||
<ComponentView
|
||||
key={this.state.editorComponentViewer.identifier}
|
||||
@@ -1146,7 +1158,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.editorStateDidLoad && !this.state.editorComponentViewer && !this.state.textareaUnloading && (
|
||||
{editorMode === 'plain' && (
|
||||
<textarea
|
||||
autoComplete="off"
|
||||
dir="auto"
|
||||
@@ -1166,6 +1178,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
></textarea>
|
||||
)}
|
||||
|
||||
{editorMode === 'blocks' && (
|
||||
<div className={classNames('blocks-editor w-full flex-grow overflow-hidden overflow-y-scroll')}>
|
||||
<BlockEditor key={this.note.uuid} application={this.application} note={this.note} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{this.state.marginResizersEnabled && this.editorContentRef.current ? (
|
||||
<PanelResizer
|
||||
minWidth={300}
|
||||
|
||||
@@ -2,9 +2,6 @@ import { WebApplication } from '@/Application/Application'
|
||||
import { ClientDisplayableError, FeatureDescription } from '@standardnotes/snjs'
|
||||
import { makeAutoObservable, observable } from 'mobx'
|
||||
import { AnyPackageType } from '../Types/AnyPackageType'
|
||||
import { ComponentChecksumsType } from '@standardnotes/components-meta'
|
||||
import RawComponentChecksumsFile from '@standardnotes/components-meta/dist/zips/checksums.json'
|
||||
const ComponentChecksums = RawComponentChecksumsFile as ComponentChecksumsType
|
||||
|
||||
export class PackageProvider {
|
||||
static async load(application: WebApplication): Promise<PackageProvider | undefined> {
|
||||
@@ -38,6 +35,6 @@ function collectFeatures(features: FeatureDescription[] | undefined, versionMap:
|
||||
}
|
||||
|
||||
for (const feature of features) {
|
||||
versionMap.set(feature.identifier, ComponentChecksums[feature.identifier].version)
|
||||
versionMap.set(feature.identifier, 'Latest')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user