From 7af540365f08f57c4506bb1dd5fba52b5e51e5f6 Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Thu, 21 Nov 2024 10:43:23 -0800 Subject: [PATCH] CodeMirror implementation of GraphNodeComment (#11585) --- app/gui/package.json | 4 - .../dashboard/layouts/AssetDocs/AssetDocs.tsx | 3 +- .../GraphEditor/GraphNodeComment.vue | 27 +- .../GraphEditor/__tests__/clipboard.test.ts | 5 +- .../components/GraphEditor/clipboard.ts | 4 +- .../MarkdownEditor/__tests__/markdown.test.ts | 22 + .../components/PlainTextEditor.vue | 56 +-- .../PlainTextEditor/PlainTextEditorImpl.vue | 45 ++ .../___tests__/urlLinks.test.ts | 73 +++ .../components/PlainTextEditor/linkifyUrls.ts | 75 +++ .../components/lexical/LexicalContent.vue | 19 - .../components/lexical/LexicalDecorators.vue | 26 - .../LinkPlugin/__tests__/LinkPlugin.test.ts | 33 -- .../lexical/LinkPlugin/autoMatcher.ts | 465 ------------------ .../components/lexical/LinkPlugin/index.ts | 78 --- .../components/lexical/LinkToolbar.vue | 35 -- .../project-view/components/lexical/index.ts | 73 --- .../project-view/components/lexical/sync.ts | 71 --- .../composables/astDocumentation.ts | 32 -- .../project-view/util/__tests__/link.test.ts | 47 ++ .../util/ast/__tests__/node.test.ts | 26 +- app/gui/src/project-view/util/ast/node.ts | 13 + app/gui/src/project-view/util/link.ts | 10 + .../src/ast/__tests__/documentation.test.ts | 192 ++++---- app/ydoc-shared/src/ast/documentation.ts | 107 ++-- app/ydoc-shared/src/ast/tree.ts | 150 ++---- pnpm-lock.yaml | 88 ---- 27 files changed, 561 insertions(+), 1218 deletions(-) create mode 100644 app/gui/src/project-view/components/PlainTextEditor/PlainTextEditorImpl.vue create mode 100644 app/gui/src/project-view/components/PlainTextEditor/___tests__/urlLinks.test.ts create mode 100644 app/gui/src/project-view/components/PlainTextEditor/linkifyUrls.ts delete mode 100644 app/gui/src/project-view/components/lexical/LexicalContent.vue delete mode 100644 app/gui/src/project-view/components/lexical/LexicalDecorators.vue delete mode 100644 app/gui/src/project-view/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts delete mode 100644 app/gui/src/project-view/components/lexical/LinkPlugin/autoMatcher.ts delete mode 100644 app/gui/src/project-view/components/lexical/LinkPlugin/index.ts delete mode 100644 app/gui/src/project-view/components/lexical/LinkToolbar.vue delete mode 100644 app/gui/src/project-view/components/lexical/index.ts delete mode 100644 app/gui/src/project-view/components/lexical/sync.ts delete mode 100644 app/gui/src/project-view/composables/astDocumentation.ts create mode 100644 app/gui/src/project-view/util/__tests__/link.test.ts create mode 100644 app/gui/src/project-view/util/link.ts diff --git a/app/gui/package.json b/app/gui/package.json index c644db2ff8bc..97558adf9e03 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -95,9 +95,6 @@ "@codemirror/view": "^6.28.3", "@fast-check/vitest": "^0.0.8", "@floating-ui/vue": "^1.0.6", - "@lexical/link": "^0.16.0", - "@lexical/plain-text": "^0.16.0", - "@lexical/utils": "^0.16.0", "@lezer/common": "^1.1.0", "@lezer/highlight": "^1.1.6", "@noble/hashes": "^1.4.0", @@ -112,7 +109,6 @@ "hash-sum": "^2.0.0", "install": "^0.13.0", "isomorphic-ws": "^5.0.0", - "lexical": "^0.16.0", "lib0": "^0.2.85", "magic-string": "^0.30.3", "murmurhash": "^2.0.1", diff --git a/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx b/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx index 83bc47d82b23..5584983f4f6f 100644 --- a/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx +++ b/app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx @@ -9,6 +9,7 @@ import { useStore } from '#/utilities/zustand' import { useSuspenseQuery } from '@tanstack/react-query' import { useCallback } from 'react' import * as ast from 'ydoc-shared/ast' +import { normalizedMarkdownToStandard } from 'ydoc-shared/ast/documentation' import { splitFileContents } from 'ydoc-shared/ensoFile' import { versionContentQueryOptions } from '../AssetDiffView/useFetchVersionContent' import { assetPanelStore } from '../AssetPanel' @@ -53,7 +54,7 @@ export function AssetDocsContent(props: AssetDocsContentProps) { for (const statement of module.statements()) { if (statement instanceof ast.MutableFunctionDef && statement.name.code() === 'main') { - return statement.documentationText() ?? '' + return normalizedMarkdownToStandard(statement.mutableDocumentationMarkdown().toJSON()) } } diff --git a/app/gui/src/project-view/components/GraphEditor/GraphNodeComment.vue b/app/gui/src/project-view/components/GraphEditor/GraphNodeComment.vue index 2c3351ba673b..62d31d6810e5 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphNodeComment.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphNodeComment.vue @@ -1,8 +1,8 @@ diff --git a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts index 9323a73c2e15..691513ae7dd9 100644 --- a/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/__tests__/clipboard.test.ts @@ -7,7 +7,7 @@ import { } from '@/components/GraphEditor/clipboard' import { type Node } from '@/stores/graph' import { Ast } from '@/util/ast' -import { nodeFromAst } from '@/util/ast/node' +import { nodeDocumentationText, nodeFromAst } from '@/util/ast/node' import { Blob } from 'node:buffer' import { expect, test } from 'vitest' import { assertDefined } from 'ydoc-shared/util/assert' @@ -82,8 +82,7 @@ test.each([...testNodes.map((node) => [node]), testNodes])( const clipboardItem = clipboardItemFromTypes(nodesToClipboardData(sourceNodes)) const pastedNodes = await nodesFromClipboardContent([clipboardItem]) sourceNodes.forEach((sourceNode, i) => { - const documentation = - sourceNode.outerAst.isStatement() ? sourceNode.outerAst.documentationText() : undefined + const documentation = nodeDocumentationText(sourceNode) || undefined expect(pastedNodes[i]?.documentation).toBe(documentation) expect(pastedNodes[i]?.expression).toBe(sourceNode.innerExpr.code()) expect(pastedNodes[i]?.metadata?.colorOverride).toBe(sourceNode.colorOverride) diff --git a/app/gui/src/project-view/components/GraphEditor/clipboard.ts b/app/gui/src/project-view/components/GraphEditor/clipboard.ts index 4bbba4945887..501b8dac90aa 100644 --- a/app/gui/src/project-view/components/GraphEditor/clipboard.ts +++ b/app/gui/src/project-view/components/GraphEditor/clipboard.ts @@ -2,6 +2,7 @@ import type { NodeCreationOptions } from '@/composables/nodeCreation' import type { GraphStore, Node, NodeId } from '@/stores/graph' import { Ast } from '@/util/ast' import { Pattern } from '@/util/ast/match' +import { nodeDocumentationText } from '@/util/ast/node' import { Vec2 } from '@/util/data/vec2' import type { ToValue } from '@/util/reactivity' import * as iter from 'enso-common/src/utilities/data/iter' @@ -186,10 +187,9 @@ export function writeClipboard(data: MimeData) { // === Serializing nodes === function nodeStructuredData(node: Node): CopiedNode { - const documentation = node.outerAst.isStatement() ? node.outerAst.documentationText() : undefined return { expression: node.innerExpr.code(), - documentation, + documentation: nodeDocumentationText(node) || undefined, metadata: node.rootExpr.serializeMetadata(), ...(node.pattern ? { binding: node.pattern.code() } : {}), } diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts index 7d06fdcbf010..ae4cc735dc48 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts @@ -64,6 +64,28 @@ test.each([ }, ], }, + { + markdown: '[Link text]()', + expectedLinks: [ + { + text: 'Link text', + href: 'https://www.example.com/index.html', + }, + ], + }, + { + markdown: '[Link text]()', + expectedLinks: [ + { + text: 'Link text', + href: 'https://www.example.com/Url with spaces.html', + }, + ], + }, + { + markdown: '[Link text](https://www.example.com/Spaces not allowed without angle brackets.html)', + expectedLinks: [], + }, { markdown: '[Unclosed url](https://www.example.com/index.html', expectedLinks: [], diff --git a/app/gui/src/project-view/components/PlainTextEditor.vue b/app/gui/src/project-view/components/PlainTextEditor.vue index 6e8e75c15c71..6e37ddb50efb 100644 --- a/app/gui/src/project-view/components/PlainTextEditor.vue +++ b/app/gui/src/project-view/components/PlainTextEditor.vue @@ -1,52 +1,22 @@ - - diff --git a/app/gui/src/project-view/components/PlainTextEditor/PlainTextEditorImpl.vue b/app/gui/src/project-view/components/PlainTextEditor/PlainTextEditorImpl.vue new file mode 100644 index 000000000000..fc1ee97c1edc --- /dev/null +++ b/app/gui/src/project-view/components/PlainTextEditor/PlainTextEditorImpl.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/gui/src/project-view/components/PlainTextEditor/___tests__/urlLinks.test.ts b/app/gui/src/project-view/components/PlainTextEditor/___tests__/urlLinks.test.ts new file mode 100644 index 000000000000..f656756588a3 --- /dev/null +++ b/app/gui/src/project-view/components/PlainTextEditor/___tests__/urlLinks.test.ts @@ -0,0 +1,73 @@ +import { linkifyUrls } from '@/components/PlainTextEditor/linkifyUrls' +import { EditorState } from '@codemirror/state' +import { Decoration, EditorView } from '@codemirror/view' +import { expect, test } from 'vitest' + +function decorations( + source: string, + recognize: (from: number, to: number, decoration: Decoration) => T | undefined, +) { + const state = EditorState.create({ + doc: source, + extensions: [linkifyUrls], + }) + const view = new EditorView({ state }) + const decorationSets = state.facet(EditorView.decorations) + const results = [] + for (const decorationSet of decorationSets) { + const resolvedDecorations = + decorationSet instanceof Function ? decorationSet(view) : decorationSet + const cursor = resolvedDecorations.iter() + while (cursor.value != null) { + const recognized = recognize(cursor.from, cursor.to, cursor.value) + if (recognized) results.push(recognized) + cursor.next() + } + } + return results +} + +function links(source: string) { + return decorations(source, (from, to, deco) => { + if (deco.spec.tagName === 'a') { + return { + text: source.substring(from, to), + href: deco.spec.attributes.href, + } + } + }) +} + +// Test that link decorations are created for URLs and emails, with `href` set appropriately. The specific URL and email +// syntaxes recognized are tested separately, in the unit tests for `LINKABLE_URL_REGEX` and `LINKABLE_EMAIL_REGEX`. +test.each([ + { + text: 'Url: https://www.example.com/index.html', + expectedLinks: [ + { + text: 'https://www.example.com/index.html', + href: 'https://www.example.com/index.html', + }, + ], + }, + { + text: 'Url: www.example.com', + expectedLinks: [ + { + text: 'www.example.com', + href: 'https://www.example.com', + }, + ], + }, + { + text: 'Email: user@example.com', + expectedLinks: [ + { + text: 'user@example.com', + href: 'mailto:user@example.com', + }, + ], + }, +])('Link decoration: $text', ({ text, expectedLinks }) => { + expect(links(text)).toEqual(expectedLinks) +}) diff --git a/app/gui/src/project-view/components/PlainTextEditor/linkifyUrls.ts b/app/gui/src/project-view/components/PlainTextEditor/linkifyUrls.ts new file mode 100644 index 000000000000..af04e59de65e --- /dev/null +++ b/app/gui/src/project-view/components/PlainTextEditor/linkifyUrls.ts @@ -0,0 +1,75 @@ +import { LINKABLE_EMAIL_REGEX, LINKABLE_URL_REGEX } from '@/util/link' +import { RangeSetBuilder, type Extension } from '@codemirror/state' +import { + Decoration, + ViewPlugin, + type DecorationSet, + type EditorView, + type ViewUpdate, +} from '@codemirror/view' + +/** CodeMirror extension rendering URLs and email addresses as links. */ +export const linkifyUrls: Extension = ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(view: EditorView) { + this.decorations = decorate(view) + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) this.decorations = decorate(update.view) + } + }, + { + decorations: (v) => v.decorations, + }, +) + +interface RangeLike { + from: number + to: number + value: T +} + +function regexpMatcher( + regexp: RegExp, + matchHandler: (match: RegExpExecArray) => T, +): (text: string) => Iterable> { + function* matcher(text: string) { + for (const match of text.matchAll(regexp)) { + const from = match.index + const to = from + match[0].length + const value = matchHandler(match) + yield { from, to, value } + } + } + return matcher +} + +const MATCHERS = [ + regexpMatcher(LINKABLE_URL_REGEX, (match) => + match[0].startsWith('http') ? match[0] : `https://${match[0]}`, + ), + regexpMatcher(LINKABLE_EMAIL_REGEX, (match) => `mailto:${match[0]}`), +] + +function decorate(view: EditorView): DecorationSet { + const decorations = new RangeSetBuilder() + for (const visibleRange of view.visibleRanges) { + const visibleText = view.state.doc.sliceString(visibleRange.from, visibleRange.to) + for (const matcher of MATCHERS) { + for (const match of matcher(visibleText)) { + decorations.add( + visibleRange.from + match.from, + visibleRange.from + match.to, + Decoration.mark({ + tagName: 'a', + attributes: { href: match.value, target: '_blank' }, + }), + ) + } + } + } + return decorations.finish() +} diff --git a/app/gui/src/project-view/components/lexical/LexicalContent.vue b/app/gui/src/project-view/components/lexical/LexicalContent.vue deleted file mode 100644 index 5fc55d5e75e4..000000000000 --- a/app/gui/src/project-view/components/lexical/LexicalContent.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/app/gui/src/project-view/components/lexical/LexicalDecorators.vue b/app/gui/src/project-view/components/lexical/LexicalDecorators.vue deleted file mode 100644 index d459da48046d..000000000000 --- a/app/gui/src/project-view/components/lexical/LexicalDecorators.vue +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/app/gui/src/project-view/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts b/app/gui/src/project-view/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts deleted file mode 100644 index 16ab36107a91..000000000000 --- a/app/gui/src/project-view/components/lexical/LinkPlugin/__tests__/LinkPlugin.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { expect, test } from 'vitest' -import { __TEST } from '..' - -const { URL_REGEX, EMAIL_REGEX } = __TEST - -test('Auto linking URL_REGEX', () => { - expect('www.a.b').toMatch(URL_REGEX) - expect('http://example.com').toMatch(URL_REGEX) - expect('https://a.b').toMatch(URL_REGEX) - expect('https://some.local').toMatch(URL_REGEX) - expect('http://AsDf.GhI').toMatch(URL_REGEX) - expect('https://xn--ls8h.la/').toMatch(URL_REGEX) - expect('https://💩.la/').not.toMatch(URL_REGEX) - expect('a.b').not.toMatch(URL_REGEX) - expect('a@b').not.toMatch(URL_REGEX) - expect('http://AsDf').not.toMatch(URL_REGEX) - expect('file://hello.world').not.toMatch(URL_REGEX) - expect('https://localhost').not.toMatch(URL_REGEX) - expect('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==').not.toMatch(URL_REGEX) - expect('](http://example.com').not.toMatch(URL_REGEX) -}) - -test('Auto linking EMAIL_REGEX', () => { - expect('example@gmail.com').toMatch(EMAIL_REGEX) - expect('EXAMPLE@GMAIL.COM').toMatch(EMAIL_REGEX) - expect('example..+hello.world@gmail.com').toMatch(EMAIL_REGEX) - expect('a@b.bla').toMatch(EMAIL_REGEX) - expect('(a@b.cd)').toMatch(EMAIL_REGEX) - expect('http://example.com').not.toMatch(EMAIL_REGEX) - expect('').not.toMatch(EMAIL_REGEX) - expect('a@b').not.toMatch(EMAIL_REGEX) - expect('a@b.c').not.toMatch(EMAIL_REGEX) -}) diff --git a/app/gui/src/project-view/components/lexical/LinkPlugin/autoMatcher.ts b/app/gui/src/project-view/components/lexical/LinkPlugin/autoMatcher.ts deleted file mode 100644 index cc521e2e65aa..000000000000 --- a/app/gui/src/project-view/components/lexical/LinkPlugin/autoMatcher.ts +++ /dev/null @@ -1,465 +0,0 @@ -import type { LinkAttributes } from '@lexical/link' -import type { ElementNode, LexicalEditor, LexicalNode, Spread } from 'lexical' - -export type AutoLinkAttributes = Partial> - -import { - $createAutoLinkNode, - $isAutoLinkNode, - $isLinkNode, - AutoLinkNode, - TOGGLE_LINK_COMMAND, -} from '@lexical/link' -import { mergeRegister } from '@lexical/utils' -import { - $createTextNode, - $getSelection, - $isElementNode, - $isLineBreakNode, - $isNodeSelection, - $isRangeSelection, - $isTextNode, - COMMAND_PRIORITY_LOW, - TextNode, -} from 'lexical' -import { assert } from 'ydoc-shared/util/assert' - -type ChangeHandler = (url: string | null, prevUrl: string | null) => void - -type MatchedLink = { - attributes?: AutoLinkAttributes - index: number - length: number - text: string - url: string -} - -export type LinkMatcher = (text: string) => MatchedLink | null - -/** TODO: Add docs */ -export function createLinkMatcherWithRegExp( - regExp: RegExp, - urlTransformer: (text: string) => string = (text) => text, -) { - return (text: string) => { - const match = regExp.exec(text) - if (match === null) { - return null - } - return { - index: match.index, - length: match[0].length, - text: match[0], - url: urlTransformer(match[0]), - } - } -} - -function findFirstMatch(text: string, matchers: Array): MatchedLink | null { - for (let i = 0; i < matchers.length; i++) { - const match = matchers[i]!(text) - - if (match) { - return match - } - } - - return null -} - -const PUNCTUATION_OR_SPACE = /[.,;\s]/ - -function isSeparator(char: string): boolean { - return PUNCTUATION_OR_SPACE.test(char) -} - -function endsWithSeparator(textContent: string): boolean { - return isSeparator(textContent[textContent.length - 1] ?? '') -} - -function startsWithSeparator(textContent: string): boolean { - return isSeparator(textContent[0] ?? '') -} - -function startsWithFullStop(textContent: string): boolean { - return /^\.[a-zA-Z0-9]{1,}/.test(textContent) -} - -function isPreviousNodeValid(node: LexicalNode): boolean { - let previousNode = node.getPreviousSibling() - if ($isElementNode(previousNode)) { - previousNode = previousNode.getLastDescendant() - } - return ( - previousNode === null || - $isLineBreakNode(previousNode) || - ($isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent())) - ) -} - -function isNextNodeValid(node: LexicalNode): boolean { - let nextNode = node.getNextSibling() - if ($isElementNode(nextNode)) { - nextNode = nextNode.getFirstDescendant() - } - return ( - nextNode === null || - $isLineBreakNode(nextNode) || - ($isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent())) - ) -} - -function isContentAroundIsValid( - matchStart: number, - matchEnd: number, - text: string, - nodes: TextNode[], -): boolean { - const contentBeforeIsValid = - matchStart > 0 ? isSeparator(text[matchStart - 1]!) : isPreviousNodeValid(nodes[0]!) - if (!contentBeforeIsValid) { - return false - } - - const contentAfterIsValid = - matchEnd < text.length ? - isSeparator(text[matchEnd]!) - : isNextNodeValid(nodes[nodes.length - 1]!) - return contentAfterIsValid -} - -function extractMatchingNodes( - nodes: TextNode[], - startIndex: number, - endIndex: number, -): [ - matchingOffset: number, - unmodifiedBeforeNodes: TextNode[], - matchingNodes: TextNode[], - unmodifiedAfterNodes: TextNode[], -] { - const unmodifiedBeforeNodes: TextNode[] = [] - const matchingNodes: TextNode[] = [] - const unmodifiedAfterNodes: TextNode[] = [] - let matchingOffset = 0 - - let currentOffset = 0 - const currentNodes = [...nodes] - - while (currentNodes.length > 0) { - const currentNode = currentNodes[0]! - const currentNodeText = currentNode.getTextContent() - const currentNodeLength = currentNodeText.length - const currentNodeStart = currentOffset - const currentNodeEnd = currentOffset + currentNodeLength - - if (currentNodeEnd <= startIndex) { - unmodifiedBeforeNodes.push(currentNode) - matchingOffset += currentNodeLength - } else if (currentNodeStart >= endIndex) { - unmodifiedAfterNodes.push(currentNode) - } else { - matchingNodes.push(currentNode) - } - currentOffset += currentNodeLength - currentNodes.shift() - } - return [matchingOffset, unmodifiedBeforeNodes, matchingNodes, unmodifiedAfterNodes] -} - -function $createAutoLinkNode_( - nodes: TextNode[], - startIndex: number, - endIndex: number, - match: MatchedLink, -): TextNode | undefined { - const linkNode = $createAutoLinkNode(match.url, match.attributes) - if (nodes.length === 1) { - let remainingTextNode: TextNode | undefined = nodes[0]! - let linkTextNode: TextNode | undefined - if (startIndex === 0) { - ;[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex) - } else { - ;[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex) - } - - assert(linkTextNode != null) - - const textNode = $createTextNode(match.text) - textNode.setFormat(linkTextNode.getFormat()) - textNode.setDetail(linkTextNode.getDetail()) - textNode.setStyle(linkTextNode.getStyle()) - linkNode.append(textNode) - - linkTextNode.replace(linkNode) - return remainingTextNode - } else if (nodes.length > 1) { - const firstTextNode = nodes[0]! - let offset = firstTextNode.getTextContent().length - let firstLinkTextNode: TextNode - if (startIndex === 0) { - firstLinkTextNode = firstTextNode - } else { - firstLinkTextNode = firstTextNode.splitText(startIndex)[1]! - } - const linkNodes = [] - let remainingTextNode: TextNode | undefined - for (let i = 1; i < nodes.length; i++) { - const currentNode = nodes[i]! - const currentNodeText = currentNode.getTextContent() - const currentNodeLength = currentNodeText.length - const currentNodeStart = offset - const currentNodeEnd = offset + currentNodeLength - if (currentNodeStart < endIndex) { - if (currentNodeEnd <= endIndex) { - linkNodes.push(currentNode) - } else { - const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart) - linkNodes.push(linkTextNode!) - remainingTextNode = endNode - } - } - offset += currentNodeLength - } - const selection = $getSelection() - const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined - const textNode = $createTextNode(firstLinkTextNode.getTextContent()) - textNode.setFormat(firstLinkTextNode.getFormat()) - textNode.setDetail(firstLinkTextNode.getDetail()) - textNode.setStyle(firstLinkTextNode.getStyle()) - linkNode.append(textNode, ...linkNodes) - // it does not preserve caret position if caret was at the first text node - // so we need to restore caret position - if (selectedTextNode && selectedTextNode === firstLinkTextNode) { - if ($isRangeSelection(selection)) { - textNode.select(selection.anchor.offset, selection.focus.offset) - } else if ($isNodeSelection(selection)) { - textNode.select(0, textNode.getTextContent().length) - } - } - firstLinkTextNode.replace(linkNode) - return remainingTextNode - } - return undefined -} - -function $handleLinkCreation( - nodes: TextNode[], - matchers: Array, - onChange: ChangeHandler, -): void { - let currentNodes = [...nodes] - const initialText = currentNodes.map((node) => node.getTextContent()).join('') - let text = initialText - let match - let invalidMatchEnd = 0 - - while ((match = findFirstMatch(text, matchers)) && match !== null) { - const matchStart = match.index - const matchLength = match.length - const matchEnd = matchStart + matchLength - const isValid = isContentAroundIsValid( - invalidMatchEnd + matchStart, - invalidMatchEnd + matchEnd, - initialText, - currentNodes, - ) - - if (isValid) { - const [matchingOffset, , matchingNodes, unmodifiedAfterNodes] = extractMatchingNodes( - currentNodes, - invalidMatchEnd + matchStart, - invalidMatchEnd + matchEnd, - ) - - const actualMatchStart = invalidMatchEnd + matchStart - matchingOffset - const actualMatchEnd = invalidMatchEnd + matchEnd - matchingOffset - const remainingTextNode = $createAutoLinkNode_( - matchingNodes, - actualMatchStart, - actualMatchEnd, - match, - ) - currentNodes = - remainingTextNode ? [remainingTextNode, ...unmodifiedAfterNodes] : unmodifiedAfterNodes - onChange(match.url, null) - invalidMatchEnd = 0 - } else { - invalidMatchEnd += matchEnd - } - - text = text.substring(matchEnd) - } -} - -function handleLinkEdit( - linkNode: AutoLinkNode, - matchers: Array, - onChange: ChangeHandler, -): void { - // Check children are simple text - const children = linkNode.getChildren() - const childrenLength = children.length - for (let i = 0; i < childrenLength; i++) { - const child = children[i] - if (!$isTextNode(child) || !child.isSimpleText()) { - replaceWithChildren(linkNode) - onChange(null, linkNode.getURL()) - return - } - } - - // Check text content fully matches - const text = linkNode.getTextContent() - const match = findFirstMatch(text, matchers) - if (match === null || match.text !== text) { - replaceWithChildren(linkNode) - onChange(null, linkNode.getURL()) - return - } - - // Check neighbors - if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) { - replaceWithChildren(linkNode) - onChange(null, linkNode.getURL()) - return - } - - const url = linkNode.getURL() - if (url !== match.url) { - linkNode.setURL(match.url) - onChange(match.url, url) - } - - if (match.attributes) { - const rel = linkNode.getRel() - if (rel !== match.attributes.rel) { - linkNode.setRel(match.attributes.rel || null) - onChange(match.attributes.rel || null, rel) - } - - const target = linkNode.getTarget() - if (target !== match.attributes.target) { - linkNode.setTarget(match.attributes.target || null) - onChange(match.attributes.target || null, target) - } - } -} - -// Bad neighbors are edits in neighbor nodes that make AutoLinks incompatible. -// Given the creation preconditions, these can only be simple text nodes. -function handleBadNeighbors( - textNode: TextNode, - matchers: Array, - onChange: ChangeHandler, -): void { - const previousSibling = textNode.getPreviousSibling() - const nextSibling = textNode.getNextSibling() - const text = textNode.getTextContent() - - // FIXME: Uncommend usages of `IsUnlinked` once released lexical supports it. - if ( - $isAutoLinkNode(previousSibling) && - /*!previousSibling.getIsUnlinked() &&*/ - (!startsWithSeparator(text) || startsWithFullStop(text)) - ) { - previousSibling.append(textNode) - handleLinkEdit(previousSibling, matchers, onChange) - onChange(null, previousSibling.getURL()) - } - - if ( - $isAutoLinkNode(nextSibling) /*&& !nextSibling.getIsUnlinked()*/ && - !endsWithSeparator(text) - ) { - replaceWithChildren(nextSibling) - handleLinkEdit(nextSibling, matchers, onChange) - onChange(null, nextSibling.getURL()) - } -} - -function replaceWithChildren(node: ElementNode): Array { - const children = node.getChildren() - const childrenLength = children.length - - for (let j = childrenLength - 1; j >= 0; j--) { - node.insertAfter(children[j]!) - } - - node.remove() - return children.map((child) => child.getLatest()) -} - -function getTextNodesToMatch(textNode: TextNode): TextNode[] { - // check if next siblings are simple text nodes till a node contains a space separator - const textNodesToMatch = [textNode] - let nextSibling = textNode.getNextSibling() - while (nextSibling !== null && $isTextNode(nextSibling) && nextSibling.isSimpleText()) { - textNodesToMatch.push(nextSibling) - if (/[\s]/.test(nextSibling.getTextContent())) { - break - } - nextSibling = nextSibling.getNextSibling() - } - return textNodesToMatch -} - -/** TODO: Add docs */ -export function useAutoLink( - editor: LexicalEditor, - matchers: Array, - onChange?: ChangeHandler, -): void { - assert( - editor.hasNodes([AutoLinkNode]), - 'LexicalAutoLinkPlugin: AutoLinkNode not registered on editor', - ) - - const onChangeWrapped = (url: string | null, prevUrl: string | null) => { - if (onChange) { - onChange(url, prevUrl) - } - } - - mergeRegister( - editor.registerNodeTransform(TextNode, (textNode: TextNode) => { - const parent = textNode.getParentOrThrow() - const previous = textNode.getPreviousSibling() - if ($isAutoLinkNode(parent) /*&& !parent.getIsUnlinked() */) { - handleLinkEdit(parent, matchers, onChangeWrapped) - } else if (!$isLinkNode(parent)) { - if ( - textNode.isSimpleText() && - (startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous)) - ) { - const textNodesToMatch = getTextNodesToMatch(textNode) - $handleLinkCreation(textNodesToMatch, matchers, onChangeWrapped) - } - - handleBadNeighbors(textNode, matchers, onChangeWrapped) - } - }), - editor.registerCommand( - TOGGLE_LINK_COMMAND, - (payload) => { - const selection = $getSelection() - if (payload !== null || !$isRangeSelection(selection)) { - return false - } - const nodes = selection.extract() - nodes.forEach((node) => { - const parent = node.getParent() - - if ($isAutoLinkNode(parent)) { - // invert the value - // parent.setIsUnlinked(!parent.getIsUnlinked()) - parent.markDirty() - return true - } - }) - return false - }, - COMMAND_PRIORITY_LOW, - ), - ) -} diff --git a/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts b/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts deleted file mode 100644 index be6224c8dc28..000000000000 --- a/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { documentationEditorBindings } from '@/bindings' -import type { LexicalPlugin } from '@/components/lexical' -import { AutoLinkNode, LinkNode } from '@lexical/link' -import { $getNearestNodeOfType } from '@lexical/utils' -import { - $getSelection, - CLICK_COMMAND, - COMMAND_PRIORITY_CRITICAL, - COMMAND_PRIORITY_LOW, - SELECTION_CHANGE_COMMAND, - type LexicalEditor, -} from 'lexical' -import { shallowRef } from 'vue' -import { createLinkMatcherWithRegExp, useAutoLink } from './autoMatcher' - -const URL_REGEX = - /(?()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ - -export const __TEST = { URL_REGEX, EMAIL_REGEX } - -/** TODO: Add docs */ -export function $getSelectedLinkNode() { - const selection = $getSelection() - if (selection?.isCollapsed) { - const node = selection?.getNodes()[0] - if (node) { - return ( - $getNearestNodeOfType(node, LinkNode) ?? - $getNearestNodeOfType(node, AutoLinkNode) ?? - undefined - ) - } - } -} - -const autoLinkClickHandler = documentationEditorBindings.handler({ - openLink() { - const link = $getSelectedLinkNode() - if (link instanceof AutoLinkNode) { - window.open(link.getURL(), '_blank')?.focus() - return true - } - return false - }, -}) - -export const autoLinkPlugin: LexicalPlugin = { - nodes: [AutoLinkNode], - register(editor: LexicalEditor): void { - editor.registerCommand( - CLICK_COMMAND, - (event) => autoLinkClickHandler(event), - COMMAND_PRIORITY_CRITICAL, - ) - - useAutoLink(editor, [ - createLinkMatcherWithRegExp(URL_REGEX, (t) => (t.startsWith('http') ? t : `https://${t}`)), - createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => `mailto:${text}`), - ]) - }, -} - -/** TODO: Add docs */ -export function useLinkNode(editor: LexicalEditor) { - const urlUnderCursor = shallowRef() - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - () => { - urlUnderCursor.value = $getSelectedLinkNode()?.getURL() - return false - }, - COMMAND_PRIORITY_LOW, - ) - return { urlUnderCursor } -} diff --git a/app/gui/src/project-view/components/lexical/LinkToolbar.vue b/app/gui/src/project-view/components/lexical/LinkToolbar.vue deleted file mode 100644 index 9a015c1dbde8..000000000000 --- a/app/gui/src/project-view/components/lexical/LinkToolbar.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - - diff --git a/app/gui/src/project-view/components/lexical/index.ts b/app/gui/src/project-view/components/lexical/index.ts deleted file mode 100644 index f3e7da567b91..000000000000 --- a/app/gui/src/project-view/components/lexical/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { unrefElement, type MaybeElement } from '@vueuse/core' -import type { - EditorThemeClasses, - KlassConstructor, - LexicalEditor, - LexicalNode, - LexicalNodeReplacement, -} from 'lexical' -import { createEditor } from 'lexical' -import { markRaw, onMounted, type Ref } from 'vue' -import { assertDefined } from 'ydoc-shared/util/assert' - -type NodeDefinition = KlassConstructor | LexicalNodeReplacement - -export interface LexicalPlugin { - nodes?: NodeDefinition[] - register: (editor: LexicalEditor) => void -} - -/** TODO: Add docs */ -export function lexicalTheme(theme: Record): EditorThemeClasses { - interface EditorThemeShape extends Record {} - const editorClasses: EditorThemeShape = {} - for (const [classPath, className] of Object.entries(theme)) { - const path = classPath.split('_') - const leaf = path.pop() - // `split` will always return at least one value - assertDefined(leaf) - let obj = editorClasses - for (const section of path) { - const nextObj = (obj[section] ??= {}) - if (typeof nextObj === 'string') { - console.warn( - `Lexical theme contained path '${classPath}', but path component '${section}' is a leaf.`, - ) - continue - } - obj = nextObj - } - obj[leaf] = className - } - return editorClasses -} - -/** TODO: Add docs */ -export function useLexical( - contentElement: Ref, - namespace: string, - theme: EditorThemeClasses, - plugins: LexicalPlugin[], -) { - const nodes = new Set() - for (const node of plugins.flatMap((plugin) => plugin.nodes)) if (node) nodes.add(node) - - const editor = markRaw( - createEditor({ - editable: true, - namespace, - theme, - nodes: [...nodes], - onError: console.error, - }), - ) - - onMounted(() => { - const element = unrefElement(contentElement.value) - if (element instanceof HTMLElement) editor.setRootElement(element) - - for (const plugin of plugins) plugin.register(editor) - }) - - return { editor } -} diff --git a/app/gui/src/project-view/components/lexical/sync.ts b/app/gui/src/project-view/components/lexical/sync.ts deleted file mode 100644 index de91c064e438..000000000000 --- a/app/gui/src/project-view/components/lexical/sync.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { type ToValue } from '@/util/reactivity' -import type { LexicalEditor } from 'lexical' -import { $createParagraphNode, $createTextNode, $getRoot, $setSelection } from 'lexical' -import { computed, shallowRef, toValue } from 'vue' - -const SYNC_TAG = 'ENSO_SYNC' - -/** - * Enables two-way synchronization between the editor and a string model `content`. - * - * By default, the editor's text contents are synchronized with the string. A content getter and setter may be provided - * to synchronize a different view of the state, e.g. to transform to an encoding that keeps rich text information. - */ -export function useLexicalStringSync( - editor: LexicalEditor, - $getEditorContent: () => string = $getRootText, - $setEditorContent: (content: string) => void = $setRootTextClearingSelection, -) { - return useLexicalSync(editor, $getEditorContent, (content, prevContent) => { - if (content !== toValue(prevContent)) $setEditorContent(content) - }) -} - -/** TODO: Add docs */ -export function useLexicalSync( - editor: LexicalEditor, - $read: () => T, - $write: (content: T, prevContent: ToValue) => void, -) { - const state = shallowRef(editor.getEditorState()) - - const unregister = editor.registerUpdateListener(({ editorState, tags }) => { - if (tags.has(SYNC_TAG)) return - state.value = editorState - }) - - const getContent = () => editor.getEditorState().read($read) - - return { - content: { - editedContent: computed(() => state.value.read($read)), - set: (content: T) => { - editor.update(() => $write(content, getContent), { - discrete: true, - skipTransforms: true, - tag: SYNC_TAG, - }) - }, - }, - unregister, - } -} - -/** TODO: Add docs */ -export function $getRootText() { - return $getRoot().getTextContent() -} - -/** TODO: Add docs */ -export function $setRootText(text: string) { - const root = $getRoot() - root.clear() - const paragraph = $createParagraphNode() - paragraph.append($createTextNode(text)) - root.append(paragraph) -} - -function $setRootTextClearingSelection(text: string) { - $setRootText(text) - $setSelection(null) -} diff --git a/app/gui/src/project-view/composables/astDocumentation.ts b/app/gui/src/project-view/composables/astDocumentation.ts deleted file mode 100644 index dca29731dfa8..000000000000 --- a/app/gui/src/project-view/composables/astDocumentation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type GraphStore } from '@/stores/graph' -import { Ast } from '@/util/ast' -import { type ToValue } from '@/util/reactivity' -import { computed, toValue } from 'vue' - -/** A composable for reactively retrieving and setting documentation from given Ast node. */ -export function useAstDocumentation(graphStore: GraphStore, ast: ToValue) { - return { - documentation: { - state: computed(() => { - const astValue = toValue(ast) - return (astValue?.isStatement() ? astValue.documentationText() : undefined) ?? '' - }), - set: (text: string | undefined) => { - const astValue = toValue(ast) - graphStore.edit((edit) => { - if (astValue?.isStatement()) { - const editAst = edit.getVersion(astValue) - // If the statement can have documentation attached (for example, it is a `Function`, `Assignment`, or - // `ExpressionStatement`), do so. If in cannot (for example, it is an `import` declaration), an error will - // be reported below. - if ('setDocumentationText' in editAst) { - editAst.setDocumentationText(text) - return - } - } - console.error('Unable to set documentation', astValue?.id) - }) - }, - }, - } -} diff --git a/app/gui/src/project-view/util/__tests__/link.test.ts b/app/gui/src/project-view/util/__tests__/link.test.ts new file mode 100644 index 000000000000..f8ed89a68490 --- /dev/null +++ b/app/gui/src/project-view/util/__tests__/link.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'vitest' +import { LINKABLE_EMAIL_REGEX, LINKABLE_URL_REGEX } from '../link' + +const cases = { + urls: [ + 'www.a.b', + 'http://example.com', + 'https://a.b', + 'https://some.local', + 'http://AsDf.GhI', + 'https://xn--ls8h.la/', + '](http://example.com', + ], + emails: [ + 'example@gmail.com', + 'EXAMPLE@GMAIL.COM', + 'example..+hello.world@gmail.com', + 'a@b.bla', + '(a@b.cd)', + ], + neither: [ + 'https://💩.la/', + 'a.b', + 'http://AsDf', + 'file://hello.world', + 'https://localhost', + 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', + '', + 'a@b', + 'a@b.c', + ], +} + +test.each(cases.urls)('LINKABLE_URL_REGEX should match: %s', (url) => + expect(url).toMatch(LINKABLE_URL_REGEX), +) +test.each([...cases.neither, ...cases.emails])( + 'LINKABLE_URL_REGEX should not match: %s', + (nonUrl) => expect(nonUrl).not.toMatch(LINKABLE_URL_REGEX), +) +test.each(cases.emails)('LINKABLE_EMAIL_REGEX should match: %s', (email) => + expect(email).toMatch(LINKABLE_EMAIL_REGEX), +) +test.each([...cases.neither, ...cases.urls])( + 'LINKABLE_EMAIL_REGEX should not match: %s', + (nonEmail) => expect(nonEmail).not.toMatch(LINKABLE_EMAIL_REGEX), +) diff --git a/app/gui/src/project-view/util/ast/__tests__/node.test.ts b/app/gui/src/project-view/util/ast/__tests__/node.test.ts index a6a71512652b..cad5b88470bf 100644 --- a/app/gui/src/project-view/util/ast/__tests__/node.test.ts +++ b/app/gui/src/project-view/util/ast/__tests__/node.test.ts @@ -1,26 +1,31 @@ -import { assert } from '@/util/assert' +import { assert, assertDefined } from '@/util/assert' import { Ast } from '@/util/ast' import { nodeFromAst, primaryApplicationSubject } from '@/util/ast/node' import { expect, test } from 'vitest' +import { nodeDocumentationText } from '../node' test.each` line | pattern | rootExpr | documentation - ${'2 + 2'} | ${undefined} | ${'2 + 2'} | ${undefined} - ${'foo = bar'} | ${'foo'} | ${'bar'} | ${undefined} + ${'2 + 2'} | ${undefined} | ${'2 + 2'} | ${''} + ${'foo = bar'} | ${'foo'} | ${'bar'} | ${''} ${'## Documentation\n2 + 2'} | ${undefined} | ${'2 + 2'} | ${'Documentation'} ${'## Documentation\nfoo = 2 + 2'} | ${'foo'} | ${'2 + 2'} | ${'Documentation'} `('Node information from AST $line line', ({ line, pattern, rootExpr, documentation }) => { - const ast = [...Ast.parseBlock(line).statements()][0]! + const ast = Ast.parseStatement(line) + assertDefined(ast) const node = nodeFromAst(ast, false) - expect(node?.outerAst).toBe(ast) - expect(node?.pattern?.code()).toBe(pattern) - expect(node?.rootExpr.code()).toBe(rootExpr) - expect(node?.innerExpr.code()).toBe(rootExpr) - expect(node?.outerAst.isStatement() && node.outerAst.documentationText()).toBe(documentation) + expect(node).toBeDefined() + assertDefined(node) + expect(node.outerAst).toBe(ast) + expect(node.pattern?.code()).toBe(pattern) + expect(node.rootExpr.code()).toBe(rootExpr) + expect(node.innerExpr.code()).toBe(rootExpr) + expect(nodeDocumentationText(node)).toBe(documentation) }) test.each(['## Documentation only'])("'%s' should not be a node", (line) => { const ast = Ast.parseStatement(line) + assertDefined(ast) const node = nodeFromAst(ast, false) expect(node).toBeUndefined() }) @@ -48,13 +53,14 @@ test.each([ { code: 'operator1 + operator2', expected: undefined }, ])('Primary application subject of $code', ({ code, expected }) => { const ast = Ast.parseExpression(code) + assertDefined(ast) const module = ast.module const primaryApplication = primaryApplicationSubject(ast) const analyzed = primaryApplication && { subject: module.get(primaryApplication.subject).code(), accesses: primaryApplication.accessChain.map((id) => { const ast = module.get(id) - assert(ast instanceof Ast.PropertyAccess) + assert(ast instanceof Ast.MutablePropertyAccess) return ast.rhs.code() }), } diff --git a/app/gui/src/project-view/util/ast/node.ts b/app/gui/src/project-view/util/ast/node.ts index a436542196ff..f33c30fce4b7 100644 --- a/app/gui/src/project-view/util/ast/node.ts +++ b/app/gui/src/project-view/util/ast/node.ts @@ -1,6 +1,7 @@ import type { NodeDataFromAst } from '@/stores/graph' import { Ast } from '@/util/ast' import { Prefixes } from '@/util/ast/prefixes' +import * as Y from 'yjs' export const prefixes = Prefixes.FromLines({ enableRecording: @@ -80,3 +81,15 @@ export function primaryApplicationSubject( return return { subject: subject.id, accessChain: accessChain.map((ast) => ast.id) } } + +/** @returns The node's documentation, if this type of node is documentable (currently, this excludes input nodes). */ +export function nodeMutableDocumentation(node: NodeDataFromAst): Y.Text | undefined { + if (!node.outerAst.isStatement()) return + if (!('mutableDocumentationText' in node.outerAst)) return + return node.outerAst.mutableDocumentationText() +} + +/** @returns The node's documentation text. Returns an empty string if the node has no documentation comment. */ +export function nodeDocumentationText(node: NodeDataFromAst): string { + return nodeMutableDocumentation(node)?.toJSON() ?? '' +} diff --git a/app/gui/src/project-view/util/link.ts b/app/gui/src/project-view/util/link.ts new file mode 100644 index 000000000000..916f60fd5bce --- /dev/null +++ b/app/gui/src/project-view/util/link.ts @@ -0,0 +1,10 @@ +/** + * Heuristic that matches strings suitable to be automatically interpreted as links. Recognizes absolute URLs with + * `http` and `https` protocols, and some protocol-less strings that are likely to be URLs. + */ +export const LINKABLE_URL_REGEX = + /(?:https?:\/\/(?:www\.)?|www\.)[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b[-a-zA-Z0-9()@:%_+.~#?&/=]*/g + +/** Heuristic that matches strings suitable to be automatically interpreted as email addresses. */ +export const LINKABLE_EMAIL_REGEX = + /(?:[^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*|(".+"))@(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}]|(?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,})/g diff --git a/app/ydoc-shared/src/ast/__tests__/documentation.test.ts b/app/ydoc-shared/src/ast/__tests__/documentation.test.ts index b8f4c9919032..7ffb0c5becdc 100644 --- a/app/ydoc-shared/src/ast/__tests__/documentation.test.ts +++ b/app/ydoc-shared/src/ast/__tests__/documentation.test.ts @@ -1,95 +1,110 @@ import * as iter from 'enso-common/src/utilities/data/iter' import { describe, expect, test } from 'vitest' -import { assert } from '../../util/assert' -import { MutableModule } from '../mutableModule' -import { parseBlock, parseModule, parseStatement } from '../parse' -import { MutableAssignment, MutableExpressionStatement, MutableFunctionDef } from '../tree' +import * as Y from 'yjs' +import { assert, assertDefined } from '../../util/assert' +import { parseModule } from '../parse' +import { MutableBodyBlock, MutableFunctionDef, Statement } from '../tree' -test.each([ - { code: '## Simple\nnode', documentation: 'Simple' }, - { - code: '## Preferred indent\n 2nd line\n 3rd line\nnode', - documentation: 'Preferred indent\n2nd line\n3rd line', - }, - { - code: '## Extra-indented child\n 2nd line\n 3rd line\nnode', - documentation: 'Extra-indented child\n2nd line\n3rd line', - normalized: '## Extra-indented child\n 2nd line\n 3rd line\nnode', - }, - { - code: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode', - documentation: 'Extra-indented child, beyond 4th column\n2nd line\n 3rd line', - normalized: '## Extra-indented child, beyond 4th column\n 2nd line\n 3rd line\nnode', - }, - { - code: '##Preferred indent, no initial space\n 2nd line\n 3rd line\nnode', - documentation: 'Preferred indent, no initial space\n2nd line\n3rd line', - normalized: '## Preferred indent, no initial space\n 2nd line\n 3rd line\nnode', - }, - { - code: '## Minimum indent\n 2nd line\n 3rd line\nnode', - documentation: 'Minimum indent\n2nd line\n3rd line', - normalized: '## Minimum indent\n 2nd line\n 3rd line\nnode', - }, -])('Documentation edit round-trip: $code', docCase => { - const { code, documentation } = docCase - const parsed = parseStatement(code)! - const parsedDocumentation = parsed.documentationText() - expect(parsedDocumentation).toBe(documentation) - const edited = MutableModule.Transient().copy(parsed) - assert('setDocumentationText' in edited) - edited.setDocumentationText(parsedDocumentation) - expect(edited.code()).toBe(docCase.normalized ?? code) -}) +describe('Component documentation (plain text)', () => { + const plaintextDocumentableStatements = [ + // assignment statement + 'x = 1', + // expression statement (e.g. output component) + 'x', + ] + const textCases = [ + { + source: '## A component comment', + text: 'A component comment', + }, + { + source: '## A multiline\n component comment', + text: 'A multiline\ncomponent comment', + }, + ] + const cases = plaintextDocumentableStatements.flatMap(statement => + textCases.map(textCase => ({ statement, ...textCase })), + ) -test.each([ - '## Some documentation\nf x = 123', - '## Some documentation\n and a second line\nf x = 123', - '## Some documentation## Another documentation??\nf x = 123', -])('Finding documentation: $code', code => { - const block = parseBlock(code) - const method = [...block.statements()][0] - assert(method instanceof MutableFunctionDef) - expect(method.documentationText()).toBeTruthy() -}) + test.each(cases)('Enso source comments to normalized text', ({ statement, source, text }) => { + const moduleSource = `main =\n ${source}\n ${statement}` + const topLevel = parseModule(moduleSource) + topLevel.module.setRoot(topLevel) + const main = iter.first(topLevel.statements()) + assert(main instanceof MutableFunctionDef) + expect(main.name.code()).toBe('main') + const body = main.body + assert(body instanceof MutableBodyBlock) + const nodeStatement = iter.first(body.statements()) + assertDefined(nodeStatement) + assert(nodeStatement.isStatement()) + expect(statementDocumentation(nodeStatement).toJSON()).toBe(text) + }) -test.each([ - { - code: '## Already documented\nf x = 123', - expected: '## Already documented\nf x = 123', - }, - { - code: 'f x = 123', - expected: '##\nf x = 123', - }, -])('Adding documentation: $code', ({ code, expected }) => { - const block = parseBlock(code) - const method = [...block.statements()][0] - assert(method instanceof MutableFunctionDef) - if (method.documentationText() === undefined) method.setDocumentationText('') - expect(block.code()).toBe(expected) -}) + test.each(cases)('Text to Enso source', ({ statement, source, text }) => { + const expectedSource = `main =\n ${source}\n ${statement}` + const initialSource = `main =\n ${statement}` + const topLevel = parseModule(initialSource) + topLevel.module.setRoot(topLevel) + const main = iter.first(topLevel.statements()) + assert(main instanceof MutableFunctionDef) + expect(main.name.code()).toBe('main') + const body = main.body + assert(body instanceof MutableBodyBlock) + const nodeStatement = iter.first(body.statements()) + assertDefined(nodeStatement) + assert(nodeStatement.isStatement()) + const docs = statementDocumentation(nodeStatement) + docs.insert(0, text) + expect(topLevel.code()).toBe(expectedSource) + }) -test('Creating comments', () => { - const block = parseBlock('2 + 2') - block.module.setRoot(block) - const statement = [...block.statements()][0] - assert(statement instanceof MutableExpressionStatement) - const docText = 'Calculate five' - statement.setDocumentationText(docText) - expect(statement.module.root()?.code()).toBe(`## ${docText}\n2 + 2`) -}) + test.each(cases)( + 'Editing different comments with syncToCode ($statement): $source', + ({ statement, source }) => { + const functionCode = (docs: string) => `main =\n ${docs}\n ${statement}` + const moduleOriginalSource = functionCode(source) + const topLevel = parseModule(moduleOriginalSource) + topLevel.module.setRoot(topLevel) + assert(topLevel.code() === moduleOriginalSource) + const moduleEditedSource = functionCode('## Some new docs') + topLevel.syncToCode(moduleEditedSource) + expect(topLevel.module.root()?.code()).toBe(moduleEditedSource) + }, + ) + + test.each(cases)( + 'Setting comments to different content with syncToCode', + ({ statement, source }) => { + const functionCode = (docs: string) => `main =\n ${docs}\n ${statement}` + const moduleOriginalSource = functionCode('## Original docs') + const topLevel = parseModule(moduleOriginalSource) + const module = topLevel.module + module.setRoot(topLevel) + assert(module.root()?.code() === moduleOriginalSource) + const moduleEditedSource = functionCode(source) + module.syncToCode(moduleEditedSource) + expect(module.root()?.code()).toBe(moduleEditedSource) + }, + ) -test('Creating comments: indented', () => { - const topLevel = parseModule('main =\n x = 1') - topLevel.module.setRoot(topLevel) - const main = [...topLevel.statements()][0] - assert(main instanceof MutableFunctionDef) - const statement = [...main.bodyAsBlock().statements()][0] - assert(statement instanceof MutableAssignment) - const docText = 'The smallest natural number' - statement.setDocumentationText(docText) - expect(statement.module.root()?.code()).toBe(`main =\n ## ${docText}\n x = 1`) + test('Setting empty markdown content removes comment', () => { + const originalSourceWithDocComment = 'main =\n ## Some docs\n x = 1' + const functionCodeWithoutDocs = 'main =\n x = 1' + const topLevel = parseModule(originalSourceWithDocComment) + expect(topLevel.code()).toBe(originalSourceWithDocComment) + const main = iter.first(topLevel.statements()) + assert(main instanceof MutableFunctionDef) + expect(main.name.code()).toBe('main') + const body = main.body + assert(body instanceof MutableBodyBlock) + const nodeStatement = iter.first(body.statements()) + assertDefined(nodeStatement) + assert(nodeStatement.isStatement()) + const docs = statementDocumentation(nodeStatement) + docs.delete(0, docs.length) + expect(topLevel.code()).toBe(functionCodeWithoutDocs) + }) }) describe('Function documentation (Markdown)', () => { @@ -223,3 +238,10 @@ describe('Function documentation (Markdown)', () => { expect(topLevel.code()).toBe(functionCodeWithoutDocs) }) }) + +function statementDocumentation(statement: Statement): Y.Text { + assert('mutableDocumentationText' in statement) + const docs = statement.mutableDocumentationText() + assertDefined(docs) + return docs +} diff --git a/app/ydoc-shared/src/ast/documentation.ts b/app/ydoc-shared/src/ast/documentation.ts index c1b9d68609ad..e1703b9a6ace 100644 --- a/app/ydoc-shared/src/ast/documentation.ts +++ b/app/ydoc-shared/src/ast/documentation.ts @@ -122,56 +122,85 @@ function normalizeMarkdown(rawMarkdown: string): string { return normalized } +function stringCollector() { + let output = '' + const collector = { + text: (text: string) => (output += text), + wrapText: (text: string) => (output += text), + newline: () => (output += '\n'), + } + return { collector, output } +} + +/** + * Convert from "normalized" Markdown (with hard line-breaks removed) to the standard format, with paragraphs separated + * by blank lines. + */ +export function normalizedMarkdownToStandard(normalizedMarkdown: string) { + const { collector, output } = stringCollector() + standardizeMarkdown(normalizedMarkdown, collector) + return output +} + /** * Convert from "normalized" Markdown to the on-disk representation, with paragraphs hard-wrapped and separated by blank * lines. */ function standardizeMarkdown(normalizedMarkdown: string, textConsumer: TextConsumer) { - let prevTo = 0 - let prevName: string | undefined = undefined let printingTags = true const cursor = markdownParser.parse(normalizedMarkdown).cursor() - cursor.firstChild() - do { - if (prevTo < cursor.from) { - const betweenText = normalizedMarkdown.slice(prevTo, cursor.from) - for (const _match of betweenText.matchAll(LINE_BOUNDARIES)) { - textConsumer.newline() + + function standardizeDocument() { + let prevTo = 0 + let prevName: string | undefined = undefined + cursor.firstChild() + do { + if (prevTo < cursor.from) { + const betweenText = normalizedMarkdown.slice(prevTo, cursor.from) + for (const _match of betweenText.matchAll(LINE_BOUNDARIES)) { + textConsumer.newline() + } + if (cursor.name === 'Paragraph' && prevName !== 'Table') { + textConsumer.newline() + } } - if (cursor.name === 'Paragraph' && prevName !== 'Table') { - textConsumer.newline() + const lines = normalizedMarkdown.slice(cursor.from, cursor.to).split(LINE_BOUNDARIES) + if (cursor.name === 'Paragraph') { + standardizeParagraph(lines) + } else { + lines.forEach((line, i) => { + if (i > 0) textConsumer.newline() + textConsumer.text(line) + }) + printingTags = false } - } - const lines = normalizedMarkdown.slice(cursor.from, cursor.to).split(LINE_BOUNDARIES) - if (cursor.name === 'Paragraph') { - let printingNonTags = false - lines.forEach((line, i) => { - if (printingTags) { - if (cursor.name === 'Paragraph' && line.startsWith('ICON ')) { - textConsumer.text(line) - } else { - printingTags = false - } + prevTo = cursor.to + prevName = cursor.name + } while (cursor.nextSibling()) + } + + function standardizeParagraph(lines: string[]) { + let printingNonTags = false + lines.forEach((line, i) => { + if (printingTags) { + if (cursor.name === 'Paragraph' && line.startsWith('ICON ')) { + textConsumer.text(line) + } else { + printingTags = false } - if (!printingTags) { - if (i > 0) { - textConsumer.newline() - if (printingNonTags) textConsumer.newline() - } - textConsumer.wrapText(line) - printingNonTags = true + } + if (!printingTags) { + if (i > 0) { + textConsumer.newline() + if (printingNonTags) textConsumer.newline() } - }) - } else { - lines.forEach((line, i) => { - if (i > 0) textConsumer.newline() - textConsumer.text(line) - }) - printingTags = false - } - prevTo = cursor.to - prevName = cursor.name - } while (cursor.nextSibling()) + textConsumer.wrapText(line) + printingNonTags = true + } + }) + } + + standardizeDocument() } interface TextConsumer { diff --git a/app/ydoc-shared/src/ast/tree.ts b/app/ydoc-shared/src/ast/tree.ts index 0451c24cc4d2..83ae4a1d9ad9 100644 --- a/app/ydoc-shared/src/ast/tree.ts +++ b/app/ydoc-shared/src/ast/tree.ts @@ -8,7 +8,7 @@ import type { SourceRangeEdit } from '../util/data/text' import { allKeys } from '../util/types' import type { ExternalId, VisualizationMetadata } from '../yjsModel' import { visMetadataEquals } from '../yjsModel' -import { functionDocsToConcrete } from './documentation' +import { docLineToConcrete, functionDocsToConcrete } from './documentation' import { is_numeric_literal } from './ffi' import type { SpanMap } from './idMap' import { newExternalId } from './idMap' @@ -707,17 +707,14 @@ abstract class BaseStatement extends Ast { override isAllowedInExpressionContext() { return false } - documentationText(): string | undefined { - return - } } /** * A statement, i.e. the contents of a line which is either at the top level of a module (a module declaration), or * within a body block (a function body statement). */ export interface Statement extends BaseStatement { - /** If this statement type supports attached documentation, and documentation is present, parse and return it. */ - documentationText(): string | undefined + /** If this statement type supports plain-text documentation, return a value synchronized with its content. */ + mutableDocumentationText?: () => Y.Text } abstract class BaseMutableStatement extends MutableAst implements Statement { override isAllowedInStatementContext(): true { @@ -726,18 +723,11 @@ abstract class BaseMutableStatement extends MutableAst implements Statement { override isAllowedInExpressionContext() { return false } - documentationText(): string | undefined { - return - } - setDocumentationText?: (text: string | undefined) => void } /** A mutable {@link Statement}. */ export interface MutableStatement extends BaseMutableStatement { - /** - * Set (or clear) the documentation associated with this statement. This method is only present on statement types - * that support attaching documentation. - */ - setDocumentationText?: (text: string | undefined) => void + /** If this statement type supports plain-text documentation, return a value synchronized with its content. */ + mutableDocumentationText?: () => Y.Text } abstract class BaseExpression extends Ast { @@ -1365,10 +1355,6 @@ export class Generic extends Ast implements Expression, Statement { override isAllowedInExpressionContext(): true { return true } - /** See {@link Statement['documentationText']}. */ - documentationText() { - return undefined - } declare fields: FixedMapView /** TODO: Add docs */ constructor(module: Module, fields: FixedMapView) { @@ -1706,10 +1692,24 @@ function textElementValue(element: TextElement): string { function rawTextElementValue(raw: TextElement, module: Module): string { return textElementValue(mapRefs(raw, rawToConcrete(module))) } - function uninterpolatedText(elements: DeepReadonly, module: Module): string { return elements.reduce((s, e) => s + rawTextElementValue(e, module), '') } +function docTextToConcrete( + docLine: DeepReadonly | undefined, + docText: DeepReadonly, + indent: string | null, + module: Module, +): IterableIterator | undefined { + const docLineText = docLineToText(docLine, module) ?? '' + const docTextValue = docText.toJSON() + if (docLine && docTextValue === docLineText) { + return docLineToConcrete(docLine, indent || '') + } else if (docTextValue) { + const fromText = elementsToDocLine(textToUninterpolatedElements(docTextValue)) + return docLineToConcrete(fromText, indent || '') + } +} function fieldRawChildren(field: DeepReadonly) { const children = new Array() @@ -1886,6 +1886,7 @@ applyMixins(MutableTextLiteral, [MutableAst]) interface ExpressionStatementFields { docLine: DocLine | undefined + docText: Y.Text expression: NodeChild } /** TODO: Add docs */ @@ -1935,8 +1936,10 @@ export class ExpressionStatement extends BaseStatement { ) { const base = module.baseObject('ExpressionStatement') const id_ = base.get('id') + const rawDocLine = docLine && mapRefs(docLine, ownedToRaw(module, id_)) const fields = composeFieldData(base, { - docLine: docLine && mapRefs(docLine, ownedToRaw(module, id_)), + docLine: rawDocLine, + docText: new Y.Text(docLineToText(rawDocLine, module) ?? ''), expression: concreteChild(module, expression, id_), }) return asOwned(new MutableExpressionStatement(module, fields)) @@ -1947,44 +1950,22 @@ export class ExpressionStatement extends BaseStatement { return this.module.get(this.fields.get('expression').node) as Expression } - /** Return the string value of the documentation. */ - override documentationText(): string | undefined { - return docLineToText(this.fields.get('docLine'), this.module) + /** + * Returns the documentation text for editing. The returned object may be used to edit the documentation directly, + * without creating an edit module to explicitly commit it. + */ + mutableDocumentationText(): Y.Text { + return (this.fields as MutableExpressionStatement['fields']).get('docText') } /** TODO: Add docs */ - *concreteChildren({ indent, verbatim }: PrintContext): IterableIterator { - const { docLine, expression } = getAll(this.fields) - if (docLine) yield* docLineToConcrete(docLine, indent || '') - yield docLine ? - { whitespace: indent || '', node: expression.node } - : ensureUnspaced(expression, verbatim) - } -} -function* docLineToConcrete( - docLine: DeepReadonly, - indent: string, -): IterableIterator { - yield firstChild(docLine.docs.open) - let prevType = undefined - let extraIndent = '' - for (const { token } of docLine.docs.elements) { - if (token.node.tokenType_ === TokenType.Newline) { - yield ensureUnspaced(token, false) - } else { - if (prevType === TokenType.Newline) { - yield { whitespace: indent + extraIndent, node: token.node } - } else { - if (prevType === undefined) { - const leadingSpace = token.node.code_.match(/ */) - extraIndent = ' ' + (leadingSpace ? leadingSpace[0] : '') - } - yield { whitespace: '', node: token.node } - } - } - prevType = token.node.tokenType_ + *concreteChildren({ indent }: PrintContext): IterableIterator { + const { docLine, docText, expression } = getAll(this.fields) + const docTokens = docTextToConcrete(docLine, docText, indent, this.module) + const prevLine = !!docTokens + if (docTokens) yield* docTokens + yield prevLine ? { whitespace: indent || '', node: expression.node } : firstChild(expression) } - for (const newline of docLine.newlines) yield preferUnspaced(newline) } function docLineToText( docLine: DeepReadonly | undefined, @@ -1994,25 +1975,11 @@ function docLineToText( const raw = uninterpolatedText(docLine.docs.elements, module) return raw.startsWith(' ') ? raw.slice(1) : raw } -function docLineFromText( - text: string | undefined, - ast: { module: MutableModule; id: AstId }, -): DocLine | undefined { - if (text == null) return - return mapRefs( - elementsToDocLine(textToUninterpolatedElements(text)), - ownedToRaw(ast.module, ast.id), - ) -} /** TODO: Add docs */ export class MutableExpressionStatement extends ExpressionStatement implements MutableStatement { declare readonly module: MutableModule declare readonly fields: FixedMap - setDocumentationText(text: string | undefined) { - this.fields.set('docLine', docLineFromText(text, this)) - } - setExpression(value: Owned) { this.fields.set('expression', unspaced(this.claimChild(value))) } @@ -2064,10 +2031,6 @@ export class Invalid extends Ast implements Statement, Expression { override isAllowedInExpressionContext(): true { return true } - /** See {@link Statement['documentationText']}. */ - documentationText() { - return undefined - } declare fields: FixedMapView /** TODO: Add docs */ constructor(module: Module, fields: FixedMapView) { @@ -2488,11 +2451,6 @@ export class FunctionDef extends BaseStatement { !!equals.whitespace && !(this.module.tryGet(body.node) instanceof BodyBlock), ) } - - /** Return the string value of the documentation. */ - override documentationText(): string | undefined { - return docLineToText(this.fields.get('docLine'), this.module) - } } function* argumentDefinitionToConcrete(def: DeepReadonly, verbatim: boolean) { const { open, open2, suspension, pattern, type, close2, defaultValue, close } = def @@ -2525,9 +2483,6 @@ export class MutableFunctionDef extends FunctionDef implements MutableStatement declare readonly module: MutableModule declare readonly fields: FixedMap - setDocumentationText(text: string | undefined) { - this.fields.set('docLine', docLineFromText(text, this)) - } setName(value: Owned) { this.fields.set('name', unspaced(this.claimChild(value))) } @@ -2563,7 +2518,8 @@ interface PrintContext { } interface AssignmentFields { - docLine: DocLine | undefined + docLine: DocLine | undefined + docText: Y.Text pattern: NodeChild equals: NodeChild expression: NodeChild @@ -2592,8 +2548,10 @@ export class Assignment extends BaseStatement { ) { const base = module.baseObject('Assignment') const id_ = base.get('id') + const rawDocLine = docLine && mapRefs(docLine, ownedToRaw(module, id_)) const fields = composeFieldData(base, { - docLine: docLine && mapRefs(docLine, ownedToRaw(module, id_)), + docLine: rawDocLine, + docText: new Y.Text(docLineToText(rawDocLine, module) ?? ''), pattern: concreteChild(module, pattern, id_), equals, expression: concreteChild(module, expression, id_), @@ -2628,30 +2586,30 @@ export class Assignment extends BaseStatement { return this.module.get(this.fields.get('expression').node) as Expression } + /** + * Returns the documentation text for editing. The returned object may be used to edit the documentation directly, + * without creating an edit module to explicitly commit it. + */ + mutableDocumentationText(): Y.Text { + return (this.fields as MutableAssignment['fields']).get('docText') + } + /** TODO: Add docs */ *concreteChildren({ verbatim, indent }: PrintContext): IterableIterator { - const { docLine, pattern, equals, expression } = getAll(this.fields) - if (docLine) yield* docLineToConcrete(docLine, indent || '') - yield docLine ? - { whitespace: indent || '', node: pattern.node } - : ensureUnspaced(pattern, verbatim) + const { docLine, docText, pattern, equals, expression } = getAll(this.fields) + const docTokens = docTextToConcrete(docLine, docText, indent, this.module) + const prevLine = !!docTokens + if (docTokens) yield* docTokens + yield prevLine ? { whitespace: indent || '', node: pattern.node } : firstChild(pattern) yield ensureSpacedOnlyIf(equals, expression.whitespace !== '', verbatim) yield preferSpaced(expression) } - - /** Return the string value of the documentation. */ - override documentationText(): string | undefined { - return docLineToText(this.fields.get('docLine'), this.module) - } } /** TODO: Add docs */ export class MutableAssignment extends Assignment implements MutableStatement { declare readonly module: MutableModule declare readonly fields: FixedMap - setDocumentationText(text: string | undefined) { - this.fields.set('docLine', docLineFromText(text, this)) - } setPattern(value: Owned) { this.fields.set('pattern', unspaced(this.claimChild(value))) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b8d02e7c5e7..ed3ec8de8ffc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,15 +157,6 @@ importers: '@internationalized/date': specifier: ^3.5.5 version: 3.5.5 - '@lexical/link': - specifier: ^0.16.0 - version: 0.16.0 - '@lexical/plain-text': - specifier: ^0.16.0 - version: 0.16.0 - '@lexical/utils': - specifier: ^0.16.0 - version: 0.16.0 '@lezer/common': specifier: ^1.1.0 version: 1.2.1 @@ -253,9 +244,6 @@ importers: isomorphic-ws: specifier: ^5.0.0 version: 5.0.0(ws@8.18.0) - lexical: - specifier: ^0.16.0 - version: 0.16.0 lib0: specifier: ^0.2.85 version: 0.2.94 @@ -1879,30 +1867,6 @@ packages: '@json-schema-tools/traverse@1.10.4': resolution: {integrity: sha512-9e42zjhLIxzBONroNC4SGsTqdB877tzwH2S6lqgTav9K24kWJR9vNieeMVSuyqnY8FlclH21D8wsm/tuD9WA9Q==} - '@lexical/clipboard@0.16.0': - resolution: {integrity: sha512-eYMJ6jCXpWBVC05Mu9HLMysrBbfi++xFfsm+Yo7A6kYGrqYUhpXqjJkYnw1xdZYL3bV73Oe4ByVJuq42GU+Mqw==} - - '@lexical/html@0.16.0': - resolution: {integrity: sha512-okxn3q/1qkUpCZNEFRI39XeJj4YRjb6prm3WqZgP4d39DI1W24feeTZJjYRCW+dc3NInwFaolU3pNA2MGkjRtg==} - - '@lexical/link@0.16.0': - resolution: {integrity: sha512-ppvJSh/XGqlzbeymOiwcXJcUcrqgQqTK2QXTBAZq7JThtb0WsJxYd2CSLSN+Ycu23prnwqOqILcU0+34+gAVFw==} - - '@lexical/list@0.16.0': - resolution: {integrity: sha512-nBx/DMM7nCgnOzo1JyNnVaIrk/Xi5wIPNi8jixrEV6w9Om2K6dHutn/79Xzp2dQlNGSLHEDjky6N2RyFgmXh0g==} - - '@lexical/plain-text@0.16.0': - resolution: {integrity: sha512-BK7/GSOZUHRJTbNPkpb9a/xN9z+FBCdunTsZhnOY8pQ7IKws3kuMO2Tk1zXfTd882ZNAxFdDKNdLYDSeufrKpw==} - - '@lexical/selection@0.16.0': - resolution: {integrity: sha512-trT9gQVJ2j6AwAe7tHJ30SRuxCpV6yR9LFtggxphHsXSvJYnoHC0CXh1TF2jHl8Gd5OsdWseexGLBE4Y0V3gwQ==} - - '@lexical/table@0.16.0': - resolution: {integrity: sha512-A66K779kxdr0yH2RwT2itsMnkzyFLFNPXyiWGLobCH8ON4QPuBouZvjbRHBe8Pe64yJ0c1bRDxSbTqUi9Wt3Gg==} - - '@lexical/utils@0.16.0': - resolution: {integrity: sha512-GWmFEmd7o3GHqJBaEwzuZQbfTNI3Gg8ReGuHMHABgrkhZ8j2NggoRBlxsQLG0f7BewfTMVwbye22yBPq78775w==} - '@lezer/common@1.2.1': resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==} @@ -5708,9 +5672,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lexical@0.16.0: - resolution: {integrity: sha512-Skn45Qhriazq4fpAtwnAB11U//GKc4vjzx54xsV3TkDLDvWpbL4Z9TNRwRoN3g7w8AkWnqjeOSODKkrjgfRSrg==} - lib0@0.2.94: resolution: {integrity: sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==} engines: {node: '>=16'} @@ -9300,53 +9261,6 @@ snapshots: '@json-schema-tools/traverse@1.10.4': {} - '@lexical/clipboard@0.16.0': - dependencies: - '@lexical/html': 0.16.0 - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/html@0.16.0': - dependencies: - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/link@0.16.0': - dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/list@0.16.0': - dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/plain-text@0.16.0': - dependencies: - '@lexical/clipboard': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/selection@0.16.0': - dependencies: - lexical: 0.16.0 - - '@lexical/table@0.16.0': - dependencies: - '@lexical/utils': 0.16.0 - lexical: 0.16.0 - - '@lexical/utils@0.16.0': - dependencies: - '@lexical/list': 0.16.0 - '@lexical/selection': 0.16.0 - '@lexical/table': 0.16.0 - lexical: 0.16.0 - '@lezer/common@1.2.1': {} '@lezer/css@1.1.9': @@ -14297,8 +14211,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lexical@0.16.0: {} - lib0@0.2.94: dependencies: isomorphic.js: 0.2.5