feat(labs): super editor (#2001)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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'),
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 '@/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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user