refactor: inner blocks note links (#1973)
This commit is contained in:
@@ -8,6 +8,11 @@ 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 './Contexts/LinkingControllerProvider'
|
||||
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
|
||||
import ItemBubblePlugin from './Plugins/ItemBubblePlugin/ItemBubblePlugin'
|
||||
import { NodeObserverPlugin } from './Plugins/NodeObserverPlugin/NodeObserverPlugin'
|
||||
|
||||
const StringEllipses = '...'
|
||||
const NotePreviewCharLimit = 160
|
||||
@@ -15,9 +20,10 @@ const NotePreviewCharLimit = 160
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
note: SNNote
|
||||
linkingController: LinkingController
|
||||
}
|
||||
|
||||
export const BlockEditor: FunctionComponent<Props> = ({ note, application }) => {
|
||||
export const BlockEditor: FunctionComponent<Props> = ({ note, application, linkingController }) => {
|
||||
const controller = useRef(new BlockEditorController(note, application))
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -31,19 +37,34 @@ export const BlockEditor: FunctionComponent<Props> = ({ note, application }) =>
|
||||
[controller],
|
||||
)
|
||||
|
||||
const handleBubbleRemove = useCallback(
|
||||
(itemUuid: string) => {
|
||||
const item = application.items.findItem(itemUuid)
|
||||
if (item) {
|
||||
linkingController.unlinkItemFromSelectedItem(item).catch(console.error)
|
||||
}
|
||||
},
|
||||
[linkingController, application],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full p-5">
|
||||
<ErrorBoundary>
|
||||
<BlocksEditorComposer initialValue={note.text} nodes={[FileNode]}>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note} />
|
||||
<FilePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
<LinkingControllerProvider controller={linkingController}>
|
||||
<BlocksEditorComposer initialValue={note.text} nodes={[FileNode, BubbleNode]}>
|
||||
<BlocksEditor
|
||||
onChange={handleChange}
|
||||
className="relative relative resize-none text-base focus:shadow-none focus:outline-none"
|
||||
>
|
||||
<ItemSelectionPlugin currentNote={note} />
|
||||
<FilePlugin />
|
||||
<ItemBubblePlugin />
|
||||
<BlockPickerMenuPlugin />
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
</BlocksEditor>
|
||||
</BlocksEditorComposer>
|
||||
</LinkingControllerProvider>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ReactNode, createContext, useContext, memo } from 'react'
|
||||
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { LinkingController } from '@/Controllers/LinkingController'
|
||||
|
||||
const LinkingControllerContext = createContext<LinkingController | undefined>(undefined)
|
||||
|
||||
export const useLinkingController = () => {
|
||||
const value = useContext(LinkingControllerContext)
|
||||
|
||||
if (!value) {
|
||||
throw new Error('Component must be a child of <LinkingControllerProvider />')
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
type ChildrenProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
type ProviderProps = {
|
||||
controller: LinkingController
|
||||
} & ChildrenProps
|
||||
|
||||
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
|
||||
|
||||
const LinkingControllerProvider = ({ controller, children }: ProviderProps) => {
|
||||
return (
|
||||
<LinkingControllerContext.Provider value={controller}>
|
||||
<MemoizedChildren children={children} />
|
||||
</LinkingControllerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(LinkingControllerProvider)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
|
||||
import { BlockPickerOption } from './BlockPickerOption'
|
||||
|
||||
export function BlockPickerMenuItem({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
||||
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { TextNode } from 'lexical'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { PopoverClassNames } from '@standardnotes/blocks-editor'
|
||||
import useModal from '@standardnotes/blocks-editor/src/Lexical/Hooks/useModal'
|
||||
import { InsertTableDialog } from '@standardnotes/blocks-editor/src/Lexical/Plugins/TablePlugin'
|
||||
import { BlockPickerOption } from './BlockPickerOption'
|
||||
@@ -20,6 +19,7 @@ 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'
|
||||
|
||||
export default function BlockPickerMenuPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
@@ -109,7 +109,6 @@ export default function BlockPickerMenuPlugin(): JSX.Element {
|
||||
x: anchorElementRef.current.offsetLeft,
|
||||
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
|
||||
}}
|
||||
className={'min-h-80 h-80'}
|
||||
open={popoverOpen}
|
||||
togglePopover={() => {
|
||||
setPopoverOpen((prevValue) => !prevValue)
|
||||
|
||||
@@ -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 gap-4 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')
|
||||
@@ -0,0 +1,4 @@
|
||||
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')
|
||||
@@ -1,10 +1,11 @@
|
||||
import { INSERT_FILE_COMMAND } from './../Commands'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { $insertNodeToNearestRoot } from '@lexical/utils'
|
||||
import { COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { FileNode } from './Nodes/FileNode'
|
||||
import { $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { $createFileNode } from './Nodes/FileUtils'
|
||||
import { INSERT_FILE_COMMAND } from '@standardnotes/blocks-editor'
|
||||
import { $wrapNodeInElement } from '@lexical/utils'
|
||||
|
||||
export default function FilePlugin(): JSX.Element | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
@@ -18,7 +19,11 @@ export default function FilePlugin(): JSX.Element | null {
|
||||
INSERT_FILE_COMMAND,
|
||||
(payload) => {
|
||||
const fileNode = $createFileNode(payload)
|
||||
$insertNodeToNearestRoot(fileNode)
|
||||
// $insertNodeToNearestRoot(fileNode)
|
||||
$insertNodes([fileNode])
|
||||
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
|
||||
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -3,8 +3,9 @@ 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 {
|
||||
export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
|
||||
__id: string
|
||||
|
||||
static getType(): string {
|
||||
@@ -45,7 +46,7 @@ export class FileNode extends DecoratorBlockNode {
|
||||
}
|
||||
|
||||
exportDOM(): DOMExportOutput {
|
||||
const element = document.createElement('div')
|
||||
const element = document.createElement('span')
|
||||
element.setAttribute('data-lexical-file-uuid', this.__id)
|
||||
const text = document.createTextNode(this.getTextContent())
|
||||
element.append(text)
|
||||
@@ -74,8 +75,4 @@ export class FileNode extends DecoratorBlockNode {
|
||||
|
||||
return <FileComponent className={className} format={this.__format} nodeKey={this.getKey()} fileUuid={this.__id} />
|
||||
}
|
||||
|
||||
isInline(): false {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '@/Components/BlockEditor/Contexts/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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ItemNodeInterface {
|
||||
getId(): string
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { TypeaheadOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
|
||||
|
||||
export class ItemOption extends TypeaheadOption {
|
||||
icon?: JSX.Element
|
||||
|
||||
constructor(
|
||||
public item: FileItem,
|
||||
public item: LinkableItem,
|
||||
public options: {
|
||||
keywords?: Array<string>
|
||||
keyboardShortcut?: string
|
||||
onSelect: (queryString: string) => void
|
||||
},
|
||||
) {
|
||||
super(item.title)
|
||||
super(item.title || '')
|
||||
this.key = item.uuid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import LinkedItemMeta from '@/Components/LinkedItems/LinkedItemMeta'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '@standardnotes/blocks-editor'
|
||||
import { PopoverItemClassNames, PopoverItemSelectedClassNames } from '../ClassNames'
|
||||
import { ItemOption } from './ItemOption'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { LexicalTypeaheadMenuPlugin, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'
|
||||
import { INSERT_FILE_COMMAND, PopoverClassNames } from '@standardnotes/blocks-editor'
|
||||
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, FileItem, SNNote } from '@standardnotes/snjs'
|
||||
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 '../../Contexts/LinkingControllerProvider'
|
||||
import { PopoverClassNames } from '../ClassNames'
|
||||
|
||||
type Props = {
|
||||
currentNote: SNNote
|
||||
@@ -19,6 +21,8 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
|
||||
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const linkingController = useLinkingController()
|
||||
|
||||
const [queryString, setQueryString] = useState<string | null>('')
|
||||
|
||||
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('@', {
|
||||
@@ -43,18 +47,24 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
|
||||
|
||||
const options = useMemo(() => {
|
||||
const results = getLinkingSearchResults(queryString || '', application, currentNote, {
|
||||
contentType: ContentType.File,
|
||||
returnEmptyIfQueryEmpty: false,
|
||||
})
|
||||
const files = [...results.linkedItems, ...results.unlinkedItems] as FileItem[]
|
||||
return files.map((file) => {
|
||||
return new ItemOption(file, {
|
||||
|
||||
const items = [...results.linkedItems, ...results.unlinkedItems]
|
||||
|
||||
return items.map((item) => {
|
||||
return new ItemOption(item, {
|
||||
onSelect: (_queryString: string) => {
|
||||
editor.dispatchCommand(INSERT_FILE_COMMAND, file.uuid)
|
||||
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}, [application, editor, currentNote, queryString])
|
||||
}, [application, editor, currentNote, queryString, linkingController])
|
||||
|
||||
return (
|
||||
<LexicalTypeaheadMenuPlugin<ItemOption>
|
||||
@@ -80,7 +90,6 @@ export const ItemSelectionPlugin: FunctionComponent<Props> = ({ currentNote }) =
|
||||
x: anchorElementRef.current.offsetLeft,
|
||||
y: anchorElementRef.current.offsetTop + anchorElementRef.current.offsetHeight,
|
||||
}}
|
||||
className={'min-h-80 h-80'}
|
||||
open={popoverOpen}
|
||||
togglePopover={() => {
|
||||
setPopoverOpen((prevValue) => !prevValue)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,11 +15,13 @@ type Props = {
|
||||
link: ItemLink
|
||||
activateItem: (item: LinkableItem) => Promise<void>
|
||||
unlinkItem: LinkingController['unlinkItemFromSelectedItem']
|
||||
focusPreviousItem: () => void
|
||||
focusNextItem: () => void
|
||||
focusedId: string | undefined
|
||||
setFocusedId: (id: string) => void
|
||||
focusPreviousItem?: () => void
|
||||
focusNextItem?: () => void
|
||||
focusedId?: string | undefined
|
||||
setFocusedId?: (id: string) => void
|
||||
isBidirectional: boolean
|
||||
inlineFlex?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LinkedItemBubble = ({
|
||||
@@ -31,6 +33,8 @@ const LinkedItemBubble = ({
|
||||
focusedId,
|
||||
setFocusedId,
|
||||
isBidirectional,
|
||||
inlineFlex,
|
||||
className,
|
||||
}: Props) => {
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
const application = useApplication()
|
||||
@@ -42,7 +46,7 @@ const LinkedItemBubble = ({
|
||||
|
||||
const handleFocus = () => {
|
||||
if (focusedId !== link.id) {
|
||||
setFocusedId(link.id)
|
||||
setFocusedId?.(link.id)
|
||||
}
|
||||
setShowUnlinkButton(true)
|
||||
}
|
||||
@@ -63,21 +67,21 @@ const LinkedItemBubble = ({
|
||||
|
||||
const onUnlinkClick: MouseEventHandler = (event) => {
|
||||
event.stopPropagation()
|
||||
void unlinkItem(link)
|
||||
void unlinkItem(link.item)
|
||||
}
|
||||
|
||||
const onKeyDown: KeyboardEventHandler = (event) => {
|
||||
switch (event.key) {
|
||||
case KeyboardKey.Backspace: {
|
||||
focusPreviousItem()
|
||||
void unlinkItem(link)
|
||||
focusPreviousItem?.()
|
||||
void unlinkItem(link.item)
|
||||
break
|
||||
}
|
||||
case KeyboardKey.Left:
|
||||
focusPreviousItem()
|
||||
focusPreviousItem?.()
|
||||
break
|
||||
case KeyboardKey.Right:
|
||||
focusNextItem()
|
||||
focusNextItem?.()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -94,7 +98,12 @@ const LinkedItemBubble = ({
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className="group flex h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-sm text-text hover:bg-contrast focus:bg-contrast lg:text-xs"
|
||||
className={classNames(
|
||||
'group h-6 cursor-pointer items-center rounded border-0 bg-passive-4-opacity-variant py-2 pl-1 pr-2 text-sm',
|
||||
'text-text hover:bg-contrast focus:bg-contrast lg:text-xs',
|
||||
inlineFlex ? 'inline-flex' : 'flex',
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={onBlur}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -279,7 +279,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -299,7 +299,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -330,7 +330,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -349,7 +349,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -366,7 +366,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
@@ -385,7 +385,7 @@ const LinkedItemsPanel = ({
|
||||
key={link.id}
|
||||
item={link.item}
|
||||
searchQuery={searchQuery}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link)}
|
||||
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
|
||||
activateItem={activateItem}
|
||||
handleFileAction={filesController.handleFileAction}
|
||||
/>
|
||||
|
||||
@@ -1091,7 +1091,9 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<LinkedItemBubblesContainer linkingController={this.viewControllerManager.linkingController} />
|
||||
{editorMode !== 'blocks' && (
|
||||
<LinkedItemBubblesContainer linkingController={this.viewControllerManager.linkingController} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1148,7 +1150,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
|
||||
{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} />
|
||||
<BlockEditor
|
||||
key={this.note.uuid}
|
||||
application={this.application}
|
||||
note={this.note}
|
||||
linkingController={this.viewControllerManager.linkingController}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,7 +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 BLOCKS_EDITOR_NAME = 'Super Note'
|
||||
|
||||
export const SYNC_TIMEOUT_DEBOUNCE = 350
|
||||
export const SYNC_TIMEOUT_NO_DEBOUNCE = 100
|
||||
|
||||
@@ -219,14 +219,14 @@ export class LinkingController extends AbstractViewController {
|
||||
return undefined
|
||||
}
|
||||
|
||||
unlinkItemFromSelectedItem = async (itemToUnlink: ItemLink) => {
|
||||
unlinkItemFromSelectedItem = async (itemToUnlink: LinkableItem) => {
|
||||
const selectedItem = this.selectionController.firstSelectedItem
|
||||
|
||||
if (!selectedItem) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.items.unlinkItems(selectedItem, itemToUnlink.item)
|
||||
await this.application.items.unlinkItems(selectedItem, itemToUnlink)
|
||||
|
||||
void this.application.sync.sync()
|
||||
this.reloadAllLinks()
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@standardnotes/snjs'
|
||||
import { EditorMenuGroup } from '@/Components/NotesOptions/EditorMenuGroup'
|
||||
import { EditorMenuItem } from '@/Components/NotesOptions/EditorMenuItem'
|
||||
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
import { BLOCKS_EDITOR_NAME, PLAIN_EDITOR_NAME } from '@/Constants/Constants'
|
||||
|
||||
type NoteTypeToEditorRowsMap = Record<NoteType, EditorMenuItem[]>
|
||||
|
||||
@@ -128,7 +128,7 @@ const createGroupsFromMap = (map: NoteTypeToEditorRowsMap): EditorMenuGroup[] =>
|
||||
groups.splice(1, 0, {
|
||||
icon: 'dashboard',
|
||||
iconClassName: 'text-accessory-tint-1',
|
||||
title: 'Blocks',
|
||||
title: BLOCKS_EDITOR_NAME,
|
||||
items: map[NoteType.Blocks],
|
||||
})
|
||||
}
|
||||
@@ -157,7 +157,7 @@ const createBaselineMap = (): NoteTypeToEditorRowsMap => {
|
||||
|
||||
if (featureTrunkEnabled(FeatureTrunkName.Blocks)) {
|
||||
map[NoteType.Blocks].push({
|
||||
name: 'Blocks',
|
||||
name: BLOCKS_EDITOR_NAME,
|
||||
isEntitled: true,
|
||||
noteType: NoteType.Blocks,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user