chore: upgrade lexical (#2889)

This commit is contained in:
Aman Harwara
2025-01-02 13:31:40 +05:30
committed by GitHub
parent 5c23a11b5a
commit 1456950a63
73 changed files with 700 additions and 1288 deletions

View File

@@ -1,10 +1,11 @@
import { $createParagraphNode, $getRoot, $insertNodes, $nodesOfType, LexicalNode } from 'lexical'
import { $createParagraphNode, $getRoot, $insertNodes, LexicalNode } from 'lexical'
import { $generateNodesFromDOM } from '@lexical/html'
import { createHeadlessEditor } from '@lexical/headless'
import { BlockEditorNodes } from '../SuperEditor/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '../SuperEditor/Lexical/Theme/Theme'
import { ClipPayload } from '@standardnotes/clipper/src/types/message'
import { LinkNode } from '@lexical/link'
import { $isLinkNode } from '@lexical/link'
import { $dfs } from '@lexical/utils'
const AbsoluteLinkRegExp = new RegExp('^(?:[a-z+]+:)?//', 'i')
@@ -66,15 +67,18 @@ export const getSuperJSONFromClipPayload = async (clipPayload: ClipPayload) => {
await new Promise<void>((resolve) => {
editor.update(() => {
$nodesOfType(LinkNode).forEach((linkNode) => {
const url = linkNode.getURL()
for (const { node } of $dfs()) {
if (!$isLinkNode(node)) {
continue
}
const url = node.getURL()
const isAbsoluteLink = AbsoluteLinkRegExp.test(url)
if (!isAbsoluteLink) {
const fixedURL = new URL(url, clipURL)
linkNode.setURL(fixedURL.toString())
node.setURL(fixedURL.toString())
}
})
}
resolve()
})

View File

@@ -1,6 +1,6 @@
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { parseAndCreateZippableFileName } from '@standardnotes/utils'
import { DOMExportOutput, Spread } from 'lexical'
import { DOMExportOutput, ElementFormatType, NodeKey, Spread } from 'lexical'
type SerializedFileExportNode = Spread<
{
@@ -20,14 +20,14 @@ export class FileExportNode extends DecoratorBlockNode {
return 'file-export'
}
constructor(name: string, mimeType: string) {
super()
constructor(name: string, mimeType: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__name = name
this.__mimeType = mimeType
}
static clone(node: FileExportNode): FileExportNode {
return new FileExportNode(node.__name, node.__mimeType)
return new FileExportNode(node.__name, node.__mimeType, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedFileExportNode): FileExportNode {

View File

@@ -127,6 +127,11 @@ export class TweetNode extends DecoratorBlockNode {
return 'tweet'
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
static override clone(node: TweetNode): TweetNode {
return new TweetNode(node.__id, node.__format, node.__key)
}
@@ -168,11 +173,6 @@ export class TweetNode extends DecoratorBlockNode {
return { element }
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
getId(): string {
return this.__id
}

View File

@@ -72,6 +72,11 @@ export class YouTubeNode extends DecoratorBlockNode {
return 'youtube'
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
static clone(node: YouTubeNode): YouTubeNode {
return new YouTubeNode(node.__id, node.__format, node.__key)
}
@@ -121,11 +126,6 @@ export class YouTubeNode extends DecoratorBlockNode {
}
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__id = id
}
updateDOM(): false {
return false
}

View File

@@ -7,12 +7,9 @@
*/
/**
* Taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownExport.ts
* but modified using changes from https://github.com/facebook/lexical/pull/4957 to make nested elements work
* better when exporting to markdown.
*/
* Taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownExport.ts but modified using changes from https://github.com/facebook/lexical/pull/4957 to make nested elements work better when exporting to markdown.
* */
import type { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown'
import {
ElementNode,
LexicalNode,
@@ -24,10 +21,27 @@ import {
$isLineBreakNode,
$isTextNode,
} from 'lexical'
import { TRANSFORMERS, transformersByType } from './MarkdownImportExportUtils'
export function createMarkdownExport(transformers: Array<Transformer>): (node?: ElementNode) => string {
import {
ElementTransformer,
MultilineElementTransformer,
TextFormatTransformer,
TextMatchTransformer,
Transformer,
} from '@lexical/markdown'
import { isEmptyParagraph, TRANSFORMERS, transformersByType } from './MarkdownImportExportUtils'
/**
* Renders string from markdown. The selection is moved to the start after the operation.
*/
function createMarkdownExport(
transformers: Array<Transformer>,
shouldPreserveNewLines: boolean = false,
): (node?: ElementNode) => string {
const byType = transformersByType(transformers)
const elementTransformers = [...byType.multilineElement, ...byType.element]
const isNewlineDelimited = !shouldPreserveNewLines
// Export only uses text formats that are responsible for single format
// e.g. it will filter out *** (bold, italic) and instead use separate ** and *
@@ -37,25 +51,35 @@ export function createMarkdownExport(transformers: Array<Transformer>): (node?:
const output = []
const children = (node || $getRoot()).getChildren()
for (const child of children) {
const result = exportTopLevelElements(child, byType.element, textFormatTransformers, byType.textMatch)
for (let i = 0; i < children.length; i++) {
const child = children[i]
const result = exportTopLevelElements(child, elementTransformers, textFormatTransformers, byType.textMatch)
if (result != null) {
output.push(result)
output.push(
// separate consecutive group of texts with a line break: eg. ["hello", "world"] -> ["hello", "/nworld"]
isNewlineDelimited && i > 0 && !isEmptyParagraph(child) && !isEmptyParagraph(children[i - 1])
? '\n'.concat(result)
: result,
)
}
}
return output.join('\n\n')
// Ensure consecutive groups of texts are at least \n\n apart while each empty paragraph render as a newline.
// Eg. ["hello", "", "", "hi", "\nworld"] -> "hello\n\n\nhi\n\nworld"
return output.join('\n')
}
}
function exportTopLevelElements(
node: LexicalNode,
elementTransformers: Array<ElementTransformer>,
elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
): string | null {
for (const transformer of elementTransformers) {
if (!transformer.export) {
continue
}
const result = transformer.export(node, (_node) =>
exportChildren(_node, elementTransformers, textTransformersIndex, textMatchTransformers),
)
@@ -76,23 +100,33 @@ function exportTopLevelElements(
function exportChildren(
node: ElementNode,
elementTransformers: Array<ElementTransformer>,
elementTransformers: Array<ElementTransformer | MultilineElementTransformer>,
textTransformersIndex: Array<TextFormatTransformer>,
textMatchTransformers: Array<TextMatchTransformer>,
): string {
const output = []
const children = node.getChildren()
const childrenLength = children.length
// keep track of unclosed tags from the very beginning
const unclosedTags: { format: TextFormatType; tag: string }[] = []
mainLoop: for (let childIndex = 0; childIndex < childrenLength; childIndex++) {
const child = children[childIndex]
const isLastChild = childIndex === childrenLength - 1
mainLoop: for (const child of children) {
if ($isElementNode(child)) {
for (const transformer of elementTransformers) {
if (!transformer.export) {
continue
}
const result = transformer.export(child, (_node) =>
exportChildren(_node, elementTransformers, textTransformersIndex, textMatchTransformers),
)
if (result != null) {
output.push(result)
if (children.indexOf(child) !== children.length - 1) {
if (!isLastChild) {
output.push('\n')
}
continue mainLoop
@@ -101,10 +135,14 @@ function exportChildren(
}
for (const transformer of textMatchTransformers) {
if (!transformer.export) {
continue
}
const result = transformer.export(
child,
(parentNode) => exportChildren(parentNode, elementTransformers, textTransformersIndex, textMatchTransformers),
(textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex),
(textNode, textContent) => exportTextFormat(textNode, textContent, textTransformersIndex, unclosedTags),
)
if (result != null) {
@@ -116,9 +154,15 @@ function exportChildren(
if ($isLineBreakNode(child)) {
output.push('\n')
} else if ($isTextNode(child)) {
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex))
output.push(exportTextFormat(child, child.getTextContent(), textTransformersIndex, unclosedTags))
} else if ($isElementNode(child)) {
output.push(exportChildren(child, elementTransformers, textTransformersIndex, textMatchTransformers), '\n')
// empty paragraph returns ""
output.push(exportChildren(child, elementTransformers, textTransformersIndex, textMatchTransformers))
// Don't insert linebreak after last child
if (!isLastChild) {
// Insert two line breaks to create a space between two paragraphs or other elements, as required by Markdown syntax.
output.push('\n', '\n')
}
} else if ($isDecoratorNode(child)) {
output.push(child.getTextContent())
}
@@ -127,13 +171,26 @@ function exportChildren(
return output.join('')
}
function exportTextFormat(node: TextNode, textContent: string, textTransformers: Array<TextFormatTransformer>): string {
function exportTextFormat(
node: TextNode,
textContent: string,
textTransformers: Array<TextFormatTransformer>,
// unclosed tags include the markdown tags that haven't been closed yet, and their associated formats
unclosedTags: Array<{ format: TextFormatType; tag: string }>,
): string {
// This function handles the case of a string looking like this: " foo "
// Where it would be invalid markdown to generate: "** foo **"
// We instead want to trim the whitespace out, apply formatting, and then
// bring the whitespace back. So our returned string looks like this: " **foo** "
const frozenString = textContent.trim()
let output = frozenString
// the opening tags to be added to the result
let openingTags = ''
// the closing tags to be added to the result
let closingTags = ''
const prevNode = getTextSibling(node, true)
const nextNode = getTextSibling(node, false)
const applied = new Set()
@@ -141,27 +198,39 @@ function exportTextFormat(node: TextNode, textContent: string, textTransformers:
const format = transformer.format[0]
const tag = transformer.tag
// dedup applied formats
if (hasFormat(node, format) && !applied.has(format)) {
// Multiple tags might be used for the same format (*, _)
applied.add(format)
// Prevent adding opening tag is already opened by the previous sibling
const previousNode = getTextSibling(node, true)
if (!hasFormat(previousNode, format)) {
output = tag + output
}
// Prevent adding closing tag if next sibling will do it
const nextNode = getTextSibling(node, false)
if (!hasFormat(nextNode, format)) {
output += tag
// append the tag to openningTags, if it's not applied to the previous nodes,
// or the nodes before that (which would result in an unclosed tag)
if (!hasFormat(prevNode, format) || !unclosedTags.find((element) => element.tag === tag)) {
unclosedTags.push({ format, tag })
openingTags += tag
}
}
}
// close any tags in the same order they were applied, if necessary
for (let i = 0; i < unclosedTags.length; i++) {
// prevent adding closing tag if next sibling will do it
if (hasFormat(nextNode, unclosedTags[i].format)) {
continue
}
while (unclosedTags.length > i) {
const unclosedTag = unclosedTags.pop()
if (unclosedTag && typeof unclosedTag.tag === 'string') {
closingTags += unclosedTag.tag
}
}
break
}
output = openingTags + output + closingTags
// Replace trimmed version of textContent ensuring surrounding whitespace is not modified
return textContent.replace(frozenString, output)
return textContent.replace(frozenString, () => output)
}
// Get next or previous text sibling a text node, including cases
@@ -208,7 +277,11 @@ function hasFormat(node: LexicalNode | null | undefined, format: TextFormatType)
return $isTextNode(node) && node.hasFormat(format)
}
export function $convertToMarkdownString(transformers: Array<Transformer> = TRANSFORMERS, node?: ElementNode): string {
const exportMarkdown = createMarkdownExport(transformers)
export function $convertToMarkdownString(
transformers: Array<Transformer> = TRANSFORMERS,
node?: ElementNode,
shouldPreserveNewLines: boolean = false,
): string {
const exportMarkdown = createMarkdownExport(transformers, shouldPreserveNewLines)
return exportMarkdown(node)
}

View File

@@ -1,368 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
/**
* Taken from https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownImport.ts
* but modified to allow keeping new lines when importing markdown.
*/
import { CodeNode, $createCodeNode } from '@lexical/code'
import { ElementTransformer, TextFormatTransformer, TextMatchTransformer, Transformer } from '@lexical/markdown'
import { $isListItemNode, $isListNode, ListItemNode } from '@lexical/list'
import { $isQuoteNode } from '@lexical/rich-text'
import { $findMatchingParent } from '@lexical/utils'
import {
LexicalNode,
TextNode,
$createLineBreakNode,
$createParagraphNode,
$createTextNode,
$getRoot,
$getSelection,
$isParagraphNode,
$isTextNode,
ElementNode,
} from 'lexical'
import { IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI } from '../Shared/environment'
import { TRANSFORMERS, transformersByType } from './MarkdownImportExportUtils'
const PUNCTUATION_OR_SPACE = /[!-/:-@[-`{-~\s]/
const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/
const CODE_BLOCK_REG_EXP = /^```(\w{1,10})?\s?$/
type TextFormatTransformersIndex = Readonly<{
fullMatchRegExpByTag: Readonly<Record<string, RegExp>>
openTagsRegExp: RegExp
transformersByTag: Readonly<Record<string, TextFormatTransformer>>
}>
function createMarkdownImport(
transformers: Array<Transformer>,
): (markdownString: string, node?: ElementNode, keepNewLines?: boolean) => void {
const byType = transformersByType(transformers)
const textFormatTransformersIndex = createTextFormatTransformersIndex(byType.textFormat)
return (markdownString, node, keepNewLines = false) => {
const lines = markdownString.split('\n')
const linesLength = lines.length
const root = node || $getRoot()
root.clear()
for (let i = 0; i < linesLength; i++) {
const lineText = lines[i]
// Codeblocks are processed first as anything inside such block
// is ignored for further processing
// TODO:
// Abstract it to be dynamic as other transformers (add multiline match option)
const [codeBlockNode, shiftedIndex] = importCodeBlock(lines, i, root)
if (codeBlockNode != null) {
i = shiftedIndex
continue
}
importBlocks(lineText, root, byType.element, textFormatTransformersIndex, byType.textMatch)
}
if (!keepNewLines) {
// Removing empty paragraphs as md does not really
// allow empty lines and uses them as dilimiter
const children = root.getChildren()
for (const child of children) {
if (isEmptyParagraph(child)) {
child.remove()
}
}
}
if ($getSelection() !== null) {
root.selectEnd()
}
}
}
function isEmptyParagraph(node: LexicalNode): boolean {
if (!$isParagraphNode(node)) {
return false
}
const firstChild = node.getFirstChild()
return (
firstChild == null ||
(node.getChildrenSize() === 1 &&
$isTextNode(firstChild) &&
MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent()))
)
}
function importBlocks(
lineText: string,
rootNode: ElementNode,
elementTransformers: Array<ElementTransformer>,
textFormatTransformersIndex: TextFormatTransformersIndex,
textMatchTransformers: Array<TextMatchTransformer>,
) {
const lineTextTrimmed = lineText.trim()
const textNode = $createTextNode(lineTextTrimmed)
const elementNode = $createParagraphNode()
elementNode.append(textNode)
rootNode.append(elementNode)
for (const { regExp, replace } of elementTransformers) {
const match = lineText.match(regExp)
if (match) {
textNode.setTextContent(lineText.slice(match[0].length))
replace(elementNode, [textNode], match, true)
break
}
}
importTextFormatTransformers(textNode, textFormatTransformersIndex, textMatchTransformers)
// If no transformer found and we left with original paragraph node
// can check if its content can be appended to the previous node
// if it's a paragraph, quote or list
if (elementNode.isAttached() && lineTextTrimmed.length > 0) {
const previousNode = elementNode.getPreviousSibling()
if ($isParagraphNode(previousNode) || $isQuoteNode(previousNode) || $isListNode(previousNode)) {
let targetNode: typeof previousNode | ListItemNode | null = previousNode
if ($isListNode(previousNode)) {
const lastDescendant = previousNode.getLastDescendant()
if (lastDescendant == null) {
targetNode = null
} else {
targetNode = $findMatchingParent(lastDescendant, $isListItemNode)
}
}
if (targetNode != null && targetNode.getTextContentSize() > 0) {
targetNode.splice(targetNode.getChildrenSize(), 0, [$createLineBreakNode(), ...elementNode.getChildren()])
elementNode.remove()
}
}
}
}
function importCodeBlock(
lines: Array<string>,
startLineIndex: number,
rootNode: ElementNode,
): [CodeNode | null, number] {
const openMatch = lines[startLineIndex].match(CODE_BLOCK_REG_EXP)
if (openMatch) {
let endLineIndex = startLineIndex
const linesLength = lines.length
while (++endLineIndex < linesLength) {
const closeMatch = lines[endLineIndex].match(CODE_BLOCK_REG_EXP)
if (closeMatch) {
const codeBlockNode = $createCodeNode(openMatch[1])
const textNode = $createTextNode(lines.slice(startLineIndex + 1, endLineIndex).join('\n'))
codeBlockNode.append(textNode)
rootNode.append(codeBlockNode)
return [codeBlockNode, endLineIndex]
}
}
}
return [null, startLineIndex]
}
// Processing text content and replaces text format tags.
// It takes outermost tag match and its content, creates text node with
// format based on tag and then recursively executed over node's content
//
// E.g. for "*Hello **world**!*" string it will create text node with
// "Hello **world**!" content and italic format and run recursively over
// its content to transform "**world**" part
function importTextFormatTransformers(
textNode: TextNode,
textFormatTransformersIndex: TextFormatTransformersIndex,
textMatchTransformers: Array<TextMatchTransformer>,
) {
const textContent = textNode.getTextContent()
const match = findOutermostMatch(textContent, textFormatTransformersIndex)
if (!match) {
// Once text format processing is done run text match transformers, as it
// only can span within single text node (unline formats that can cover multiple nodes)
importTextMatchTransformers(textNode, textMatchTransformers)
return
}
let currentNode, remainderNode, leadingNode
// If matching full content there's no need to run splitText and can reuse existing textNode
// to update its content and apply format. E.g. for **_Hello_** string after applying bold
// format (**) it will reuse the same text node to apply italic (_)
if (match[0] === textContent) {
currentNode = textNode
} else {
const startIndex = match.index || 0
const endIndex = startIndex + match[0].length
if (startIndex === 0) {
;[currentNode, remainderNode] = textNode.splitText(endIndex)
} else {
;[leadingNode, currentNode, remainderNode] = textNode.splitText(startIndex, endIndex)
}
}
currentNode.setTextContent(match[2])
const transformer = textFormatTransformersIndex.transformersByTag[match[1]]
if (transformer) {
for (const format of transformer.format) {
if (!currentNode.hasFormat(format)) {
currentNode.toggleFormat(format)
}
}
}
// Recursively run over inner text if it's not inline code
if (!currentNode.hasFormat('code')) {
importTextFormatTransformers(currentNode, textFormatTransformersIndex, textMatchTransformers)
}
// Run over leading/remaining text if any
if (leadingNode) {
importTextFormatTransformers(leadingNode, textFormatTransformersIndex, textMatchTransformers)
}
if (remainderNode) {
importTextFormatTransformers(remainderNode, textFormatTransformersIndex, textMatchTransformers)
}
}
function importTextMatchTransformers(textNode_: TextNode, textMatchTransformers: Array<TextMatchTransformer>) {
let textNode = textNode_
mainLoop: while (textNode) {
for (const transformer of textMatchTransformers) {
const match = textNode.getTextContent().match(transformer.importRegExp)
if (!match) {
continue
}
const startIndex = match.index || 0
const endIndex = startIndex + match[0].length
let replaceNode, newTextNode
if (startIndex === 0) {
;[replaceNode, textNode] = textNode.splitText(endIndex)
} else {
;[, replaceNode, newTextNode] = textNode.splitText(startIndex, endIndex)
}
if (newTextNode) {
importTextMatchTransformers(newTextNode, textMatchTransformers)
}
transformer.replace(replaceNode, match)
continue mainLoop
}
break
}
}
// Finds first "<tag>content<tag>" match that is not nested into another tag
function findOutermostMatch(
textContent: string,
textTransformersIndex: TextFormatTransformersIndex,
): RegExpMatchArray | null {
const openTagsMatch = textContent.match(textTransformersIndex.openTagsRegExp)
if (openTagsMatch == null) {
return null
}
for (const match of openTagsMatch) {
// Open tags reg exp might capture leading space so removing it
// before using match to find transformer
const tag = match.replace(/^\s/, '')
const fullMatchRegExp = textTransformersIndex.fullMatchRegExpByTag[tag]
if (fullMatchRegExp == null) {
continue
}
const fullMatch = textContent.match(fullMatchRegExp)
const transformer = textTransformersIndex.transformersByTag[tag]
if (fullMatch != null && transformer != null) {
if (transformer.intraword !== false) {
return fullMatch
}
// For non-intraword transformers checking if it's within a word
// or surrounded with space/punctuation/newline
const { index = 0 } = fullMatch
const beforeChar = textContent[index - 1]
const afterChar = textContent[index + fullMatch[0].length]
if (
(!beforeChar || PUNCTUATION_OR_SPACE.test(beforeChar)) &&
(!afterChar || PUNCTUATION_OR_SPACE.test(afterChar))
) {
return fullMatch
}
}
}
return null
}
function createTextFormatTransformersIndex(
textTransformers: Array<TextFormatTransformer>,
): TextFormatTransformersIndex {
const transformersByTag: Record<string, TextFormatTransformer> = {}
const fullMatchRegExpByTag: Record<string, RegExp> = {}
const openTagsRegExp = []
const escapeRegExp = '(?<![\\\\])'
for (const transformer of textTransformers) {
const { tag } = transformer
transformersByTag[tag] = transformer
const tagRegExp = tag.replace(/(\*|\^|\+)/g, '\\$1')
openTagsRegExp.push(tagRegExp)
if (IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT) {
fullMatchRegExpByTag[tag] = new RegExp(
`(${tagRegExp})(?![${tagRegExp}\\s])(.*?[^${tagRegExp}\\s])${tagRegExp}(?!${tagRegExp})`,
)
} else {
fullMatchRegExpByTag[tag] = new RegExp(
`(?<![\\\\${tagRegExp}])(${tagRegExp})((\\\\${tagRegExp})?.*?[^${tagRegExp}\\s](\\\\${tagRegExp})?)((?<!\\\\)|(?<=\\\\\\\\))(${tagRegExp})(?![\\\\${tagRegExp}])`,
)
}
}
return {
// Reg exp to find open tag + content + close tag
fullMatchRegExpByTag,
// Reg exp to find opening tags
openTagsRegExp: new RegExp(
(IS_SAFARI || IS_IOS || IS_APPLE_WEBKIT ? '' : `${escapeRegExp}`) + '(' + openTagsRegExp.join('|') + ')',
'g',
),
transformersByTag,
}
}
export function $convertFromMarkdownString(
markdown: string,
transformers: Array<Transformer> = TRANSFORMERS,
node?: ElementNode,
keepNewLines = false,
): void {
const importMarkdown = createMarkdownImport(transformers)
return importMarkdown(markdown, node, keepNewLines)
}

View File

@@ -6,7 +6,10 @@ import {
ELEMENT_TRANSFORMERS,
TEXT_FORMAT_TRANSFORMERS,
TEXT_MATCH_TRANSFORMERS,
MultilineElementTransformer,
MULTILINE_ELEMENT_TRANSFORMERS,
} from '@lexical/markdown'
import { LexicalNode, $isParagraphNode, $isTextNode } from 'lexical'
function indexBy<T>(list: Array<T>, callback: (arg0: T) => string): Readonly<Record<string, Array<T>>> {
const index: Record<string, Array<T>> = {}
@@ -26,6 +29,7 @@ function indexBy<T>(list: Array<T>, callback: (arg0: T) => string): Readonly<Rec
export function transformersByType(transformers: Array<Transformer>): Readonly<{
element: Array<ElementTransformer>
multilineElement: Array<MultilineElementTransformer>
textFormat: Array<TextFormatTransformer>
textMatch: Array<TextMatchTransformer>
}> {
@@ -33,13 +37,31 @@ export function transformersByType(transformers: Array<Transformer>): Readonly<{
return {
element: (byType.element || []) as Array<ElementTransformer>,
multilineElement: (byType['multiline-element'] || []) as Array<MultilineElementTransformer>,
textFormat: (byType['text-format'] || []) as Array<TextFormatTransformer>,
textMatch: (byType['text-match'] || []) as Array<TextMatchTransformer>,
}
}
const MARKDOWN_EMPTY_LINE_REG_EXP = /^\s{0,3}$/
export function isEmptyParagraph(node: LexicalNode): boolean {
if (!$isParagraphNode(node)) {
return false
}
const firstChild = node.getFirstChild()
return (
firstChild == null ||
(node.getChildrenSize() === 1 &&
$isTextNode(firstChild) &&
MARKDOWN_EMPTY_LINE_REG_EXP.test(firstChild.getTextContent()))
)
}
export const TRANSFORMERS: Array<Transformer> = [
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
]

View File

@@ -7,6 +7,7 @@ import {
TextMatchTransformer,
$convertToMarkdownString,
$convertFromMarkdownString,
MULTILINE_ELEMENT_TRANSFORMERS,
} from '@lexical/markdown'
import {
$createTableCellNode,
@@ -247,6 +248,7 @@ export const MarkdownTransformers = [
IMAGE,
INLINE_FILE,
...ELEMENT_TRANSFORMERS,
...MULTILINE_ELEMENT_TRANSFORMERS,
...TEXT_FORMAT_TRANSFORMERS,
...TEXT_MATCH_TRANSFORMERS,
HorizontalRule,

View File

@@ -44,14 +44,14 @@ export class CollapsibleContainerNode extends ElementNode {
this.__open = open ?? false
}
static override getType(): string {
return 'collapsible-container'
}
static override clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
return new CollapsibleContainerNode(node.__open, node.__key)
}
static override getType(): string {
return 'collapsible-container'
}
override createDOM(_: EditorConfig, editor: LexicalEditor): HTMLElement {
const dom = document.createElement('details')
dom.classList.add('Collapsible__container')

View File

@@ -13,6 +13,12 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
return 'snfile'
}
constructor(id: string, format?: ElementFormatType, key?: NodeKey, zoomLevel?: number) {
super(format, key)
this.__id = id
this.__zoomLevel = zoomLevel || 100
}
static clone(node: FileNode): FileNode {
return new FileNode(node.__id, node.__format, node.__key, node.__zoomLevel)
}
@@ -56,12 +62,6 @@ export class FileNode extends DecoratorBlockNode implements ItemNodeInterface {
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
}

View File

@@ -5,7 +5,7 @@ import { handleEditorChange } from '../../Utils'
import { SuperNotePreviewCharLimit } from '../../SuperEditor'
import { $generateNodesFromDOM } from '@lexical/html'
import { MarkdownTransformers } from '../../MarkdownTransformers'
import { $convertFromMarkdownString } from '../../Lexical/Utils/MarkdownImport'
import { $convertFromMarkdownString } from '@lexical/markdown'
/** Note that markdown conversion does not insert new lines. See: https://github.com/facebook/lexical/issues/2815 */
export default function ImportPlugin({

View File

@@ -1,5 +1,14 @@
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { DOMConversionMap, DOMExportOutput, EditorConfig, LexicalEditor, LexicalNode, Spread } from 'lexical'
import {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical'
import InlineFileComponent from './InlineFileComponent'
type SerializedInlineFileNode = Spread<
@@ -22,15 +31,15 @@ export class InlineFileNode extends DecoratorBlockNode {
return 'inline-file'
}
constructor(src: string, mimeType: string, fileName: string | undefined) {
super()
constructor(src: string, mimeType: string, fileName: string | undefined, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__src = src
this.__mimeType = mimeType
this.__fileName = fileName
}
static clone(node: InlineFileNode): InlineFileNode {
return new InlineFileNode(node.__src, node.__mimeType, node.__fileName)
return new InlineFileNode(node.__src, node.__mimeType, node.__fileName, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedInlineFileNode): InlineFileNode {

View File

@@ -1,9 +1,10 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
import { $createCodeNode } from '@lexical/code'
import { $createTextNode, $getRoot, $nodesOfType, ParagraphNode } from 'lexical'
import { $convertToMarkdownString } from '@lexical/markdown'
import { $createTextNode, $getRoot, $isParagraphNode } from 'lexical'
import { MarkdownTransformers } from '../../MarkdownTransformers'
import { $dfs } from '@lexical/utils'
import { $convertToMarkdownString } from '../../Lexical/Utils/MarkdownExport'
type Props = {
onMarkdown: (markdown: string) => void
@@ -15,10 +16,12 @@ export default function MarkdownPreviewPlugin({ onMarkdown }: Props): JSX.Elemen
useEffect(() => {
editor.update(() => {
const root = $getRoot()
const paragraphs = $nodesOfType(ParagraphNode)
for (const paragraph of paragraphs) {
if (paragraph.isEmpty()) {
paragraph.remove()
for (const { node } of $dfs()) {
if (!$isParagraphNode(node)) {
continue
}
if (node.isEmpty()) {
node.remove()
}
}
const markdown = $convertToMarkdownString(MarkdownTransformers)

View File

@@ -1,5 +1,14 @@
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { DOMConversionMap, DOMExportOutput, EditorConfig, LexicalEditor, LexicalNode, Spread } from 'lexical'
import {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical'
import RemoteImageComponent from './RemoteImageComponent'
type SerializedRemoteImageNode = Spread<
@@ -20,14 +29,14 @@ export class RemoteImageNode extends DecoratorBlockNode {
return 'unencrypted-image'
}
constructor(src: string, alt?: string) {
super()
constructor(src: string, alt?: string, format?: ElementFormatType, key?: NodeKey) {
super(format, key)
this.__src = src
this.__alt = alt
}
static clone(node: RemoteImageNode): RemoteImageNode {
return new RemoteImageNode(node.__src, node.__alt)
return new RemoteImageNode(node.__src, node.__alt, node.__format, node.__key)
}
static importJSON(serializedNode: SerializedRemoteImageNode): RemoteImageNode {

View File

@@ -89,9 +89,10 @@ export const SearchPlugin = () => {
const handleSearch = useCallback(
(query: string, isCaseSensitive: boolean) => {
document.querySelectorAll('.search-highlight').forEach((element) => {
const currentHighlights = document.querySelectorAll('.search-highlight')
for (const element of currentHighlights) {
element.remove()
})
}
if (!query) {
dispatch({ type: 'clear-results' })
@@ -109,7 +110,7 @@ export const SearchPlugin = () => {
const results: SuperSearchResult[] = []
textNodes.forEach((node) => {
for (const node of textNodes) {
const text = node.textContent || ''
const indices: number[] = []
@@ -122,7 +123,7 @@ export const SearchPlugin = () => {
indices.push(index)
}
indices.forEach((index) => {
for (const index of indices) {
const startIndex = index
const endIndex = startIndex + query.length
@@ -131,8 +132,8 @@ export const SearchPlugin = () => {
startIndex,
endIndex,
})
})
})
}
}
dispatch({
type: 'set-results',
@@ -205,7 +206,10 @@ export const SearchPlugin = () => {
}
replaceResult(result, true)
} else if (type === 'all') {
resultsRef.current.forEach((result) => replaceResult(result))
const results = resultsRef.current
for (const result of results) {
replaceResult(result)
}
}
void handleSearch(queryRef.current, isCaseSensitiveRef.current)
@@ -214,9 +218,10 @@ export const SearchPlugin = () => {
}, [addReplaceEventListener, currentResultIndexRef, editor, handleSearch, isCaseSensitiveRef, queryRef, resultsRef])
useEffect(() => {
document.querySelectorAll('.search-highlight').forEach((element) => {
const currentHighlights = document.querySelectorAll('.search-highlight')
for (const element of currentHighlights) {
element.remove()
})
}
if (currentResultIndex === -1) {
return
}

View File

@@ -190,7 +190,7 @@ function TableActionMenu({ onClose, tableCellNode: _tableCellNode, cellMerge }:
const tableSelection = getTableObserverFromTableElement(tableElement)
if (tableSelection !== null) {
tableSelection.clearHighlight()
tableSelection.$clearHighlight()
}
tableNode.markDirty()

View File

@@ -1,24 +1,17 @@
import { createHeadlessEditor } from '@lexical/headless'
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
import {
$createParagraphNode,
$getRoot,
$insertNodes,
$nodesOfType,
LexicalEditor,
LexicalNode,
ParagraphNode,
} from 'lexical'
import { $createParagraphNode, $getRoot, $insertNodes, $isParagraphNode, LexicalEditor, LexicalNode } from 'lexical'
import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes, SuperExportNodes } from '../Lexical/Nodes/AllNodes'
import { MarkdownTransformers } from '../MarkdownTransformers'
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
import { $convertFromMarkdownString } from '../Lexical/Utils/MarkdownImport'
import { $convertFromMarkdownString } from '@lexical/markdown'
import { $convertToMarkdownString } from '../Lexical/Utils/MarkdownExport'
import { parseFileName } from '@standardnotes/utils'
import { $dfs } from '@lexical/utils'
import { $isFileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileUtils'
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private importEditor: LexicalEditor
@@ -80,103 +73,103 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
let content: string | undefined
await new Promise<void>((resolve) => {
this.exportEditor.update(
() => {
if (embedBehavior === 'reference') {
resolve()
return
}
if (!getFileItem) {
resolve()
return
}
const fileNodes = $nodesOfType(FileNode)
const filenameCounts: Record<string, number> = {}
Promise.all(
fileNodes.map(async (fileNode) => {
const fileItem = getFileItem(fileNode.getId())
if (!fileItem) {
const handleFileNodes = () => {
if (embedBehavior === 'reference') {
resolve()
return
}
if (!getFileItem) {
resolve()
return
}
const filenameCounts: Record<string, number> = {}
Promise.all(
$dfs().map(async ({ node: fileNode }) => {
if (!$isFileNode(fileNode)) {
return
}
const fileItem = getFileItem(fileNode.getId())
if (!fileItem) {
return
}
const canInlineFileType = toFormat === 'pdf' ? fileItem.mimeType.startsWith('image/') : true
if (embedBehavior === 'inline' && getFileBase64 && canInlineFileType) {
const fileBase64 = await getFileBase64(fileNode.getId())
if (!fileBase64) {
return
}
const canInlineFileType = toFormat === 'pdf' ? fileItem.mimeType.startsWith('image/') : true
if (embedBehavior === 'inline' && getFileBase64 && canInlineFileType) {
const fileBase64 = await getFileBase64(fileNode.getId())
if (!fileBase64) {
return
}
this.exportEditor.update(
() => {
const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name)
fileNode.replace(inlineFileNode)
},
{ discrete: true },
)
} else {
this.exportEditor.update(
() => {
filenameCounts[fileItem.name] =
filenameCounts[fileItem.name] == undefined ? 0 : filenameCounts[fileItem.name] + 1
this.exportEditor.update(
() => {
const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name)
fileNode.replace(inlineFileNode)
},
{ discrete: true },
)
} else {
this.exportEditor.update(
() => {
filenameCounts[fileItem.name] =
filenameCounts[fileItem.name] == undefined ? 0 : filenameCounts[fileItem.name] + 1
let name = fileItem.name
let name = fileItem.name
if (filenameCounts[name] > 0) {
const { name: _name, ext } = parseFileName(name)
name = `${_name}-${fileItem.uuid}.${ext}`
}
if (filenameCounts[name] > 0) {
const { name: _name, ext } = parseFileName(name)
name = `${_name}-${fileItem.uuid}.${ext}`
}
const fileExportNode = $createFileExportNode(name, fileItem.mimeType)
fileNode.replace(fileExportNode)
},
{ discrete: true },
)
}
}),
)
.then(() => resolve())
.catch(console.error)
},
{ discrete: true },
)
const fileExportNode = $createFileExportNode(name, fileItem.mimeType)
fileNode.replace(fileExportNode)
},
{ discrete: true },
)
}
}),
)
.then(() => resolve())
.catch(console.error)
}
this.exportEditor.update(handleFileNodes, { discrete: true })
})
await new Promise<void>((resolve) => {
this.exportEditor.update(
() => {
switch (toFormat) {
case 'txt':
case 'md': {
const paragraphs = $nodesOfType(ParagraphNode)
for (const paragraph of paragraphs) {
if (paragraph.isEmpty()) {
paragraph.remove()
}
const convertToFormat = () => {
switch (toFormat) {
case 'txt':
case 'md': {
for (const { node: paragraph } of $dfs()) {
if (!$isParagraphNode(paragraph)) {
continue
}
if (paragraph.isEmpty()) {
paragraph.remove()
}
content = $convertToMarkdownString(MarkdownTransformers)
resolve()
break
}
case 'html':
content = $generateHtmlFromNodes(this.exportEditor)
resolve()
break
case 'pdf': {
void import('../Lexical/Utils/PDFExport/PDFExport').then(({ $generatePDFFromNodes }): void => {
void $generatePDFFromNodes(this.exportEditor, config?.pdf?.pageSize || 'A4').then((pdf) => {
content = pdf
resolve()
})
})
break
}
case 'json':
default:
content = superString
resolve()
break
content = $convertToMarkdownString(MarkdownTransformers)
resolve()
break
}
},
{ discrete: true },
)
case 'html':
content = $generateHtmlFromNodes(this.exportEditor)
resolve()
break
case 'pdf': {
void import('../Lexical/Utils/PDFExport/PDFExport').then(({ $generatePDFFromNodes }): void => {
void $generatePDFFromNodes(this.exportEditor, config?.pdf?.pageSize || 'A4').then((pdf) => {
content = pdf
resolve()
})
})
break
}
case 'json':
default:
content = superString
resolve()
break
}
}
this.exportEditor.update(convertToFormat, { discrete: true })
})
if (typeof content !== 'string') {
@@ -288,14 +281,16 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
const ids: string[] = []
this.exportEditor.getEditorState().read(() => {
const fileNodes = $nodesOfType(FileNode)
fileNodes.forEach((fileNode) => {
for (const { node: fileNode } of $dfs()) {
if (!$isFileNode(fileNode)) {
continue
}
const nodeId = fileNode.getId()
if (ids.includes(nodeId)) {
return
continue
}
ids.push(nodeId)
})
}
})
return ids