refactor: merge blocks-editor package with web (#2217)

This commit is contained in:
Aman Harwara
2023-02-17 18:35:17 +05:30
committed by GitHub
parent 135956ce73
commit 7bf76b51c5
158 changed files with 95 additions and 668 deletions

View File

@@ -27,7 +27,7 @@ import {
} from '@standardnotes/snjs'
import { confirmDialog, DELETE_NOTE_KEYBOARD_COMMAND, KeyboardKey } from '@standardnotes/ui-services'
import { ChangeEventHandler, createRef, KeyboardEventHandler, RefObject } from 'react'
import { SuperEditor } from './SuperEditor/SuperEditor'
import { SuperEditor } from '../SuperEditor/SuperEditor'
import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import LinkedItemBubblesContainer from '../LinkedItems/LinkedItemBubblesContainer'
import LinkedItemsButton from '../LinkedItems/LinkedItemsButton'
@@ -41,7 +41,7 @@ import {
transactionForAssociateComponentWithCurrentNote,
transactionForDisassociateComponentWithCurrentNote,
} from './TransactionFunctions'
import { SuperEditorContentId } from '@standardnotes/blocks-editor'
import { SuperEditorContentId } from '../SuperEditor/Constants'
import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'

View File

@@ -1,55 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { AutoLinkPlugin as LexicalAutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
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'
const URL_MATCHER =
/((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/
const MATCHERS = [
(text: string) => {
const match = URL_MATCHER.exec(text)
if (match === null) {
return null
}
const fullMatch = match[0]
return {
index: match.index,
length: fullMatch.length,
text: fullMatch,
url: fullMatch.startsWith('http') ? fullMatch : `https://${fullMatch}`,
}
},
]
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 (
<>
<LexicalAutoLinkPlugin matchers={MATCHERS} />
</>
)
}

View File

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

View File

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

View File

@@ -1,145 +0,0 @@
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 { GetNumberedListBlockOption } from './Options/NumberedList'
import { GetBulletedListBlockOption } from './Options/BulletedList'
import { GetChecklistBlockOption } from './Options/Checklist'
import { GetDividerBlockOption } from './Options/Divider'
import { GetCollapsibleBlockOption } from './Options/Collapsible'
import { GetDynamicPasswordBlocks, GetPasswordBlockOption } from './Options/Password'
import { GetParagraphBlockOption } from './Options/Paragraph'
import { GetHeadingsBlockOptions } from './Options/Headings'
import { GetQuoteBlockOption } from './Options/Quote'
import { GetAlignmentBlockOptions } from './Options/Alignment'
import { GetCodeBlockOption } from './Options/Code'
import { GetEmbedsBlockOptions } from './Options/Embeds'
import { GetDynamicTableBlocks, GetTableBlockOption } from './Options/Table'
import Popover from '@/Components/Popover/Popover'
import { PopoverClassNames } from '../ClassNames'
import { GetDatetimeBlockOptions } from './Options/DateTime'
import { isMobileScreen } from '@/Utils'
import { useApplication } from '@/Components/ApplicationProvider'
import { GetIndentOutdentBlockOptions } from './Options/IndentOutdent'
export default function BlockPickerMenuPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext()
const application = useApplication()
const [modal, showModal] = useModal()
const [queryString, setQueryString] = useState<string | null>(null)
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
minLength: 0,
})
const options = useMemo(() => {
const indentOutdentOptions = application.isNativeMobileWeb() ? GetIndentOutdentBlockOptions(editor) : []
const baseOptions = [
GetParagraphBlockOption(editor),
...GetHeadingsBlockOptions(editor),
...indentOutdentOptions,
GetTableBlockOption(() =>
showModal('Insert Table', (onClose) => <InsertTableDialog activeEditor={editor} onClose={onClose} />),
),
GetNumberedListBlockOption(editor),
GetBulletedListBlockOption(editor),
GetChecklistBlockOption(editor),
GetQuoteBlockOption(editor),
GetCodeBlockOption(editor),
GetDividerBlockOption(editor),
...GetDatetimeBlockOptions(editor),
...GetAlignmentBlockOptions(editor),
GetPasswordBlockOption(editor),
GetCollapsibleBlockOption(editor),
...GetEmbedsBlockOptions(editor),
]
const dynamicOptions = [
...GetDynamicTableBlocks(editor, queryString || ''),
...GetDynamicPasswordBlocks(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, application])
const onSelectOption = useCallback(
(
selectedOption: BlockPickerOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
matchingString: string,
) => {
editor.update(() => {
if (nodeToRemove) {
nodeToRemove.remove()
}
selectedOption.onSelect(matchingString)
closeMenu()
})
},
[editor],
)
return (
<>
{modal}
<LexicalTypeaheadMenuPlugin<BlockPickerOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
if (!anchorElementRef.current || !options.length) {
return null
}
return (
<Popover
title="Block picker"
align="start"
anchorElement={anchorElementRef.current}
open={true}
disableMobileFullscreenTakeover={true}
side={isMobileScreen() ? 'top' : 'bottom'}
maxHeight={(mh) => mh / 2}
>
<div className={PopoverClassNames}>
<ul>
{options.map((option, i: number) => (
<BlockPickerMenuItem
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>
</Popover>
)
}}
/>
</>
)
}

View File

@@ -1,14 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetAlignmentBlocks } from '../../Blocks/Alignment'
export function GetAlignmentBlockOptions(editor: LexicalEditor) {
return GetAlignmentBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetBulletedListBlock } from '../../Blocks/BulletedList'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetBulletedListBlockOption(editor: LexicalEditor) {
const block = GetBulletedListBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetChecklistBlock } from '../../Blocks/Checklist'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetChecklistBlockOption(editor: LexicalEditor) {
const block = GetChecklistBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,12 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetCodeBlock } from '../../Blocks/Code'
export function GetCodeBlockOption(editor: LexicalEditor) {
const block = GetCodeBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,12 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetCollapsibleBlock } from '../../Blocks/Collapsible'
export function GetCollapsibleBlockOption(editor: LexicalEditor) {
const block = GetCollapsibleBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetDatetimeBlocks } from '../../Blocks/DateTime'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetDatetimeBlockOptions(editor: LexicalEditor) {
return GetDatetimeBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetDividerBlock } from '../../Blocks/Divider'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetDividerBlockOption(editor: LexicalEditor) {
const block = GetDividerBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { GetEmbedsBlocks } from '../../Blocks/Embeds'
export function GetEmbedsBlockOptions(editor: LexicalEditor) {
return GetEmbedsBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
import { GetHeadingsBlocks } from '../../Blocks/Headings'
export function GetHeadingsBlockOptions(editor: LexicalEditor) {
return GetHeadingsBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,15 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetIndentOutdentBlocks } from '../../Blocks/IndentOutdent'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetIndentOutdentBlockOptions(editor: LexicalEditor) {
return GetIndentOutdentBlocks(editor).map(
(block) =>
new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
}),
)
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetNumberedListBlock } from '../../Blocks/NumberedList'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetNumberedListBlockOption(editor: LexicalEditor) {
const block = GetNumberedListBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetParagraphBlock } from '../../Blocks/Paragraph'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetParagraphBlockOption(editor: LexicalEditor) {
const block = GetParagraphBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

@@ -1,42 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { INSERT_PASSWORD_COMMAND } from '../../Commands'
import { GetPasswordBlock } from '../../Blocks/Password'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
const MIN_PASSWORD_LENGTH = 8
export function GetPasswordBlockOption(editor: LexicalEditor) {
const block = GetPasswordBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}
export function GetDynamicPasswordBlocks(editor: LexicalEditor, queryString: string) {
if (queryString == null) {
return []
}
const lengthRegex = /^\d+$/
const match = lengthRegex.exec(queryString)
if (!match) {
return []
}
const length = parseInt(match[0], 10)
if (length < MIN_PASSWORD_LENGTH) {
return []
}
return [
new BlockPickerOption(`Generate ${length}-character cryptographically secure password`, {
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, length.toString()),
}),
]
}

View File

@@ -1,13 +0,0 @@
import { BlockPickerOption } from '../BlockPickerOption'
import { LexicalEditor } from 'lexical'
import { GetQuoteBlock } from '../../Blocks/Quote'
import { LexicalIconName } from '@/Components/Icon/LexicalIcons'
export function GetQuoteBlockOption(editor: LexicalEditor) {
const block = GetQuoteBlock(editor)
return new BlockPickerOption(block.name, {
iconName: block.iconName as LexicalIconName,
keywords: block.keywords,
onSelect: block.onSelect,
})
}

View File

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

View File

@@ -1,11 +0,0 @@
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) => ({
name: `Align ${alignment}`,
iconName: `align-${alignment}` as LexicalIconName,
keywords: ['align', 'justify', alignment],
onSelect: () => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment as ElementFormatType),
}))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
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) => ({
name: `Embed ${embedConfig.contentName}`,
iconName: embedConfig.iconName as LexicalIconName,
keywords: [...embedConfig.keywords, 'embed'],
onSelect: () => editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
}))
}

View File

@@ -1,19 +0,0 @@
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) => ({
name: `Heading ${n}`,
iconName: `h${n}` as LexicalIconName,
keywords: ['heading', 'header', `h${n}`],
onSelect: () =>
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode(`h${n}` as HeadingTagType))
}
}),
}))
}

View File

@@ -1,18 +0,0 @@
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, LexicalEditor } from 'lexical'
export function GetIndentOutdentBlocks(editor: LexicalEditor) {
return [
{
name: 'Indent',
iconName: 'arrow-right',
keywords: ['indent'],
onSelect: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
},
{
name: 'Outdent',
iconName: 'arrow-left',
keywords: ['outdent'],
onSelect: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
},
]
}

View File

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

View File

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

View File

@@ -1,13 +0,0 @@
import { LexicalEditor } from 'lexical'
import { INSERT_PASSWORD_COMMAND } from '../Commands'
const DEFAULT_PASSWORD_LENGTH = 16
export function GetPasswordBlock(editor: LexicalEditor) {
return {
name: 'Generate cryptographically secure password',
iconName: 'password',
keywords: ['password', 'secure'],
onSelect: () => editor.dispatchCommand(INSERT_PASSWORD_COMMAND, String(DEFAULT_PASSWORD_LENGTH)),
}
}

View File

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

View File

@@ -1,3 +0,0 @@
export function GetTableBlock(onSelect: () => void) {
return { name: 'Table', iconName: 'table', keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'], onSelect }
}

View File

@@ -1,26 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
export type ChangeEditorFunction = (jsonContent: string) => void
type ChangeEditorFunctionProvider = (changeEditorFunction: ChangeEditorFunction) => void
export function ChangeContentCallbackPlugin({
providerCallback,
}: {
providerCallback: ChangeEditorFunctionProvider
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const changeContents: ChangeEditorFunction = (jsonContent: string) => {
editor.update(() => {
const editorState = editor.parseEditorState(jsonContent)
editor.setEditorState(editorState)
})
}
providerCallback(changeContents)
}, [editor, providerCallback])
return null
}

View File

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

View File

@@ -1,109 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { $getNodeByKey, $getSelection, $isRangeSelection, $isRootOrShadowRoot, NodeKey } from 'lexical'
import { useCallback, useEffect, useState } from 'react'
import { $isCodeNode, CODE_LANGUAGE_MAP, CODE_LANGUAGE_FRIENDLY_NAME_MAP, normalizeCodeLang } from '@lexical/code'
import Dropdown from '@/Components/Dropdown/Dropdown'
function getCodeLanguageOptions(): [string, string][] {
const options: [string, string][] = []
for (const [lang, friendlyName] of Object.entries(CODE_LANGUAGE_FRIENDLY_NAME_MAP)) {
options.push([lang, friendlyName])
}
return options
}
const CODE_LANGUAGE_OPTIONS = getCodeLanguageOptions()
const CodeOptionsPlugin = () => {
const [editor] = useLexicalComposerContext()
const [isCode, setIsCode] = useState(false)
const [codeLanguage, setCodeLanguage] = useState<keyof typeof CODE_LANGUAGE_MAP>('')
const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(null)
const updateToolbar = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
const anchorNode = selection.anchor.getNode()
let element =
anchorNode.getKey() === 'root'
? anchorNode
: $findMatchingParent(anchorNode, (e) => {
const parent = e.getParent()
return parent !== null && $isRootOrShadowRoot(parent)
})
if (element === null) {
element = anchorNode.getTopLevelElementOrThrow()
}
const elementKey = element.getKey()
const elementDOM = editor.getElementByKey(elementKey)
if (elementDOM !== null) {
setSelectedElementKey(elementKey)
if ($isCodeNode(element)) {
setIsCode(true)
const language = element.getLanguage() as keyof typeof CODE_LANGUAGE_MAP
setCodeLanguage(language ? CODE_LANGUAGE_MAP[language] || language : '')
} else {
setIsCode(false)
}
}
}, [editor])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
updateToolbar()
})
}),
)
}, [editor, updateToolbar])
const onCodeLanguageSelect = useCallback(
(value: string) => {
editor.update(() => {
if (selectedElementKey !== null) {
const node = $getNodeByKey(selectedElementKey)
if ($isCodeNode(node)) {
node.setLanguage(value)
}
}
})
},
[editor, selectedElementKey],
)
if (!isCode) {
return null
}
return (
<>
<div className="absolute top-2 right-6 rounded border border-border bg-default p-2">
<Dropdown
id="code-language-dropdown"
label="Change code block language"
items={CODE_LANGUAGE_OPTIONS.map(([value, label]) => ({
label,
value,
}))}
value={normalizeCodeLang(codeLanguage)}
onChange={(value: string) => {
onCodeLanguageSelect(value)
}}
/>
</div>
</>
)
}
export default CodeOptionsPlugin

View File

@@ -1,8 +0,0 @@
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')
export const INSERT_PASSWORD_COMMAND: LexicalCommand<string> = createCommand('INSERT_PASSWORD_COMMAND')

View File

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

View File

@@ -1,84 +0,0 @@
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,
COMMAND_PRIORITY_NORMAL,
PASTE_COMMAND,
} from 'lexical'
import { $createFileNode } from './Nodes/FileUtils'
import { $wrapNodeInElement, mergeRegister } 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')
}
const uploadFilesList = (files: FileList) => {
Array.from(files).forEach(async (file) => {
try {
const uploadedFile = await filesController.uploadNewFile(file)
if (uploadedFile) {
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
}
} catch (error) {
console.error(error)
}
})
}
return mergeRegister(
editor.registerCommand<string>(
INSERT_FILE_COMMAND,
(payload) => {
const fileNode = $createFileNode(payload)
$insertNodes([fileNode])
if ($isRootOrShadowRoot(fileNode.getParentOrThrow())) {
$wrapNodeInElement(fileNode, $createParagraphNode).selectEnd()
}
const newLineNode = $createParagraphNode()
$insertNodes([newLineNode])
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
PASTE_COMMAND,
(payload) => {
const files = payload instanceof ClipboardEvent ? payload.clipboardData?.files : null
if (files?.length) {
uploadFilesList(files)
return true
}
return false
},
COMMAND_PRIORITY_NORMAL,
),
)
}, [editor, filesController])
useEffect(() => {
const disposer = filesController.addEventObserver((event, data) => {
if (event === FilesControllerEvent.FileUploadedToNote) {
const fileUuid = data[FilesControllerEvent.FileUploadedToNote].uuid
editor.dispatchCommand(INSERT_FILE_COMMAND, fileUuid)
}
})
return disposer
}, [filesController, editor])
return null
}

View File

@@ -1,88 +0,0 @@
import { BlockWithAlignableContents } from '@lexical/react/LexicalBlockWithAlignableContents'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ElementFormatType, NodeKey } from 'lexical'
import { useApplication } from '@/Components/ApplicationProvider'
import FilePreview from '@/Components/FilePreview/FilePreview'
import { FileItem } from '@standardnotes/snjs'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
export type FileComponentProps = Readonly<{
className: Readonly<{
base: string
focus: string
}>
format: ElementFormatType | null
nodeKey: NodeKey
fileUuid: string
zoomLevel: number
setZoomLevel: (zoomLevel: number) => void
}>
export function FileComponent({ className, format, nodeKey, fileUuid, zoomLevel, setZoomLevel }: FileComponentProps) {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const file = useMemo(() => application.items.findItem<FileItem>(fileUuid), [application, fileUuid])
const [canLoad, setCanLoad] = useState(false)
const blockWrapperRef = useRef<HTMLDivElement>(null)
const blockObserver = useMemo(
() =>
new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setCanLoad(true)
}
})
},
{
threshold: 0.25,
},
),
[],
)
useEffect(() => {
const wrapper = blockWrapperRef.current
if (!wrapper) {
return
}
blockObserver.observe(wrapper)
return () => {
blockObserver.unobserve(wrapper)
}
}, [blockObserver])
const setImageZoomLevel = useCallback(
(zoomLevel: number) => {
editor.update(() => {
setZoomLevel(zoomLevel)
})
},
[editor, setZoomLevel],
)
if (!file) {
return <div>Unable to find file {fileUuid}</div>
}
return (
<BlockWithAlignableContents className={className} format={format} nodeKey={nodeKey}>
<div ref={blockWrapperRef}>
{canLoad && (
<FilePreview
isEmbeddedInSuper={true}
file={file}
application={application}
imageZoomLevel={zoomLevel}
setImageZoomLevel={setImageZoomLevel}
/>
)}
</div>
</BlockWithAlignableContents>
)
}

View File

@@ -1,96 +0,0 @@
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
__zoomLevel: number
static getType(): string {
return 'snfile'
}
static clone(node: FileNode): FileNode {
return new FileNode(node.__id, node.__format, node.__key, node.__zoomLevel)
}
static importJSON(serializedNode: SerializedFileNode): FileNode {
const node = $createFileNode(serializedNode.fileUuid)
node.setFormat(serializedNode.format)
node.setZoomLevel(serializedNode.zoomLevel)
return node
}
exportJSON(): SerializedFileNode {
return {
...super.exportJSON(),
fileUuid: this.getId(),
version: 1,
type: 'snfile',
zoomLevel: this.__zoomLevel,
}
}
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, zoomLevel?: number) {
super(format, key)
this.__id = id
this.__zoomLevel = zoomLevel || 100
}
getId(): string {
return this.__id
}
getTextContent(_includeInert?: boolean | undefined, _includeDirectionless?: false | undefined): string {
return `[File: ${this.__id}]`
}
setZoomLevel(zoomLevel: number): void {
const writable = this.getWritable()
writable.__zoomLevel = zoomLevel
}
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}
zoomLevel={this.__zoomLevel}
setZoomLevel={this.setZoomLevel.bind(this)}
/>
)
}
}

View File

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

View File

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

View File

@@ -1,113 +0,0 @@
import { useApplication } from '@/Components/ApplicationProvider'
import { downloadBlobOnAndroid } from '@/NativeMobileWeb/DownloadBlobOnAndroid'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { Platform } from '@standardnotes/snjs'
import {
sanitizeFileName,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
} from '@standardnotes/ui-services'
import { useCallback, useEffect } from 'react'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { $generateHtmlFromNodes } from '@lexical/html'
import { useCommandService } from '@/Components/CommandProvider'
export const ExportPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const commandService = useCommandService()
const downloadData = useCallback(
(data: Blob, fileName: string) => {
if (!application.isNativeMobileWeb()) {
application.getArchiveService().downloadData(data, fileName)
return
}
if (application.platform === Platform.Android) {
downloadBlobOnAndroid(application, data, fileName).catch(console.error)
} else {
shareBlobOnMobile(application, data, fileName).catch(console.error)
}
},
[application],
)
const exportJson = useCallback(
(title: string) => {
const content = JSON.stringify(editor.toJSON())
const blob = new Blob([content], { type: 'application/json' })
downloadData(blob, `${sanitizeFileName(title)}.json`)
},
[downloadData, editor],
)
const exportMarkdown = useCallback(
(title: string) => {
editor.getEditorState().read(() => {
const content = $convertToMarkdownString(MarkdownTransformers)
const blob = new Blob([content], { type: 'text/markdown' })
downloadData(blob, `${sanitizeFileName(title)}.md`)
})
},
[downloadData, editor],
)
const exportHtml = useCallback(
(title: string) => {
editor.getEditorState().read(() => {
const content = $generateHtmlFromNodes(editor)
const blob = new Blob([content], { type: 'text/html' })
downloadData(blob, `${sanitizeFileName(title)}.html`)
})
},
[downloadData, editor],
)
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_JSON,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportJson(title)
},
})
}, [commandService, exportJson])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_MARKDOWN,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportMarkdown(title)
},
})
}, [commandService, exportMarkdown])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_HTML,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportHtml(title)
},
})
}, [commandService, exportHtml])
return null
}

View File

@@ -1,28 +0,0 @@
import { forwardRef, useCallback, useImperativeHandle } from 'react'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
export type GetMarkdownPluginInterface = {
getMarkdown: () => string
}
const GetMarkdownPlugin = forwardRef<GetMarkdownPluginInterface>((_, ref) => {
const [editor] = useLexicalComposerContext()
useImperativeHandle(ref, () => ({
getMarkdown() {
return getMarkdown()
},
}))
const getMarkdown = useCallback(() => {
return editor.getEditorState().read(() => {
return $convertToMarkdownString(MarkdownTransformers)
})
}, [editor])
return null
})
export default GetMarkdownPlugin

View File

@@ -1,50 +0,0 @@
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'
import { handleEditorChange } from '@standardnotes/blocks-editor/src/Editor/Utils'
import { SuperNotePreviewCharLimit } from '../../SuperEditor'
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
export default function ImportPlugin({
text,
format,
onChange,
}: {
text: string
format: 'md' | 'html'
onChange: (value: string, preview: string) => void
}): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
const dontAllowConversionOfEmptyStringWhichWouldResultInError = text.length === 0
if (dontAllowConversionOfEmptyStringWhichWouldResultInError) {
return
}
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])
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
handleEditorChange(editorState, SuperNotePreviewCharLimit, onChange)
})
})
}, [editor, onChange])
return null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,132 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin } 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/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'
import { isMobileScreen } from '@/Utils'
import { useTypeaheadAllowingSpacesAndPunctuation } from './useTypeaheadAllowingSpacesAndPunctuation'
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 = useTypeaheadAllowingSpacesAndPunctuation('@', {
minLength: 0,
})
const onSelectOption = useCallback(
(selectedOption: ItemOption, nodeToRemove: TextNode | null, closeMenu: () => void, matchingString: string) => {
editor.update(() => {
if (nodeToRemove) {
nodeToRemove.remove()
}
selectedOption.options.onSelect(matchingString)
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}
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {
if (!anchorElementRef.current || !options.length) {
return null
}
return (
<Popover
title="Select item"
align="start"
anchorElement={anchorElementRef.current}
open={true}
disableMobileFullscreenTakeover={true}
side={isMobileScreen() ? 'top' : 'bottom'}
maxHeight={(mh) => mh / 2}
>
<div className={PopoverClassNames}>
<ul>
{options.map((option, i: number) => (
<ItemSelectionItemComponent
searchQuery={queryString || ''}
index={i}
isSelected={selectedIndex === i}
onClick={() => {
setHighlightedIndex(i)
selectOptionAndCleanUp(option)
}}
onMouseEnter={() => {
setHighlightedIndex(i)
}}
key={option.key}
option={option}
/>
))}
</ul>
</div>
</Popover>
)
}}
/>
)
}

View File

@@ -1,41 +0,0 @@
import { LexicalEditor } from 'lexical'
import { useCallback } from 'react'
export type QueryMatch = {
leadOffset: number
matchingString: string
replaceableString: string
}
type TriggerFn = (text: string, editor: LexicalEditor) => QueryMatch | null
/**
* Derived from
* https://github.com/facebook/lexical/blob/main/packages/lexical-react/src/LexicalTypeaheadMenuPlugin.tsx#L545
*/
export function useTypeaheadAllowingSpacesAndPunctuation(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = '[^' + trigger + ']'
const TypeaheadTriggerRegex = new RegExp(
'(^|\\s|\\()(' + '[' + trigger + ']' + '((?:' + validChars + '){0,' + maxLength + '})' + ')$',
)
const match = TypeaheadTriggerRegex.exec(text)
if (match !== null) {
const maybeLeadingWhitespace = match[1]
const matchingString = match[3]
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
}
}
}
return null
},
[maxLength, minLength, trigger],
)
}

View File

@@ -1,26 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { $createCodeNode } from '@lexical/code'
import { $createTextNode, $getRoot } from 'lexical'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
type Props = {
onMarkdown: (markdown: string) => void
}
export default function MarkdownPreviewPlugin({ onMarkdown }: Props): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
editor.update(() => {
const root = $getRoot()
const markdown = $convertToMarkdownString(MarkdownTransformers)
root.clear().append($createCodeNode('markdown').append($createTextNode(markdown)))
root.selectEnd()
onMarkdown(markdown)
})
}, [editor, onMarkdown])
return null
}

View File

@@ -1,222 +0,0 @@
import Icon from '@/Components/Icon/Icon'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import useModal from '@standardnotes/blocks-editor/src/Lexical/Hooks/useModal'
import { InsertTableDialog } from '@standardnotes/blocks-editor/src/Lexical/Plugins/TablePlugin'
import { getSelectedNode } from '@standardnotes/blocks-editor/src/Lexical/Utils/getSelectedNode'
import { sanitizeUrl } from '@standardnotes/blocks-editor/src/Lexical/Utils/sanitizeUrl'
import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { GetAlignmentBlocks } from '../Blocks/Alignment'
import { GetBulletedListBlock } from '../Blocks/BulletedList'
import { GetChecklistBlock } from '../Blocks/Checklist'
import { GetCodeBlock } from '../Blocks/Code'
import { GetCollapsibleBlock } from '../Blocks/Collapsible'
import { GetDatetimeBlocks } from '../Blocks/DateTime'
import { GetDividerBlock } from '../Blocks/Divider'
import { GetEmbedsBlocks } from '../Blocks/Embeds'
import { GetHeadingsBlocks } from '../Blocks/Headings'
import { GetIndentOutdentBlocks } from '../Blocks/IndentOutdent'
import { GetNumberedListBlock } from '../Blocks/NumberedList'
import { GetParagraphBlock } from '../Blocks/Paragraph'
import { GetPasswordBlock } from '../Blocks/Password'
import { GetQuoteBlock } from '../Blocks/Quote'
import { GetTableBlock } from '../Blocks/Table'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { classNames } from '@standardnotes/snjs'
import { SUPER_TOGGLE_SEARCH } from '@standardnotes/ui-services'
import { useApplication } from '@/Components/ApplicationProvider'
const MobileToolbarPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const [modal, showModal] = useModal()
const [isInEditor, setIsInEditor] = useState(false)
const [isInToolbar, setIsInToolbar] = useState(false)
const isMobile = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)
const toolbarRef = useRef<HTMLDivElement>(null)
const insertLink = useCallback(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
const node = getSelectedNode(selection)
const parent = node.getParent()
const isLink = $isLinkNode(parent) || $isLinkNode(node)
if (!isLink) {
editor.update(() => {
const selection = $getSelection()
const textContent = selection?.getTextContent()
if (!textContent) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://')
return
}
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(textContent))
})
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
}, [editor])
const items = useMemo(
() => [
{
name: 'Bold',
iconName: 'bold',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
},
},
{
name: 'Italic',
iconName: 'italic',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
},
},
{
name: 'Underline',
iconName: 'underline',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
},
},
{
name: 'Strikethrough',
iconName: 'strikethrough',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
},
},
{
name: 'Subscript',
iconName: 'subscript',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
},
},
{
name: 'Superscript',
iconName: 'superscript',
onSelect: () => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
},
},
{
name: 'Link',
iconName: 'link',
onSelect: () => {
editor.update(() => {
insertLink()
})
},
},
{
name: 'Search',
iconName: 'search',
onSelect: () => {
application.keyboardService.triggerCommand(SUPER_TOGGLE_SEARCH)
},
},
GetParagraphBlock(editor),
...GetHeadingsBlocks(editor),
...GetIndentOutdentBlocks(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),
...[GetPasswordBlock(editor)],
GetCollapsibleBlock(editor),
...GetEmbedsBlocks(editor),
],
[application.keyboardService, editor, insertLink, showModal],
)
useEffect(() => {
const rootElement = editor.getRootElement()
if (!rootElement) {
return
}
const handleFocus = () => setIsInEditor(true)
const handleBlur = (event: FocusEvent) => {
if (toolbarRef.current?.contains(event.relatedTarget as Node)) {
return
}
setIsInEditor(false)
}
rootElement.addEventListener('focus', handleFocus)
rootElement.addEventListener('blur', handleBlur)
return () => {
rootElement.removeEventListener('focus', handleFocus)
rootElement.removeEventListener('blur', handleBlur)
}
}, [editor])
useEffect(() => {
if (!toolbarRef.current) {
return
}
const toolbar = toolbarRef.current
const handleFocus = () => setIsInToolbar(true)
const handleBlur = () => setIsInToolbar(false)
toolbar.addEventListener('focus', handleFocus)
toolbar.addEventListener('blur', handleBlur)
return () => {
toolbar?.removeEventListener('focus', handleFocus)
toolbar?.removeEventListener('blur', handleBlur)
}
}, [])
const isFocusInEditorOrToolbar = isInEditor || isInToolbar
if (!isMobile || !isFocusInEditorOrToolbar) {
return null
}
return (
<>
{modal}
<div tabIndex={-1} className="flex w-full flex-shrink-0 border-t border-border bg-contrast" ref={toolbarRef}>
<div className={classNames('flex items-center gap-1 overflow-x-auto', '[&::-webkit-scrollbar]:h-0')}>
{items.map((item) => {
return (
<button
className="flex items-center justify-center rounded py-3 px-3"
aria-label={item.name}
onClick={item.onSelect}
key={item.name}
>
<Icon type={item.iconName} size="medium" />
</button>
)
})}
</div>
<button
className="flex flex-shrink-0 items-center justify-center rounded border-l border-border py-3 px-3"
aria-label="Dismiss keyboard"
>
<Icon type="keyboard-close" size="medium" />
</button>
</div>
</>
)
}
export default MobileToolbarPlugin

View File

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

View File

@@ -1,26 +0,0 @@
const LOWER_CASE_LETTERS = 'abcdefghijklmnopqrstuvwxyz'.split('')
const UPPER_CASE_LETTERS = LOWER_CASE_LETTERS.map((l) => l.toUpperCase())
const SPECIAL_SYMBOLS = '!£$%^&*()@~:;,./?{}=-_'.split('')
const CHARACTER_SET = [...LOWER_CASE_LETTERS, ...UPPER_CASE_LETTERS, ...SPECIAL_SYMBOLS]
const CHARACTER_SET_LENGTH = CHARACTER_SET.length
function isValidPassword(password: string) {
const containsSymbols = SPECIAL_SYMBOLS.some((symbol) => password.includes(symbol))
const containsUpperCase = UPPER_CASE_LETTERS.some((upperLetter) => password.includes(upperLetter))
const containsLowerCase = LOWER_CASE_LETTERS.some((lowerLetter) => password.includes(lowerLetter))
return containsLowerCase && containsUpperCase && containsSymbols
}
export function generatePassword(length: number): string {
const buffer = new Uint8Array(length)
let generatedPassword = ''
do {
window.crypto.getRandomValues(buffer)
generatedPassword = [...buffer].map((x) => CHARACTER_SET[x % CHARACTER_SET_LENGTH]).join('')
} while (!isValidPassword(generatedPassword))
return generatedPassword
}

View File

@@ -1,40 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
COMMAND_PRIORITY_EDITOR,
$createTextNode,
$getSelection,
$isRangeSelection,
$createParagraphNode,
} from 'lexical'
import { useEffect } from 'react'
import { INSERT_PASSWORD_COMMAND } from '../Commands'
import { mergeRegister } from '@lexical/utils'
import { generatePassword } from './Generator'
export default function PasswordPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(
editor.registerCommand<string>(
INSERT_PASSWORD_COMMAND,
(lengthString) => {
const length = Number(lengthString)
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return false
}
const paragraph = $createParagraphNode()
const password = generatePassword(length)
paragraph.append($createTextNode(password))
selection.insertNodes([paragraph])
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return null
}

View File

@@ -1,30 +0,0 @@
import { useApplication } from '@/Components/ApplicationProvider'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { SNNote, ContentType } from '@standardnotes/snjs'
import { useState, useEffect } from 'react'
const ReadonlyPlugin = ({ note }: { note: SNNote }) => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const [readOnly, setReadOnly] = useState(note.locked)
useEffect(() => {
return application.streamItems<SNNote>(ContentType.Note, ({ changed }) => {
const changedNoteItem = changed.find((changedItem) => changedItem.uuid === note.uuid)
if (changedNoteItem) {
setReadOnly(changedNoteItem.locked)
}
})
}, [application, note.uuid])
useEffect(() => {
editor.update(() => {
editor.setEditable(!readOnly)
})
}, [editor, readOnly])
return null
}
export default ReadonlyPlugin

View File

@@ -1,130 +0,0 @@
import { createContext, ReactNode, useCallback, useContext, useMemo, useReducer, useRef } from 'react'
import { SuperSearchContextAction, SuperSearchContextState, SuperSearchReplaceEvent } from './Types'
type SuperSearchContextData = SuperSearchContextState & {
dispatch: React.Dispatch<SuperSearchContextAction>
addReplaceEventListener: (listener: (type: SuperSearchReplaceEvent) => void) => () => void
dispatchReplaceEvent: (type: SuperSearchReplaceEvent) => void
}
const SuperSearchContext = createContext<SuperSearchContextData | undefined>(undefined)
export const useSuperSearchContext = () => {
const context = useContext(SuperSearchContext)
if (!context) {
throw new Error('useSuperSearchContext must be used within a SuperSearchContextProvider')
}
return context
}
const initialState: SuperSearchContextState = {
query: '',
results: [],
currentResultIndex: -1,
isCaseSensitive: false,
isSearchActive: false,
isReplaceMode: false,
}
const searchContextReducer = (
state: SuperSearchContextState,
action: SuperSearchContextAction,
): SuperSearchContextState => {
switch (action.type) {
case 'set-query':
return {
...state,
query: action.query,
}
case 'set-results':
return {
...state,
results: action.results,
currentResultIndex: action.results.length > 0 ? 0 : -1,
}
case 'clear-results':
return {
...state,
results: [],
currentResultIndex: -1,
}
case 'set-current-result-index':
return {
...state,
currentResultIndex: action.index,
}
case 'toggle-search':
return {
...initialState,
isSearchActive: !state.isSearchActive,
}
case 'toggle-case-sensitive':
return {
...state,
isCaseSensitive: !state.isCaseSensitive,
}
case 'toggle-replace-mode': {
const toggledValue = !state.isReplaceMode
return {
...state,
isSearchActive: toggledValue && !state.isSearchActive ? true : state.isSearchActive,
isReplaceMode: toggledValue,
}
}
case 'go-to-next-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex + 1 < state.results.length
? state.currentResultIndex + 1
: 0,
}
case 'go-to-previous-result':
return {
...state,
currentResultIndex:
state.results.length < 1
? -1
: state.currentResultIndex - 1 >= 0
? state.currentResultIndex - 1
: state.results.length - 1,
}
case 'reset-search':
return { ...initialState }
}
}
export const SuperSearchContextProvider = ({ children }: { children: ReactNode }) => {
const [state, dispatch] = useReducer(searchContextReducer, initialState)
const replaceEventListeners = useRef(new Set<(type: SuperSearchReplaceEvent) => void>())
const addReplaceEventListener = useCallback((listener: (type: SuperSearchReplaceEvent) => void) => {
replaceEventListeners.current.add(listener)
return () => {
replaceEventListeners.current.delete(listener)
}
}, [])
const dispatchReplaceEvent = useCallback((type: SuperSearchReplaceEvent) => {
replaceEventListeners.current.forEach((listener) => listener(type))
}, [])
const value = useMemo(
() => ({
...state,
dispatch,
addReplaceEventListener,
dispatchReplaceEvent,
}),
[addReplaceEventListener, dispatchReplaceEvent, state],
)
return <SuperSearchContext.Provider value={value}>{children}</SuperSearchContext.Provider>
}

View File

@@ -1,231 +0,0 @@
import Button from '@/Components/Button/Button'
import { useCommandService } from '@/Components/CommandProvider'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { TranslateFromTopAnimation, TranslateToTopAnimation } from '@/Constants/AnimationConfigs'
import { useLifecycleAnimation } from '@/Hooks/useLifecycleAnimation'
import { ArrowDownIcon, ArrowUpIcon, CloseIcon, ArrowRightIcon } from '@standardnotes/icons'
import {
keyboardStringForShortcut,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
} from '@standardnotes/ui-services'
import { classNames } from '@standardnotes/utils'
import { useCallback, useMemo, useState } from 'react'
import { useSuperSearchContext } from './Context'
export const SearchDialog = ({ open, closeDialog }: { open: boolean; closeDialog: () => void }) => {
const { query, results, currentResultIndex, isCaseSensitive, isReplaceMode, dispatch, dispatchReplaceEvent } =
useSuperSearchContext()
const [replaceQuery, setReplaceQuery] = useState('')
const focusOnMount = useCallback((node: HTMLInputElement | null) => {
if (node) {
node.focus()
}
}, [])
const [isMounted, setElement] = useLifecycleAnimation({
open,
enter: TranslateFromTopAnimation,
exit: TranslateToTopAnimation,
})
const commandService = useCommandService()
const searchToggleShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_TOGGLE_SEARCH)),
[commandService],
)
const toggleReplaceShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_REPLACE_MODE)),
[commandService],
)
const caseSensitivityShortcut = useMemo(
() => keyboardStringForShortcut(commandService.keyboardShortcutForCommand(SUPER_SEARCH_TOGGLE_CASE_SENSITIVE)),
[commandService],
)
if (!isMounted) {
return null
}
return (
<div
className="absolute right-6 top-4 z-10 flex select-none rounded border border-border bg-default"
ref={setElement}
>
<button
className="focus:ring-none border-r border-border px-1 hover:bg-contrast focus:shadow-inner focus:shadow-info"
onClick={() => {
dispatch({ type: 'toggle-replace-mode' })
}}
title={`Toggle Replace Mode (${toggleReplaceShortcut})`}
>
{isReplaceMode ? (
<ArrowDownIcon className="h-4 w-4 fill-text" />
) : (
<ArrowRightIcon className="h-4 w-4 fill-text" />
)}
</button>
<div
className="flex flex-col gap-2 py-2 px-2"
onKeyDown={(event) => {
if (event.key === 'Escape') {
closeDialog()
}
}}
>
<div className="flex items-center gap-2">
<DecoratedInput
placeholder="Search"
className={{
container: classNames('flex-grow !text-[length:inherit]', !query.length && '!py-1'),
right: '!py-1',
}}
value={query}
onChange={(query) => {
dispatch({
type: 'set-query',
query,
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && results.length) {
if (event.shiftKey) {
dispatch({
type: 'go-to-previous-result',
})
return
}
dispatch({
type: 'go-to-next-result',
})
}
}}
ref={focusOnMount}
right={[
<div className="min-w-[7ch] max-w-[7ch] flex-shrink-0 whitespace-nowrap text-right">
{query.length > 0 && (
<>
{currentResultIndex > -1 ? currentResultIndex + 1 + ' / ' : null}
{results.length}
</>
)}
</div>,
]}
/>
<label
className={classNames(
'relative flex items-center rounded border py-1 px-1.5 focus-within:ring-2 focus-within:ring-info focus-within:ring-offset-2 focus-within:ring-offset-default',
isCaseSensitive ? 'border-info bg-info text-info-contrast' : 'border-border hover:bg-contrast',
)}
title={`Case sensitive (${caseSensitivityShortcut})`}
>
<input
type="checkbox"
className="absolute top-0 left-0 z-[1] m-0 h-full w-full cursor-pointer border border-transparent p-0 opacity-0 shadow-none outline-none"
checked={isCaseSensitive}
onChange={() => {
dispatch({
type: 'toggle-case-sensitive',
})
}}
/>
<span aria-hidden>Aa</span>
<span className="sr-only">Case sensitive</span>
</label>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-previous-result',
})
}}
disabled={results.length < 1}
title="Previous result (Shift + Enter)"
>
<ArrowUpIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast disabled:cursor-not-allowed"
onClick={() => {
dispatch({
type: 'go-to-next-result',
})
}}
disabled={results.length < 1}
title="Next result (Enter)"
>
<ArrowDownIcon className="h-4 w-4 fill-current text-text" />
</button>
<button
className="flex items-center rounded border border-border p-1.5 hover:bg-contrast"
onClick={() => {
closeDialog()
}}
title={`Close (${searchToggleShortcut})`}
>
<CloseIcon className="h-4 w-4 fill-current text-text" />
</button>
</div>
{isReplaceMode && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Replace"
onChange={(e) => {
setReplaceQuery(e.target.value)
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && replaceQuery && results.length) {
if (event.ctrlKey && event.altKey) {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
event.preventDefault()
return
}
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
event.preventDefault()
}
}}
className="rounded border border-border bg-default p-1 px-2"
ref={focusOnMount}
/>
<Button
small
onClick={() => {
dispatchReplaceEvent({
type: 'next',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace (Ctrl + Enter)"
>
Replace
</Button>
<Button
small
onClick={() => {
dispatchReplaceEvent({
type: 'all',
replace: replaceQuery,
})
}}
disabled={results.length < 1 || replaceQuery.length < 1}
title="Replace all (Ctrl + Alt + Enter)"
>
Replace all
</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,300 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getNearestNodeFromDOMNode, TextNode } from 'lexical'
import { useCallback, useEffect, useLayoutEffect, useMemo } from 'react'
import { createSearchHighlightElement } from './createSearchHighlightElement'
import { useSuperSearchContext } from './Context'
import { SearchDialog } from './SearchDialog'
import { getAllTextNodesInElement } from './getAllTextNodesInElement'
import { SuperSearchResult } from './Types'
import { debounce } from '@standardnotes/utils'
import { useApplication } from '@/Components/ApplicationProvider'
import {
SUPER_SEARCH_NEXT_RESULT,
SUPER_SEARCH_PREVIOUS_RESULT,
SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
SUPER_SEARCH_TOGGLE_REPLACE_MODE,
SUPER_TOGGLE_SEARCH,
} from '@standardnotes/ui-services'
import { useStateRef } from '@/Hooks/useStateRef'
export const SearchPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const { query, currentResultIndex, results, isCaseSensitive, isSearchActive, dispatch, addReplaceEventListener } =
useSuperSearchContext()
const queryRef = useStateRef(query)
const currentResultIndexRef = useStateRef(currentResultIndex)
const isCaseSensitiveRef = useStateRef(isCaseSensitive)
const resultsRef = useStateRef(results)
useEffect(() => {
const isFocusInEditor = () => {
if (!document.activeElement || !document.activeElement.closest('.blocks-editor')) {
return false
}
return true
}
return application.keyboardService.addCommandHandlers([
{
command: SUPER_TOGGLE_SEARCH,
onKeyDown: (event) => {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-search' })
editor.focus()
},
},
{
command: SUPER_SEARCH_TOGGLE_REPLACE_MODE,
onKeyDown: (event) => {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({ type: 'toggle-replace-mode' })
},
},
{
command: SUPER_SEARCH_TOGGLE_CASE_SENSITIVE,
onKeyDown() {
if (!isFocusInEditor()) {
return
}
dispatch({
type: 'toggle-case-sensitive',
})
},
},
{
command: SUPER_SEARCH_NEXT_RESULT,
onKeyDown(event) {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-next-result',
})
},
},
{
command: SUPER_SEARCH_PREVIOUS_RESULT,
onKeyDown(event) {
if (!isFocusInEditor()) {
return
}
event.preventDefault()
event.stopPropagation()
dispatch({
type: 'go-to-previous-result',
})
},
},
])
}, [application.keyboardService, dispatch, editor])
const handleSearch = useCallback(
(query: string, isCaseSensitive: boolean) => {
document.querySelectorAll('.search-highlight').forEach((element) => {
element.remove()
})
if (!query) {
dispatch({ type: 'clear-results' })
return
}
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
if (!rootElement) {
return
}
const textNodes = getAllTextNodesInElement(rootElement)
const results: SuperSearchResult[] = []
textNodes.forEach((node) => {
const text = node.textContent || ''
const indices: number[] = []
let index = -1
const textWithCase = isCaseSensitive ? text : text.toLowerCase()
const queryWithCase = isCaseSensitive ? query : query.toLowerCase()
while ((index = textWithCase.indexOf(queryWithCase, index + 1)) !== -1) {
indices.push(index)
}
indices.forEach((index) => {
const startIndex = index
const endIndex = startIndex + query.length
results.push({
node,
startIndex,
endIndex,
})
})
})
dispatch({
type: 'set-results',
results,
})
})
},
[dispatch, editor],
)
const handleQueryChange = useMemo(() => debounce(handleSearch, 250), [handleSearch])
const handleEditorChange = useMemo(() => debounce(handleSearch, 500), [handleSearch])
useEffect(() => {
if (!query) {
dispatch({ type: 'clear-results' })
dispatch({ type: 'set-current-result-index', index: -1 })
return
}
void handleQueryChange(query, isCaseSensitiveRef.current)
}, [dispatch, handleQueryChange, isCaseSensitiveRef, query])
useEffect(() => {
const handleCaseSensitiveChange = () => {
void handleSearch(queryRef.current, isCaseSensitive)
}
handleCaseSensitiveChange()
}, [handleSearch, isCaseSensitive, queryRef])
useLayoutEffect(() => {
return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves, prevEditorState, tags }) => {
if (
(dirtyElements.size === 0 && dirtyLeaves.size === 0) ||
tags.has('history-merge') ||
prevEditorState.isEmpty()
) {
return
}
void handleEditorChange(queryRef.current, isCaseSensitiveRef.current)
})
}, [editor, handleEditorChange, isCaseSensitiveRef, queryRef])
useEffect(() => {
return addReplaceEventListener((event) => {
const { replace, type } = event
const replaceResult = (result: SuperSearchResult, scrollIntoView = false) => {
const { node, startIndex, endIndex } = result
const lexicalNode = $getNearestNodeFromDOMNode(node)
if (!lexicalNode) {
return
}
if (lexicalNode instanceof TextNode) {
lexicalNode.spliceText(startIndex, endIndex - startIndex, replace, true)
}
if (scrollIntoView && node.parentElement) {
node.parentElement.scrollIntoView({
block: 'center',
})
}
}
editor.update(() => {
if (type === 'next') {
const result = resultsRef.current[currentResultIndexRef.current]
if (!result) {
return
}
replaceResult(result, true)
} else if (type === 'all') {
resultsRef.current.forEach((result) => replaceResult(result))
}
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
})
})
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
useEffect(() => {
document.querySelectorAll('.search-highlight').forEach((element) => {
element.remove()
})
if (currentResultIndex === -1) {
return
}
const result = results[currentResultIndex]
editor.getEditorState().read(() => {
const rootElement = editor.getRootElement()
const containerElement = rootElement?.parentElement?.getElementsByClassName('search-highlight-container')[0]
result.node.parentElement?.scrollIntoView({
block: 'center',
})
if (!rootElement || !containerElement) {
return
}
createSearchHighlightElement(result, rootElement, containerElement)
})
}, [currentResultIndex, editor, results])
useEffect(() => {
let containerElement: HTMLElement | null | undefined
let rootElement: HTMLElement | null | undefined
editor.getEditorState().read(() => {
rootElement = editor.getRootElement()
containerElement = rootElement?.parentElement?.querySelector('.search-highlight-container')
})
if (!rootElement || !containerElement) {
return
}
const resizeObserver = new ResizeObserver(() => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.height = `${rootElement.scrollHeight}px`
containerElement.style.overflow = 'visible'
})
resizeObserver.observe(rootElement)
const handleScroll = () => {
if (!rootElement || !containerElement) {
return
}
containerElement.style.top = `-${rootElement.scrollTop}px`
}
rootElement.addEventListener('scroll', handleScroll)
return () => {
resizeObserver.disconnect()
rootElement?.removeEventListener('scroll', handleScroll)
}
}, [editor])
return (
<>
<SearchDialog
open={isSearchActive}
closeDialog={() => {
dispatch({ type: 'toggle-search' })
dispatch({ type: 'reset-search' })
editor.focus()
}}
/>
</>
)
}

View File

@@ -1,31 +0,0 @@
export type SuperSearchResult = {
node: Text
startIndex: number
endIndex: number
}
export type SuperSearchContextState = {
query: string
results: SuperSearchResult[]
currentResultIndex: number
isCaseSensitive: boolean
isSearchActive: boolean
isReplaceMode: boolean
}
export type SuperSearchContextAction =
| { type: 'set-query'; query: string }
| { type: 'set-results'; results: SuperSearchResult[] }
| { type: 'clear-results' }
| { type: 'set-current-result-index'; index: number }
| { type: 'go-to-next-result' }
| { type: 'go-to-previous-result' }
| { type: 'toggle-case-sensitive' }
| { type: 'toggle-replace-mode' }
| { type: 'toggle-search' }
| { type: 'reset-search' }
export type SuperSearchReplaceEvent = {
type: 'next' | 'all'
replace: string
}

View File

@@ -1,40 +0,0 @@
import { SuperSearchResult } from './Types'
export const createSearchHighlightElement = (
result: SuperSearchResult,
rootElement: Element,
containerElement: Element,
) => {
const rootElementRect = rootElement.getBoundingClientRect()
const range = document.createRange()
range.setStart(result.node, result.startIndex)
range.setEnd(result.node, result.endIndex)
const rects = range.getClientRects()
Array.from(rects).forEach((rect, index) => {
const id = `search-${result.startIndex}-${result.endIndex}-${index}`
const existingHighlightElement = document.getElementById(id)
if (existingHighlightElement) {
return
}
const highlightElement = document.createElement('div')
highlightElement.style.position = 'absolute'
highlightElement.style.zIndex = '1000'
highlightElement.style.transform = `translate(${rect.left - rootElementRect.left}px, ${
rect.top - rootElementRect.top + rootElement.scrollTop
}px)`
highlightElement.style.width = `${rect.width}px`
highlightElement.style.height = `${rect.height}px`
highlightElement.style.backgroundColor = 'var(--sn-stylekit-info-color)'
highlightElement.style.opacity = '0.5'
highlightElement.className = 'search-highlight'
highlightElement.id = id
containerElement.appendChild(highlightElement)
})
}

View File

@@ -1,10 +0,0 @@
export const getAllTextNodesInElement = (element: HTMLElement) => {
const textNodes: Text[] = []
const walk = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null)
let node = walk.nextNode()
while (node) {
textNodes.push(node as Text)
node = walk.nextNode()
}
return textNodes
}

View File

@@ -1,219 +0,0 @@
import { WebApplication } from '@/Application/Application'
import {
ApplicationEvent,
classNames,
EditorFontSize,
EditorLineHeight,
isPayloadSourceRetrieved,
PrefKey,
} from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useRef, useState } 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'
import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { useCommandService } from '@/Components/CommandProvider'
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin'
import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin'
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin'
import { getPlaintextFontSize } from '@/Utils/getPlaintextFontSize'
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import MobileToolbarPlugin from './Plugins/MobileToolbarPlugin/MobileToolbarPlugin'
import { SuperEditorNodes } from './SuperEditorNodes'
import CodeOptionsPlugin from './Plugins/CodeOptionsPlugin/CodeOptions'
export const SuperNotePreviewCharLimit = 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 [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const getMarkdownPlugin = useRef<GetMarkdownPluginInterface | null>(null)
const commandService = useCommandService()
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_SHOW_MARKDOWN_PREVIEW,
onKeyDown: () => setShowMarkdownPreview(true),
})
}, [commandService])
const closeMarkdownPreview = useCallback(() => {
setShowMarkdownPreview(false)
}, [])
useEffect(() => {
return application.actions.addPayloadRequestHandler((uuid) => {
if (uuid === note.current.uuid) {
const basePayload = note.current.payload.ejected()
return {
...basePayload,
content: {
...basePayload.content,
text: getMarkdownPlugin.current?.getMarkdown() ?? basePayload.content.text,
},
}
}
})
}, [application])
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])
const [lineHeight, setLineHeight] = useState<EditorLineHeight>(() =>
application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight]),
)
const [fontSize, setFontSize] = useState<EditorFontSize | undefined>(() =>
application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize]),
)
const reloadPreferences = useCallback(() => {
const lineHeight = application.getPreference(PrefKey.EditorLineHeight, PrefDefaults[PrefKey.EditorLineHeight])
const fontSize = application.getPreference(PrefKey.EditorFontSize, PrefDefaults[PrefKey.EditorFontSize])
setLineHeight(lineHeight)
setFontSize(fontSize)
}, [application])
useEffect(() => {
reloadPreferences()
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
reloadPreferences()
})
}, [reloadPreferences, application])
return (
<div className="font-editor relative flex h-full w-full flex-col md:block">
<ErrorBoundary>
<LinkingControllerProvider controller={linkingController}>
<FilesControllerProvider controller={filesController}>
<BlocksEditorComposer
readonly={note.current.locked}
initialValue={note.current.text}
nodes={SuperEditorNodes}
>
<BlocksEditor
onChange={handleChange}
className={classNames(
'blocks-editor relative h-full resize-none px-4 py-4 focus:shadow-none focus:outline-none',
lineHeight && `leading-${lineHeight.toLowerCase()}`,
fontSize ? getPlaintextFontSize(fontSize) : 'text-base',
)}
previewLength={SuperNotePreviewCharLimit}
spellcheck={spellcheck}
>
<ItemSelectionPlugin currentNote={note.current} />
<FilePlugin />
<ItemBubblePlugin />
<BlockPickerMenuPlugin />
<GetMarkdownPlugin ref={getMarkdownPlugin} />
<DatetimePlugin />
<PasswordPlugin />
<AutoLinkPlugin />
<ChangeContentCallbackPlugin
providerCallback={(callback) => (changeEditorFunction.current = callback)}
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
<ReadonlyPlugin note={note.current} />
{controller.isTemplateNote ? <AutoFocusPlugin /> : null}
<SuperSearchContextProvider>
<SearchPlugin />
</SuperSearchContextProvider>
<MobileToolbarPlugin />
<CodeOptionsPlugin />
</BlocksEditor>
</BlocksEditorComposer>
</FilesControllerProvider>
</LinkingControllerProvider>
<ModalOverlay isOpen={showMarkdownPreview} onDismiss={closeMarkdownPreview}>
<SuperNoteMarkdownPreview note={note.current} closeDialog={closeMarkdownPreview} />
</ModalOverlay>
</ErrorBoundary>
</div>
)
}

View File

@@ -1,4 +0,0 @@
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
export const SuperEditorNodes = [FileNode, BubbleNode]

View File

@@ -1,46 +0,0 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { $generateHtmlFromNodes } from '@lexical/html'
import { BlockEditorNodes } from '@standardnotes/blocks-editor/src/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '@standardnotes/blocks-editor/src/Lexical/Theme/Theme'
import { SNNote } from '@standardnotes/models'
import { SuperEditorNodes } from './SuperEditorNodes'
export const exportSuperNote = (note: SNNote, format: 'txt' | 'md' | 'html' | 'json') => {
const headlessEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...SuperEditorNodes, ...BlockEditorNodes],
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(note.text))
let content: string | undefined
headlessEditor.update(() => {
switch (format) {
case 'md':
content = $convertToMarkdownString(MarkdownTransformers)
break
case 'html':
content = $generateHtmlFromNodes(headlessEditor)
break
case 'json':
content = JSON.stringify(headlessEditor.toJSON())
break
case 'txt':
default:
content = note.text
break
}
})
if (!content) {
throw new Error('Could not export note')
}
return content
}

View File

@@ -1,132 +0,0 @@
import { WebApplication } from '@/Application/Application'
import { NoteType, SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
import ImportPlugin from './Plugins/ImportPlugin/ImportPlugin'
import { NoteViewController } from '../Controller/NoteViewController'
import { spaceSeparatedStrings } from '@standardnotes/utils'
import Modal, { ModalAction } from '@/Components/Modal/Modal'
const NotePreviewCharLimit = 160
type Props = {
application: WebApplication
note: SNNote
closeDialog: () => void
onConvertComplete: () => void
}
export const SuperNoteImporter: FunctionComponent<Props> = ({ note, application, closeDialog, onConvertComplete }) => {
const isSeamlessConvert = note.text.length === 0
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 () => {
closeDialog()
await performConvert(lastValue.text, lastValue.previewPlain)
onConvertComplete()
}, [closeDialog, performConvert, onConvertComplete, lastValue])
useEffect(() => {
if (isSeamlessConvert) {
void confirmConvert()
}
}, [isSeamlessConvert, 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
}
closeDialog()
await performConvert(note.text, note.preview_plain)
onConvertComplete()
}, [closeDialog, application, note, onConvertComplete, performConvert])
const modalActions: ModalAction[] = useMemo(
() => [
{
label: 'Cancel',
onClick: closeDialog,
type: 'cancel',
mobileSlot: 'left',
},
{
label: 'Convert',
onClick: confirmConvert,
mobileSlot: 'right',
type: 'primary',
},
{
label: 'Convert As-Is',
onClick: convertAsIs,
type: 'secondary',
},
],
[closeDialog, confirmConvert, convertAsIs],
)
if (isSeamlessConvert) {
return null
}
return (
<Modal title="Convert to Super note" close={closeDialog} actions={modalActions}>
<div className="border-b border-border px-4 py-4 text-sm font-normal text-neutral md:py-3">
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.
</div>
<div className="relative w-full px-4 py-4">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={undefined}>
<BlocksEditor
readonly
onChange={handleChange}
ignoreFirstChange={false}
className="relative resize-none text-base focus:shadow-none focus:outline-none"
previewLength={NotePreviewCharLimit}
spellcheck={note.spellcheck}
>
<ImportPlugin text={note.text} format={format} onChange={handleChange} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</Modal>
)
}

View File

@@ -1,61 +0,0 @@
import { SNNote } from '@standardnotes/snjs'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import { BlocksEditor, BlocksEditorComposer } from '@standardnotes/blocks-editor'
import { ErrorBoundary } from '@/Utils/ErrorBoundary'
import MarkdownPreviewPlugin from './Plugins/MarkdownPreviewPlugin/MarkdownPreviewPlugin'
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
import { copyTextToClipboard } from '../../../Utils/copyTextToClipboard'
import Modal, { ModalAction } from '@/Components/Modal/Modal'
type Props = {
note: SNNote
closeDialog: () => void
}
export const SuperNoteMarkdownPreview: FunctionComponent<Props> = ({ note, closeDialog }) => {
const [markdown, setMarkdown] = useState('')
const [didCopy, setDidCopy] = useState(false)
const copy = useCallback(() => {
copyTextToClipboard(markdown)
setDidCopy(true)
setTimeout(() => {
setDidCopy(false)
}, 1500)
}, [markdown])
const onMarkdown = useCallback((markdown: string) => {
setMarkdown(markdown)
}, [])
const modalActions: ModalAction[] = useMemo(
() => [
{
label: didCopy ? 'Copied' : 'Copy',
type: 'primary',
onClick: copy,
mobileSlot: 'left',
},
],
[copy, didCopy],
)
return (
<Modal title="Markdown Preview" close={closeDialog} actions={modalActions}>
<div className="relative w-full px-4 py-4">
<ErrorBoundary>
<BlocksEditorComposer readonly initialValue={note.text} nodes={[FileNode, BubbleNode]}>
<BlocksEditor
readonly
className="relative resize-none text-base focus:shadow-none focus:outline-none"
spellcheck={note.spellcheck}
>
<MarkdownPreviewPlugin onMarkdown={onMarkdown} />
</BlocksEditor>
</BlocksEditorComposer>
</ErrorBoundary>
</div>
</Modal>
)
}