From 3a33e81990e4f6135a460f0671ba2573ca911153 Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Wed, 20 Nov 2024 02:40:24 -0800 Subject: [PATCH] Render tables in documentation. (#11564) * Render tables in documentation. Also: - Separate parser for our flavor of Markdown from the CodeMirror integration; move the parser into ydoc-shared and use for Markdown line-wrapping. - Introduce our own version of yCollab extension; initially just the upstream version translated to Typescript and our code style. - Refactor CodeEditor. * CHANGELOG, prettier * Apply @farmaazon review. * Fix * Lint * Cleanup * Integration tests for GraphNodeComment Also a little refactoring in preparation for new implementation. * Workaround stuck CI * Revert "Workaround stuck CI" This reverts commit 74313842baa15d12c1a8be0529a16aa8ea204d83. * Fix merge --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 + LICENSE | 7 + app/gui/e2e/project-view/locate.ts | 1 + .../e2e/project-view/nodeClipboard.spec.ts | 12 +- app/gui/e2e/project-view/nodeComments.spec.ts | 75 ++++ app/gui/e2e/project-view/undoRedo.spec.ts | 3 +- app/gui/package.json | 4 +- .../project-view/components/CodeEditor.vue | 381 +----------------- .../components/CodeEditor/CodeEditorImpl.vue | 123 ++++++ .../components/CodeEditor/codemirror.ts | 207 ---------- .../components/CodeEditor/diagnostics.ts | 139 +++++++ .../components/CodeEditor/ensoSyntax.ts | 116 ++++++ .../components/CodeEditor/sync.ts | 123 ++++++ .../components/CodeEditor/tooltips.ts | 106 +++++ .../components/DocumentationEditor.vue | 2 +- .../components/MarkdownEditor.vue | 13 +- .../MarkdownEditor/MarkdownEditorImpl.vue | 81 ++-- .../components/MarkdownEditor/TableEditor.vue | 74 ++++ .../components/MarkdownEditor/markdown.ts | 95 ++++- .../MarkdownEditor/markdown/decoration.ts | 69 +++- .../{ => codemirror}/EditorRoot.vue | 12 + .../components/codemirror/testSupport.ts | 28 ++ .../components/codemirror/yCollab/index.ts | 65 +++ .../components/codemirror/yCollab/y-range.ts | 32 ++ .../codemirror/yCollab/y-remote-selections.ts | 264 ++++++++++++ .../components/codemirror/yCollab/y-sync.ts | 156 +++++++ .../codemirror/yCollab/y-undomanager.ts | 138 +++++++ .../codemirror/yCollab/yjsTypes.d.ts | 28 ++ .../stores/project/executionContext.ts | 1 - app/licenses/MIT-yCollab-LICENSE | 22 + app/ydoc-shared/package.json | 2 + .../src/ast/__tests__/documentation.test.ts | 24 +- app/ydoc-shared/src/ast/documentation.ts | 183 ++++++--- .../src/ast/ensoMarkdown.ts} | 54 +-- .../src/ast/lezerMarkdown.d.ts} | 0 app/ydoc-shared/src/ast/parse.ts | 35 ++ app/ydoc-shared/src/ast/syncToCode.ts | 61 ++- app/ydoc-shared/src/ast/tree.ts | 7 +- pnpm-lock.yaml | 26 +- 39 files changed, 1977 insertions(+), 794 deletions(-) create mode 100644 app/gui/e2e/project-view/nodeComments.spec.ts create mode 100644 app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue delete mode 100644 app/gui/src/project-view/components/CodeEditor/codemirror.ts create mode 100644 app/gui/src/project-view/components/CodeEditor/diagnostics.ts create mode 100644 app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts create mode 100644 app/gui/src/project-view/components/CodeEditor/sync.ts create mode 100644 app/gui/src/project-view/components/CodeEditor/tooltips.ts create mode 100644 app/gui/src/project-view/components/MarkdownEditor/TableEditor.vue rename app/gui/src/project-view/components/{ => codemirror}/EditorRoot.vue (70%) create mode 100644 app/gui/src/project-view/components/codemirror/testSupport.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/index.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/y-range.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/y-remote-selections.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/y-sync.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/y-undomanager.ts create mode 100644 app/gui/src/project-view/components/codemirror/yCollab/yjsTypes.d.ts create mode 100644 app/licenses/MIT-yCollab-LICENSE rename app/{gui/src/project-view/components/MarkdownEditor/markdown/parse.ts => ydoc-shared/src/ast/ensoMarkdown.ts} (94%) rename app/{gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts => ydoc-shared/src/ast/lezerMarkdown.d.ts} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f31e9a856fa0..e608d050a646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ clipboard or by drag'n'dropping image files. - ["Write" button in component menu allows to evaluate it separately from the rest of the workflow][11523]. +- [The documentation editor can now display tables][11564] [11151]: https://github.com/enso-org/enso/pull/11151 [11271]: https://github.com/enso-org/enso/pull/11271 @@ -46,6 +47,7 @@ [11469]: https://github.com/enso-org/enso/pull/11469 [11547]: https://github.com/enso-org/enso/pull/11547 [11523]: https://github.com/enso-org/enso/pull/11523 +[11564]: https://github.com/enso-org/enso/pull/11564 #### Enso Standard Library diff --git a/LICENSE b/LICENSE index 261eeb9e9f8b..334b4ede481a 100644 --- a/LICENSE +++ b/LICENSE @@ -199,3 +199,10 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +--- + +This project includes components that are licensed under the MIT license. The +full text of the MIT license and its copyright notice can be found in the +`app/licenses/` directory. + diff --git a/app/gui/e2e/project-view/locate.ts b/app/gui/e2e/project-view/locate.ts index 134a65144bf8..21df0cd0c5d8 100644 --- a/app/gui/e2e/project-view/locate.ts +++ b/app/gui/e2e/project-view/locate.ts @@ -85,6 +85,7 @@ export const componentBrowser = componentLocator('.ComponentBrowser') export const nodeOutputPort = componentLocator('.outputPortHoverArea') export const smallPlusButton = componentLocator('.SmallPlusButton') export const editorRoot = componentLocator('.EditorRoot') +export const nodeComment = componentLocator('.GraphNodeComment div[contentEditable]') /** * A not-selected variant of Component Browser Entry. diff --git a/app/gui/e2e/project-view/nodeClipboard.spec.ts b/app/gui/e2e/project-view/nodeClipboard.spec.ts index 155b4b25bd27..2b061f3b9e25 100644 --- a/app/gui/e2e/project-view/nodeClipboard.spec.ts +++ b/app/gui/e2e/project-view/nodeClipboard.spec.ts @@ -33,8 +33,8 @@ test('Copy node with comment', async ({ page }) => { // Check state before operation. const originalNodes = await locate.graphNode(page).count() - await expect(page.locator('.GraphNodeComment')).toExist() - const originalNodeComments = await page.locator('.GraphNodeComment').count() + await expect(locate.nodeComment(page)).toExist() + const originalNodeComments = await locate.nodeComment(page).count() // Select a node. const nodeToCopy = locate.graphNodeByBinding(page, 'final') @@ -48,7 +48,7 @@ test('Copy node with comment', async ({ page }) => { // Node and comment have been copied. await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1) - await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1) + await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1) }) test('Copy multiple nodes', async ({ page }) => { @@ -56,8 +56,8 @@ test('Copy multiple nodes', async ({ page }) => { // Check state before operation. const originalNodes = await locate.graphNode(page).count() - await expect(page.locator('.GraphNodeComment')).toExist() - const originalNodeComments = await page.locator('.GraphNodeComment').count() + await expect(locate.nodeComment(page)).toExist() + const originalNodeComments = await locate.nodeComment(page).count() // Select some nodes. const node1 = locate.graphNodeByBinding(page, 'final') @@ -76,7 +76,7 @@ test('Copy multiple nodes', async ({ page }) => { // Nodes and comment have been copied. await expect(locate.graphNode(page)).toHaveCount(originalNodes + 2) // `final` node has a comment. - await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1) + await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1) // Check that two copied nodes are isolated, i.e. connected to each other, not original nodes. await expect(locate.graphNodeByBinding(page, 'prod1')).toBeVisible() await expect(locate.graphNodeByBinding(page, 'final1')).toBeVisible() diff --git a/app/gui/e2e/project-view/nodeComments.spec.ts b/app/gui/e2e/project-view/nodeComments.spec.ts new file mode 100644 index 000000000000..e2835086de1a --- /dev/null +++ b/app/gui/e2e/project-view/nodeComments.spec.ts @@ -0,0 +1,75 @@ +import test from 'playwright/test' +import * as actions from './actions' +import { expect } from './customExpect' +import { CONTROL_KEY } from './keyboard' +import * as locate from './locate' + +test('Edit comment by click', async ({ page }) => { + await actions.goToGraph(page) + const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final')) + await expect(nodeComment).toHaveText('This node can be entered') + + await nodeComment.click() + await page.keyboard.press(`${CONTROL_KEY}+A`) + const NEW_COMMENT = 'New comment text' + await nodeComment.fill(NEW_COMMENT) + await page.keyboard.press(`Enter`) + await expect(nodeComment).not.toBeFocused() + await expect(nodeComment).toHaveText(NEW_COMMENT) +}) + +test('Start editing comment via menu', async ({ page }) => { + await actions.goToGraph(page) + const node = locate.graphNodeByBinding(page, 'final') + await node.click() + await locate.circularMenu(node).getByRole('button', { name: 'More' }).click() + await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click() + await expect(locate.nodeComment(node)).toBeFocused() +}) + +test('Add new comment via menu', async ({ page }) => { + await actions.goToGraph(page) + const INITIAL_NODE_COMMENTS = 1 + await expect(locate.nodeComment(page)).toHaveCount(INITIAL_NODE_COMMENTS) + const node = locate.graphNodeByBinding(page, 'data') + const nodeComment = locate.nodeComment(node) + + await node.click() + await locate.circularMenu(node).getByRole('button', { name: 'More' }).click() + await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click() + await expect(locate.nodeComment(node)).toBeFocused() + const NEW_COMMENT = 'New comment text' + await nodeComment.fill(NEW_COMMENT) + await page.keyboard.press(`Enter`) + await expect(nodeComment).not.toBeFocused() + await expect(nodeComment).toHaveText(NEW_COMMENT) + await expect(locate.nodeComment(page)).toHaveCount(INITIAL_NODE_COMMENTS + 1) +}) + +test('Delete comment by clearing text', async ({ page }) => { + await actions.goToGraph(page) + const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final')) + await expect(nodeComment).toHaveText('This node can be entered') + + await nodeComment.click() + await page.keyboard.press(`${CONTROL_KEY}+A`) + await page.keyboard.press(`Delete`) + await page.keyboard.press(`Enter`) + await expect(nodeComment).not.toExist() +}) + +test('URL added to comment is rendered as link', async ({ page }) => { + await actions.goToGraph(page) + const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final')) + await expect(nodeComment).toHaveText('This node can be entered') + await expect(nodeComment.locator('a')).not.toExist() + + await nodeComment.click() + await page.keyboard.press(`${CONTROL_KEY}+A`) + const NEW_COMMENT = "Here's a URL: https://example.com" + await nodeComment.fill(NEW_COMMENT) + await page.keyboard.press(`Enter`) + await expect(nodeComment).not.toBeFocused() + await expect(nodeComment).toHaveText(NEW_COMMENT) + await expect(nodeComment.locator('a')).toHaveCount(1) +}) diff --git a/app/gui/e2e/project-view/undoRedo.spec.ts b/app/gui/e2e/project-view/undoRedo.spec.ts index 279f5af660ef..094777296314 100644 --- a/app/gui/e2e/project-view/undoRedo.spec.ts +++ b/app/gui/e2e/project-view/undoRedo.spec.ts @@ -44,7 +44,8 @@ test('Removing node', async ({ page }) => { await page.keyboard.press(`${CONTROL_KEY}+Z`) await expect(locate.graphNode(page)).toHaveCount(nodesCount) await expect(deletedNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'func1', 'prod']) - await expect(deletedNode.locator('.GraphNodeComment')).toHaveText('This node can be entered') + await expect(locate.nodeComment(deletedNode)).toHaveText('This node can be entered') + const restoredBBox = await deletedNode.boundingBox() expect(restoredBBox).toEqual(deletedNodeBBox) diff --git a/app/gui/package.json b/app/gui/package.json index 334d6af3dce4..1ad62333a37c 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -22,7 +22,7 @@ "build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build", "preview": "vite preview", "//": "max-warnings set to 41 to match the amount of warnings introduced by the new react compiler. Eventual goal is to remove all the warnings.", - "lint": "eslint . --max-warnings=41", + "lint": "eslint . --max-warnings=39", "format": "prettier --version && prettier --write src/ && eslint . --fix", "dev:vite": "vite", "test": "corepack pnpm run /^^^^test:.*/", @@ -94,7 +94,6 @@ "@lexical/plain-text": "^0.16.0", "@lexical/utils": "^0.16.0", "@lezer/common": "^1.1.0", - "@lezer/markdown": "^1.3.1", "@lezer/highlight": "^1.1.6", "@noble/hashes": "^1.4.0", "@vueuse/core": "^10.4.1", @@ -118,7 +117,6 @@ "veaury": "^2.3.18", "vue": "^3.5.2", "vue-component-type-helpers": "^2.0.29", - "y-codemirror.next": "^0.3.2", "y-protocols": "^1.0.5", "y-textarea": "^1.0.0", "y-websocket": "^1.5.0", diff --git a/app/gui/src/project-view/components/CodeEditor.vue b/app/gui/src/project-view/components/CodeEditor.vue index 6cd00bb2cad3..98fa092c56db 100644 --- a/app/gui/src/project-view/components/CodeEditor.vue +++ b/app/gui/src/project-view/components/CodeEditor.vue @@ -1,382 +1,13 @@ - - diff --git a/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue new file mode 100644 index 000000000000..28a6e0159f2a --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/CodeEditorImpl.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/app/gui/src/project-view/components/CodeEditor/codemirror.ts b/app/gui/src/project-view/components/CodeEditor/codemirror.ts deleted file mode 100644 index 2a8edd687bfa..000000000000 --- a/app/gui/src/project-view/components/CodeEditor/codemirror.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * @file This module is a collection of codemirror related imports that are intended to be loaded - * asynchronously using a single dynamic import, allowing for code splitting. - */ - -export { defaultKeymap } from '@codemirror/commands' -export { - bracketMatching, - defaultHighlightStyle, - foldGutter, - foldNodeProp, - syntaxHighlighting, -} from '@codemirror/language' -export { forceLinting, lintGutter, linter, type Diagnostic } from '@codemirror/lint' -export { highlightSelectionMatches } from '@codemirror/search' -export { Annotation, EditorState, StateEffect, StateField, type ChangeSet } from '@codemirror/state' -export { EditorView, tooltips, type TooltipView } from '@codemirror/view' -export { type Highlighter } from '@lezer/highlight' -export { minimalSetup } from 'codemirror' -export { yCollab } from 'y-codemirror.next' -import { RawAstExtended } from '@/util/ast/extended' -import { RawAst } from '@/util/ast/raw' -import { - Language, - LanguageSupport, - defineLanguageFacet, - foldNodeProp, - languageDataProp, - syntaxTree, -} from '@codemirror/language' -import { type Diagnostic } from '@codemirror/lint' -import type { ChangeSpec } from '@codemirror/state' -import { hoverTooltip as originalHoverTooltip, type TooltipView } from '@codemirror/view' -import { - NodeProp, - NodeSet, - NodeType, - Parser, - Tree, - type Input, - type PartialParse, - type SyntaxNode, -} from '@lezer/common' -import { styleTags, tags } from '@lezer/highlight' -import { EditorView } from 'codemirror' -import * as iter from 'enso-common/src/utilities/data/iter' -import type { Diagnostic as LSDiagnostic } from 'ydoc-shared/languageServerTypes' -import type { SourceRangeEdit } from 'ydoc-shared/util/data/text' - -/** TODO: Add docs */ -export function lsDiagnosticsToCMDiagnostics( - source: string, - diagnostics: LSDiagnostic[], -): Diagnostic[] { - if (!diagnostics.length) return [] - const results: Diagnostic[] = [] - let pos = 0 - const lineStartIndices = [] - for (const line of source.split('\n')) { - lineStartIndices.push(pos) - pos += line.length + 1 - } - for (const diagnostic of diagnostics) { - if (!diagnostic.location) continue - const from = - (lineStartIndices[diagnostic.location.start.line] ?? 0) + diagnostic.location.start.character - const to = - (lineStartIndices[diagnostic.location.end.line] ?? 0) + diagnostic.location.end.character - if (to > source.length || from > source.length) { - // Suppress temporary errors if the source is not the version of the document the LS is reporting diagnostics for. - continue - } - const severity = - diagnostic.kind === 'Error' ? 'error' - : diagnostic.kind === 'Warning' ? 'warning' - : 'info' - results.push({ from, to, message: diagnostic.message, severity }) - } - return results -} - -type AstNode = RawAstExtended - -const nodeTypes: NodeType[] = [ - ...RawAst.Tree.typeNames.map((name, id) => NodeType.define({ id, name })), - ...RawAst.Token.typeNames.map((name, id) => - NodeType.define({ id: id + RawAst.Tree.typeNames.length, name: 'Token' + name }), - ), -] - -const nodeSet = new NodeSet(nodeTypes).extend( - styleTags({ - Ident: tags.variableName, - 'Private!': tags.variableName, - Number: tags.number, - 'Wildcard!': tags.variableName, - 'TextLiteral!': tags.string, - OprApp: tags.operator, - TokenOperator: tags.operator, - 'Assignment/TokenOperator': tags.definitionOperator, - UnaryOprApp: tags.operator, - 'Function/Ident': tags.function(tags.variableName), - ForeignFunction: tags.function(tags.variableName), - 'Import/TokenIdent': tags.function(tags.moduleKeyword), - Export: tags.function(tags.moduleKeyword), - Lambda: tags.function(tags.variableName), - Documented: tags.docComment, - ConstructorDefinition: tags.function(tags.variableName), - }), - foldNodeProp.add({ - Function: (node) => node.lastChild, - ArgumentBlockApplication: (node) => node, - OperatorBlockApplication: (node) => node, - }), -) - -export const astProp = new NodeProp({ perNode: true }) - -function astToCodeMirrorTree( - nodeSet: NodeSet, - ast: AstNode, - props?: readonly [number | NodeProp, any][] | undefined, -): Tree { - const [start, end] = ast.span() - const children = ast.children() - - const childrenToConvert = iter.tryGetSoleValue(children)?.isToken() ? [] : children - - const tree = new Tree( - nodeSet.types[ast.inner.type + (ast.isToken() ? RawAst.Tree.typeNames.length : 0)]!, - childrenToConvert.map((child) => astToCodeMirrorTree(nodeSet, child)), - childrenToConvert.map((child) => child.span()[0] - start), - end - start, - [...(props ?? []), [astProp, ast]], - ) - return tree -} - -const facet = defineLanguageFacet() - -class EnsoParser extends Parser { - nodeSet - constructor() { - super() - this.nodeSet = nodeSet - } - cachedCode: string | undefined - cachedTree: Tree | undefined - createParse(input: Input): PartialParse { - return { - parsedPos: input.length, - stopAt: () => {}, - stoppedAt: null, - advance: () => { - const code = input.read(0, input.length) - if (code !== this.cachedCode || this.cachedTree == null) { - this.cachedCode = code - const ast = RawAstExtended.parse(code) - this.cachedTree = astToCodeMirrorTree(this.nodeSet, ast, [[languageDataProp, facet]]) - } - return this.cachedTree - }, - } - } -} - -class EnsoLanguage extends Language { - constructor() { - super(facet, new EnsoParser()) - } -} - -const ensoLanguage = new EnsoLanguage() - -/** TODO: Add docs */ -export function enso() { - return new LanguageSupport(ensoLanguage) -} - -/** TODO: Add docs */ -export function hoverTooltip( - create: ( - ast: AstNode, - syntax: SyntaxNode, - ) => TooltipView | ((view: EditorView) => TooltipView) | null | undefined, -) { - return originalHoverTooltip((view, pos, side) => { - const syntaxNode = syntaxTree(view.state).resolveInner(pos, side) - const astNode = syntaxNode.tree?.prop(astProp) - if (astNode == null) return null - const domOrCreate = create(astNode, syntaxNode) - if (domOrCreate == null) return null - - return { - pos: syntaxNode.from, - end: syntaxNode.to, - above: true, - arrow: true, - create: typeof domOrCreate !== 'function' ? () => domOrCreate : domOrCreate, - } - }) -} - -/** TODO: Add docs */ -export function textEditToChangeSpec({ range: [from, to], insert }: SourceRangeEdit): ChangeSpec { - return { from, to, insert } -} diff --git a/app/gui/src/project-view/components/CodeEditor/diagnostics.ts b/app/gui/src/project-view/components/CodeEditor/diagnostics.ts new file mode 100644 index 000000000000..55005f1c9ffe --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/diagnostics.ts @@ -0,0 +1,139 @@ +import { type GraphStore } from '@/stores/graph' +import { type ProjectStore } from '@/stores/project' +import { type Diagnostic, forceLinting, linter } from '@codemirror/lint' +import { type Extension, StateEffect, StateField } from '@codemirror/state' +import { type EditorView } from '@codemirror/view' +import * as iter from 'enso-common/src/utilities/data/iter' +import { computed, shallowRef, watch } from 'vue' +import { type Diagnostic as LSDiagnostic, type Position } from 'ydoc-shared/languageServerTypes' + +const executionContextDiagnostics = shallowRef([]) + +// Effect that can be applied to the document to invalidate the linter state. +const diagnosticsUpdated = StateEffect.define() +// State value that is perturbed by any `diagnosticsUpdated` effect. +const diagnosticsVersion = StateField.define({ + create: (_state) => 0, + update: (value, transaction) => { + for (const effect of transaction.effects) { + if (effect.is(diagnosticsUpdated)) value += 1 + } + return value + }, +}) + +/** Given a text, indexes it and returns a function for converting between different ways of identifying positions. */ +function stringPosConverter(text: string) { + let pos = 0 + const lineStartIndex: number[] = [] + for (const line of text.split('\n')) { + lineStartIndex.push(pos) + pos += line.length + 1 + } + const length = text.length + + function lineColToIndex({ + line, + character, + }: { + line: number + character: number + }): number | undefined { + const startIx = lineStartIndex[line] + if (startIx == null) return + const ix = startIx + character + if (ix > length) return + return ix + } + + return { lineColToIndex } +} + +/** Convert the Language Server's diagnostics to CodeMirror diagnostics. */ +function lsDiagnosticsToCMDiagnostics( + diagnostics: LSDiagnostic[], + lineColToIndex: (lineCol: Position) => number | undefined, +) { + const results: Diagnostic[] = [] + for (const diagnostic of diagnostics) { + if (!diagnostic.location) continue + const from = lineColToIndex(diagnostic.location.start) + const to = lineColToIndex(diagnostic.location.end) + if (to == null || from == null) { + // Suppress temporary errors if the source is not the version of the document the LS is reporting diagnostics for. + continue + } + const severity = + diagnostic.kind === 'Error' ? 'error' + : diagnostic.kind === 'Warning' ? 'warning' + : 'info' + results.push({ from, to, message: diagnostic.message, severity }) + } + return results +} + +/** + * CodeMirror extension providing diagnostics for an Enso module. Provides CodeMirror diagnostics based on dataflow + * errors, and diagnostics the LS provided in an `executionStatus` message. + */ +export function useEnsoDiagnostics( + projectStore: Pick, + graphStore: Pick, + editorView: EditorView, +): Extension { + const expressionUpdatesDiagnostics = computed(() => { + const updates = projectStore.computedValueRegistry.db + const panics = updates.type.reverseLookup('Panic') + const errors = updates.type.reverseLookup('DataflowError') + const diagnostics: Diagnostic[] = [] + for (const externalId of iter.chain(panics, errors)) { + const update = updates.get(externalId) + if (!update) continue + const astId = graphStore.db.idFromExternal(externalId) + if (!astId) continue + const span = graphStore.moduleSource.getSpan(astId) + if (!span) continue + const [from, to] = span + switch (update.payload.type) { + case 'Panic': { + diagnostics.push({ from, to, message: update.payload.message, severity: 'error' }) + break + } + case 'DataflowError': { + const error = projectStore.dataflowErrors.lookup(externalId) + if (error?.value?.message) { + diagnostics.push({ from, to, message: error.value.message, severity: 'error' }) + } + break + } + } + } + return diagnostics + }) + watch([executionContextDiagnostics, expressionUpdatesDiagnostics], () => { + editorView.dispatch({ effects: diagnosticsUpdated.of(null) }) + forceLinting(editorView) + }) + // The LS protocol doesn't identify what version of the file updates are in reference to. When diagnostics are + // received from the LS, we map them to the text assuming that they are applicable to the current version of the + // module. This will be correct if there is no one else editing, and we aren't editing faster than the LS can send + // updates. Typing too quickly can result in incorrect ranges, but at idle it should correct itself when we receive + // new diagnostics. + watch( + () => projectStore.diagnostics, + (diagnostics) => { + const { lineColToIndex } = stringPosConverter(graphStore.moduleSource.text) + executionContextDiagnostics.value = lsDiagnosticsToCMDiagnostics(diagnostics, lineColToIndex) + }, + ) + return [ + diagnosticsVersion, + linter(() => [...executionContextDiagnostics.value, ...expressionUpdatesDiagnostics.value], { + needsRefresh(update) { + return ( + update.state.field(diagnosticsVersion) !== update.startState.field(diagnosticsVersion) + ) + }, + }), + ] +} diff --git a/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts b/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts new file mode 100644 index 000000000000..26108f80d759 --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/ensoSyntax.ts @@ -0,0 +1,116 @@ +import { RawAstExtended } from '@/util/ast/extended' +import { RawAst } from '@/util/ast/raw' +import { + defineLanguageFacet, + foldNodeProp, + Language, + languageDataProp, + LanguageSupport, +} from '@codemirror/language' +import { + type Input, + NodeProp, + NodeSet, + NodeType, + Parser, + type PartialParse, + Tree, +} from '@lezer/common' +import { styleTags, tags } from '@lezer/highlight' +import * as iter from 'enso-common/src/utilities/data/iter' + +const nodeTypes: NodeType[] = [ + ...RawAst.Tree.typeNames.map((name, id) => NodeType.define({ id, name })), + ...RawAst.Token.typeNames.map((name, id) => + NodeType.define({ id: id + RawAst.Tree.typeNames.length, name: 'Token' + name }), + ), +] + +const nodeSet = new NodeSet(nodeTypes).extend( + styleTags({ + Ident: tags.variableName, + 'Private!': tags.variableName, + Number: tags.number, + 'Wildcard!': tags.variableName, + 'TextLiteral!': tags.string, + OprApp: tags.operator, + TokenOperator: tags.operator, + 'Assignment/TokenOperator': tags.definitionOperator, + UnaryOprApp: tags.operator, + 'Function/Ident': tags.function(tags.variableName), + ForeignFunction: tags.function(tags.variableName), + 'Import/TokenIdent': tags.function(tags.moduleKeyword), + Export: tags.function(tags.moduleKeyword), + Lambda: tags.function(tags.variableName), + Documented: tags.docComment, + ConstructorDefinition: tags.function(tags.variableName), + }), + foldNodeProp.add({ + Function: (node) => node.lastChild, + ArgumentBlockApplication: (node) => node, + OperatorBlockApplication: (node) => node, + }), +) + +type AstNode = RawAstExtended +const astProp = new NodeProp({ perNode: true }) + +function astToCodeMirrorTree( + nodeSet: NodeSet, + ast: AstNode, + props?: readonly [number | NodeProp, any][] | undefined, +): Tree { + const [start, end] = ast.span() + const children = ast.children() + + const childrenToConvert = iter.tryGetSoleValue(children)?.isToken() ? [] : children + + return new Tree( + nodeSet.types[ast.inner.type + (ast.isToken() ? RawAst.Tree.typeNames.length : 0)]!, + childrenToConvert.map((child) => astToCodeMirrorTree(nodeSet, child)), + childrenToConvert.map((child) => child.span()[0] - start), + end - start, + [...(props ?? []), [astProp, ast]], + ) +} + +const facet = defineLanguageFacet() + +class EnsoParser extends Parser { + nodeSet + constructor() { + super() + this.nodeSet = nodeSet + } + cachedCode: string | undefined + cachedTree: Tree | undefined + createParse(input: Input): PartialParse { + return { + parsedPos: input.length, + stopAt: () => {}, + stoppedAt: null, + advance: () => { + const code = input.read(0, input.length) + if (code !== this.cachedCode || this.cachedTree == null) { + this.cachedCode = code + const ast = RawAstExtended.parse(code) + this.cachedTree = astToCodeMirrorTree(this.nodeSet, ast, [[languageDataProp, facet]]) + } + return this.cachedTree + }, + } + } +} + +class EnsoLanguage extends Language { + constructor() { + super(facet, new EnsoParser()) + } +} + +const ensoLanguage = new EnsoLanguage() + +/** TODO: Add docs */ +export function ensoSyntax() { + return new LanguageSupport(ensoLanguage) +} diff --git a/app/gui/src/project-view/components/CodeEditor/sync.ts b/app/gui/src/project-view/components/CodeEditor/sync.ts new file mode 100644 index 000000000000..de92ea2e3756 --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/sync.ts @@ -0,0 +1,123 @@ +import type { GraphStore } from '@/stores/graph' +import { Annotation, ChangeSet, type ChangeSpec } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { createDebouncer } from 'lib0/eventloop' +import { onUnmounted } from 'vue' +import { MutableModule } from 'ydoc-shared/ast' +import { SourceRangeEdit, textChangeToEdits } from 'ydoc-shared/util/data/text' +import type { Origin } from 'ydoc-shared/yjsModel' + +function changeSetToTextEdits(changes: ChangeSet) { + const textEdits = new Array() + changes.iterChanges((from, to, _fromB, _toB, insert) => + textEdits.push({ range: [from, to], insert: insert.toString() }), + ) + return textEdits +} + +function textEditToChangeSpec({ range: [from, to], insert }: SourceRangeEdit): ChangeSpec { + return { from, to, insert } +} + +// Indicates a change updating the text to correspond to the given module state. +const synchronizedModule = Annotation.define() + +/** @returns A CodeMirror Extension that synchronizes the editor state with the AST of an Enso module. */ +export function useEnsoSourceSync( + graphStore: Pick, + editorView: EditorView, +) { + let pendingChanges: ChangeSet | undefined + let currentModule: MutableModule | undefined + + const debounceUpdates = createDebouncer(0) + const updateListener = EditorView.updateListener.of((update) => { + for (const transaction of update.transactions) { + const newModule = transaction.annotation(synchronizedModule) + if (newModule) { + // Flush the pipeline of edits that were based on the old module. + commitPendingChanges() + currentModule = newModule + } else if (transaction.docChanged && currentModule) { + pendingChanges = + pendingChanges ? pendingChanges.compose(transaction.changes) : transaction.changes + // Defer the update until after pending events have been processed, so that if changes are arriving faster + // than we would be able to apply them individually we coalesce them to keep up. + debounceUpdates(commitPendingChanges) + } + } + }) + + /** Set the editor contents the current module state, discarding any pending editor-initiated changes. */ + function resetView() { + pendingChanges = undefined + currentModule = undefined + const viewText = editorView.state.doc.toString() + const code = graphStore.moduleSource.text + const changes = textChangeToEdits(viewText, code).map(textEditToChangeSpec) + console.info('Resetting the editor to the module code.', changes) + editorView.dispatch({ + changes, + annotations: synchronizedModule.of(graphStore.startEdit()), + }) + } + + function checkSync() { + const code = graphStore.viewModule.root()?.code() ?? '' + const viewText = editorView.state.doc.toString() + const uncommitted = textChangeToEdits(code, viewText).map(textEditToChangeSpec) + if (uncommitted.length > 0) { + console.warn(`Module source was not synced to editor content\n${code}`, uncommitted) + } + } + + /** Apply any pending changes to the currently-synchronized module, clearing the set of pending changes. */ + function commitPendingChanges() { + if (!pendingChanges || !currentModule) return + const changes = pendingChanges + pendingChanges = undefined + const edits = changeSetToTextEdits(changes) + try { + currentModule.applyTextEdits(edits, graphStore.viewModule) + graphStore.commitEdit(currentModule, undefined, 'local:userAction:CodeEditor') + checkSync() + } catch (error) { + console.error(`Code Editor failed to modify module`, error) + resetView() + } + } + + let needResync = false + function observeSourceChange(textEdits: readonly SourceRangeEdit[], origin: Origin | undefined) { + // If we received an update from outside the Code Editor while the editor contained uncommitted changes, we cannot + // proceed incrementally; we wait for the changes to be merged as Y.Js AST updates, and then set the view to the + // resulting code. + if (needResync) { + if (!pendingChanges) { + resetView() + needResync = false + } + return + } + // When we aren't in the `needResync` state, we can ignore updates that originated in the Code Editor. + if (origin === 'local:userAction:CodeEditor') { + return + } + if (pendingChanges) { + console.info(`Deferring update (editor dirty).`) + needResync = true + return + } + + // If none of the above exit-conditions were reached, the transaction is applicable to our current state. + editorView.dispatch({ + changes: textEdits.map(textEditToChangeSpec), + annotations: synchronizedModule.of(graphStore.startEdit()), + }) + } + onUnmounted(() => graphStore.moduleSource.unobserve(observeSourceChange)) + return { + updateListener, + connectModuleListener: () => graphStore.moduleSource.observe(observeSourceChange), + } +} diff --git a/app/gui/src/project-view/components/CodeEditor/tooltips.ts b/app/gui/src/project-view/components/CodeEditor/tooltips.ts new file mode 100644 index 000000000000..70b3c3e8ae76 --- /dev/null +++ b/app/gui/src/project-view/components/CodeEditor/tooltips.ts @@ -0,0 +1,106 @@ +import type { GraphStore, NodeId } from '@/stores/graph' +import { type SuggestionDbStore } from '@/stores/suggestionDatabase' +import { type RawAstExtended } from '@/util/ast/extended' +import { RawAst } from '@/util/ast/raw' +import { qnJoin, tryQualifiedName } from '@/util/qualifiedName' +import { syntaxTree } from '@codemirror/language' +import { type Extension } from '@codemirror/state' +import { + type EditorView, + hoverTooltip as originalHoverTooltip, + tooltips, + type TooltipView, +} from '@codemirror/view' +import { NodeProp, type SyntaxNode } from '@lezer/common' +import { unwrap } from 'ydoc-shared/util/data/result' +import { rangeEncloses } from 'ydoc-shared/yjsModel' + +type AstNode = RawAstExtended +const astProp = new NodeProp({ perNode: true }) + +/** TODO: Add docs */ +function hoverTooltip( + create: ( + ast: AstNode, + syntax: SyntaxNode, + ) => TooltipView | ((view: EditorView) => TooltipView) | null | undefined, +): Extension { + return [ + tooltips({ position: 'absolute' }), + originalHoverTooltip((view, pos, side) => { + const syntaxNode = syntaxTree(view.state).resolveInner(pos, side) + const astNode = syntaxNode.tree?.prop(astProp) + if (astNode == null) return null + const domOrCreate = create(astNode, syntaxNode) + if (domOrCreate == null) return null + + return { + pos: syntaxNode.from, + end: syntaxNode.to, + above: true, + arrow: true, + create: typeof domOrCreate !== 'function' ? () => domOrCreate : domOrCreate, + } + }), + ] +} + +/** @returns A CodeMirror extension that creates tooltips containing type and syntax information for Enso code. */ +export function ensoHoverTooltip( + graphStore: Pick, + suggestionDbStore: Pick, +) { + return hoverTooltip((ast, syn) => { + const dom = document.createElement('div') + const astSpan = ast.span() + let foundNode: NodeId | undefined + for (const [id, node] of graphStore.db.nodeIdToNode.entries()) { + const rootSpan = graphStore.moduleSource.getSpan(node.rootExpr.id) + if (rootSpan && rangeEncloses(rootSpan, astSpan)) { + foundNode = id + break + } + } + const expressionInfo = foundNode && graphStore.db.getExpressionInfo(foundNode) + const nodeColor = foundNode && graphStore.db.getNodeColorStyle(foundNode) + + if (foundNode != null) { + dom + .appendChild(document.createElement('div')) + .appendChild(document.createTextNode(`AST ID: ${foundNode}`)) + } + if (expressionInfo != null) { + dom + .appendChild(document.createElement('div')) + .appendChild(document.createTextNode(`Type: ${expressionInfo.typename ?? 'Unknown'}`)) + } + if (expressionInfo?.profilingInfo[0] != null) { + const profile = expressionInfo.profilingInfo[0] + const executionTime = (profile.ExecutionTime.nanoTime / 1_000_000).toFixed(3) + const text = `Execution Time: ${executionTime}ms` + dom.appendChild(document.createElement('div')).appendChild(document.createTextNode(text)) + } + + dom + .appendChild(document.createElement('div')) + .appendChild(document.createTextNode(`Syntax: ${syn.toString()}`)) + const method = expressionInfo?.methodCall?.methodPointer + if (method != null) { + const moduleName = tryQualifiedName(method.module) + const methodName = tryQualifiedName(method.name) + const qualifiedName = qnJoin(unwrap(moduleName), unwrap(methodName)) + const [id] = suggestionDbStore.entries.nameToId.lookup(qualifiedName) + const suggestionEntry = id != null ? suggestionDbStore.entries.get(id) : undefined + if (suggestionEntry != null) { + const groupNode = dom.appendChild(document.createElement('div')) + groupNode.appendChild(document.createTextNode('Group: ')) + const groupNameNode = groupNode.appendChild(document.createElement('span')) + groupNameNode.appendChild(document.createTextNode(`${method.module}.${method.name}`)) + if (nodeColor) { + groupNameNode.style.color = nodeColor + } + } + } + return { dom } + }) +} diff --git a/app/gui/src/project-view/components/DocumentationEditor.vue b/app/gui/src/project-view/components/DocumentationEditor.vue index 240aae6e4be3..d2ce830c6751 100644 --- a/app/gui/src/project-view/components/DocumentationEditor.vue +++ b/app/gui/src/project-view/components/DocumentationEditor.vue @@ -223,7 +223,7 @@ const handler = documentationEditorBindings.handler({ > diff --git a/app/gui/src/project-view/components/MarkdownEditor.vue b/app/gui/src/project-view/components/MarkdownEditor.vue index f0c4435dc7f0..fd3b587b1b47 100644 --- a/app/gui/src/project-view/components/MarkdownEditor.vue +++ b/app/gui/src/project-view/components/MarkdownEditor.vue @@ -1,11 +1,14 @@ @@ -91,24 +91,15 @@ defineExpose({ font-family: var(--font-sans); } -:deep(.cm-scroller) { - /* Prevent touchpad back gesture, which can be triggered while panning. */ - overscroll-behavior: none; +:deep(.cm-editor) { + opacity: 1; + color: black; + font-size: 12px; } :deep(img.uploading) { opacity: 0.5; } - -.EditorRoot :deep(.cm-editor) { - position: relative; - width: 100%; - height: 100%; - opacity: 1; - color: black; - font-size: 12px; - outline: none; -} diff --git a/app/gui/src/project-view/components/MarkdownEditor/TableEditor.vue b/app/gui/src/project-view/components/MarkdownEditor/TableEditor.vue new file mode 100644 index 000000000000..72be1f277634 --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/TableEditor.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown.ts index 2f5980fb2169..a01db2816c66 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown.ts @@ -1,9 +1,98 @@ import { markdownDecorators } from '@/components/MarkdownEditor/markdown/decoration' -import { markdown } from '@/components/MarkdownEditor/markdown/parse' import type { VueHost } from '@/components/VueComponentHost.vue' +import { markdown as markdownExtension } from '@codemirror/lang-markdown' +import { + defineLanguageFacet, + foldNodeProp, + foldService, + indentNodeProp, + Language, + languageDataProp, + syntaxTree, +} from '@codemirror/language' import type { Extension } from '@codemirror/state' +import { NodeProp, type NodeType, type Parser, type SyntaxNode } from '@lezer/common' +import { markdownParser } from 'ydoc-shared/ast/ensoMarkdown' -/** Markdown extension, with customizations for Enso. */ +/** CodeMirror Extension for the Enso Markdown dialect. */ export function ensoMarkdown({ vueHost }: { vueHost: VueHost }): Extension { - return [markdown(), markdownDecorators({ vueHost })] + return [ + markdownExtension({ + base: mkLang( + markdownParser.configure([ + commonmarkCodemirrorLanguageExtension, + tableCodemirrorLanguageExtension, + ]), + ), + }), + markdownDecorators({ vueHost }), + ] +} + +function mkLang(parser: Parser) { + return new Language(data, parser, [headerIndent], 'markdown') +} + +const data = defineLanguageFacet({ commentTokens: { block: { open: '' } } }) + +const headingProp = new NodeProp() + +const commonmarkCodemirrorLanguageExtension = { + props: [ + foldNodeProp.add((type) => { + return !type.is('Block') || type.is('Document') || isHeading(type) != null || isList(type) ? + undefined + : (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to }) + }), + headingProp.add(isHeading), + indentNodeProp.add({ + Document: () => null, + }), + languageDataProp.add({ + Document: data, + }), + ], +} + +function isHeading(type: NodeType) { + const match = /^(?:ATX|Setext)Heading(\d)$/.exec(type.name) + return match ? +match[1]! : undefined +} + +function isList(type: NodeType) { + return type.name == 'OrderedList' || type.name == 'BulletList' +} + +function findSectionEnd(headerNode: SyntaxNode, level: number) { + let last = headerNode + for (;;) { + const next = last.nextSibling + let heading + if (!next || ((heading = isHeading(next.type)) != null && heading <= level)) break + last = next + } + return last.to +} + +const headerIndent = foldService.of((state, start, end) => { + for ( + let node: SyntaxNode | null = syntaxTree(state).resolveInner(end, -1); + node; + node = node.parent + ) { + if (node.from < start) break + const heading = node.type.prop(headingProp) + if (heading == null) continue + const upto = findSectionEnd(node, heading) + if (upto > end) return { from: end, to: upto } + } + return null +}) + +const tableCodemirrorLanguageExtension = { + props: [ + foldNodeProp.add({ + Table: (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to }), + }), + ], } diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts index 5b87dedac2ce..668cbbd73e34 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts @@ -1,4 +1,5 @@ import DocumentationImage from '@/components/MarkdownEditor/DocumentationImage.vue' +import TableEditor from '@/components/MarkdownEditor/TableEditor.vue' import type { VueHost } from '@/components/VueComponentHost.vue' import { syntaxTree } from '@codemirror/language' import { type EditorSelection, type Extension, RangeSetBuilder, type Text } from '@codemirror/state' @@ -19,6 +20,7 @@ export function markdownDecorators({ vueHost }: { vueHost: VueHost }): Extension const stateDecorator = new TreeStateDecorator(vueHost, [ decorateImageWithClass, decorateImageWithRendered, + decorateTable, ]) const stateDecoratorExt = EditorView.decorations.compute(['doc'], (state) => stateDecorator.decorate(syntaxTree(state), state.doc), @@ -146,8 +148,6 @@ function parseLinkLike(node: SyntaxNode, doc: Text) { if (!textClose) return const urlNode = findNextSiblingNamed(textClose, 'URL') if (!urlNode) return - console.log('RANGE', urlNode.from, urlNode.to) - console.log(doc) return { textFrom: textOpen.to, textTo: textClose.from, @@ -274,3 +274,68 @@ function findNextSiblingNamed(node: SyntaxNode, name: string) { } } } + +// === Tables === + +function decorateTable( + nodeRef: SyntaxNodeRef, + doc: Text, + emitDecoration: (from: number, to: number, deco: Decoration) => void, + vueHost: VueHost, +) { + if (nodeRef.name === 'Table') { + const source = doc //.slice(nodeRef.from, nodeRef.to) + const parsed = nodeRef.node + const widget = new TableWidget({ source, parsed }, vueHost) + emitDecoration( + nodeRef.from, + nodeRef.to, + Decoration.replace({ + widget, + // Ensure the cursor is drawn relative to the content before the widget. + // If it is drawn relative to the widget, it will be hidden when the widget is hidden (i.e. during editing). + side: 1, + block: true, + }), + ) + } +} + +class TableWidget extends WidgetType { + private container: HTMLElement | undefined + private vueHostRegistration: { unregister: () => void } | undefined + + constructor( + private readonly props: { source: Text; parsed: SyntaxNode }, + private readonly vueHost: VueHost, + ) { + super() + } + + override get estimatedHeight() { + return -1 + } + + override toDOM(): HTMLElement { + if (!this.container) { + const container = markRaw(document.createElement('div')) + container.className = 'cm-table-editor' + this.vueHostRegistration = this.vueHost.register( + () => + h(TableEditor, { + source: this.props.source, + parsed: this.props.parsed, + onEdit: () => console.log('onEdit'), + }), + container, + ) + this.container = container + } + return this.container + } + + override destroy() { + this.vueHostRegistration?.unregister() + this.container = undefined + } +} diff --git a/app/gui/src/project-view/components/EditorRoot.vue b/app/gui/src/project-view/components/codemirror/EditorRoot.vue similarity index 70% rename from app/gui/src/project-view/components/EditorRoot.vue rename to app/gui/src/project-view/components/codemirror/EditorRoot.vue index dc2049147502..263783abf234 100644 --- a/app/gui/src/project-view/components/EditorRoot.vue +++ b/app/gui/src/project-view/components/codemirror/EditorRoot.vue @@ -26,4 +26,16 @@ defineExpose({ rootElement }) width: 100%; height: 100%; } + +:deep(.cm-scroller) { + /* Prevent touchpad back gesture, which can be triggered while panning. */ + overscroll-behavior: none; +} + +:deep(.cm-editor) { + position: relative; + width: 100%; + height: 100%; + outline: none; +} diff --git a/app/gui/src/project-view/components/codemirror/testSupport.ts b/app/gui/src/project-view/components/codemirror/testSupport.ts new file mode 100644 index 000000000000..b900c4baefc6 --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/testSupport.ts @@ -0,0 +1,28 @@ +import { EditorSelection } from '@codemirror/state' +import { type EditorView } from '@codemirror/view' + +/** Returns an API for the editor content, used by the integration tests. */ +export function testSupport(editorView: EditorView) { + return { + textContent: () => editorView.state.doc.toString(), + textLength: () => editorView.state.doc.length, + indexOf: (substring: string, position?: number) => + editorView.state.doc.toString().indexOf(substring, position), + placeCursor: (at: number) => { + editorView.dispatch({ selection: EditorSelection.create([EditorSelection.cursor(at)]) }) + }, + select: (from: number, to: number) => { + editorView.dispatch({ selection: EditorSelection.create([EditorSelection.range(from, to)]) }) + }, + selectAndReplace: (from: number, to: number, replaceWith: string) => { + editorView.dispatch({ selection: EditorSelection.create([EditorSelection.range(from, to)]) }) + editorView.dispatch(editorView.state.update(editorView.state.replaceSelection(replaceWith))) + }, + writeText: (text: string, from: number) => { + editorView.dispatch({ + changes: [{ from: from, insert: text }], + selection: { anchor: from + text.length }, + }) + }, + } +} diff --git a/app/gui/src/project-view/components/codemirror/yCollab/index.ts b/app/gui/src/project-view/components/codemirror/yCollab/index.ts new file mode 100644 index 000000000000..e5a9d6e5c279 --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/index.ts @@ -0,0 +1,65 @@ +/** + * @file CodeMirror extension for synchronizing with a Yjs Text object. + * Based on . Initial changes from upstream: + * - Translated from JSDoc-typed JS to Typescript. + * - Refactored for stricter typing. + * - Changes to match project code style. + */ + +import * as cmView from '@codemirror/view' +import { type Awareness } from 'y-protocols/awareness.js' +import * as Y from 'yjs' +import { YRange } from './y-range' +import { yRemoteSelections, yRemoteSelectionsTheme } from './y-remote-selections' +import { YSyncConfig, ySync, ySyncAnnotation, ySyncFacet } from './y-sync' +import { + YUndoManagerConfig, + redo, + undo, + yUndoManager, + yUndoManagerFacet, + yUndoManagerKeymap, +} from './y-undomanager' +export { + YRange, + YSyncConfig, + yRemoteSelections, + yRemoteSelectionsTheme, + ySync, + ySyncAnnotation, + ySyncFacet, + yUndoManagerKeymap, +} + +/* CodeMirror Extension for synchronizing the editor state with a {@link Y.Text}. */ +export const yCollab = ( + ytext: Y.Text & { doc: Y.Doc }, + awareness: Awareness | null, + { + undoManager = new Y.UndoManager(ytext), + }: { + /** Set to false to disable the undo-redo plugin */ + undoManager?: Y.UndoManager | false + } = {}, +) => { + const ySyncConfig = new YSyncConfig(ytext, awareness) + const plugins = [ySyncFacet.of(ySyncConfig), ySync] + if (awareness) { + plugins.push(yRemoteSelectionsTheme, yRemoteSelections) + } + if (undoManager !== false) { + // By default, only track changes that are produced by the sync plugin (local edits) + plugins.push( + yUndoManagerFacet.of(new YUndoManagerConfig(undoManager)), + yUndoManager, + cmView.EditorView.domEventHandlers({ + beforeinput(e, view) { + if (e.inputType === 'historyUndo') return undo(view) + if (e.inputType === 'historyRedo') return redo(view) + return false + }, + }), + ) + } + return plugins +} diff --git a/app/gui/src/project-view/components/codemirror/yCollab/y-range.ts b/app/gui/src/project-view/components/codemirror/yCollab/y-range.ts new file mode 100644 index 000000000000..d344e4c77934 --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/y-range.ts @@ -0,0 +1,32 @@ +import * as Y from 'yjs' + +/** + * Defines a range on text using relative positions that can be transformed back to + * absolute positions. (https://docs.yjs.dev/api/relative-positions) + */ +export class YRange { + /** TODO: Add docs */ + constructor( + readonly yanchor: Y.RelativePosition, + readonly yhead: Y.RelativePosition, + ) { + this.yanchor = yanchor + this.yhead = yhead + } + + /** TODO: Add docs */ + toJSON() { + return { + yanchor: Y.relativePositionToJSON(this.yanchor), + yhead: Y.relativePositionToJSON(this.yhead), + } + } + + /** TODO: Add docs */ + static fromJSON(json: { yanchor: unknown; yhead: unknown }) { + return new YRange( + Y.createRelativePositionFromJSON(json.yanchor), + Y.createRelativePositionFromJSON(json.yhead), + ) + } +} diff --git a/app/gui/src/project-view/components/codemirror/yCollab/y-remote-selections.ts b/app/gui/src/project-view/components/codemirror/yCollab/y-remote-selections.ts new file mode 100644 index 000000000000..27b9c354198d --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/y-remote-selections.ts @@ -0,0 +1,264 @@ +import * as cmState from '@codemirror/state' +import * as cmView from '@codemirror/view' +import * as dom from 'lib0/dom' +import * as math from 'lib0/math' +import * as pair from 'lib0/pair' +import { Awareness } from 'y-protocols/awareness.js' +import { assert } from 'ydoc-shared/util/assert' +import * as Y from 'yjs' +import { type YSyncConfig, ySyncFacet } from './y-sync' + +export const yRemoteSelectionsTheme = cmView.EditorView.baseTheme({ + '.cm-ySelection': {}, + '.cm-yLineSelection': { + padding: 0, + margin: '0px 2px 0px 4px', + }, + '.cm-ySelectionCaret': { + position: 'relative', + borderLeft: '1px solid black', + borderRight: '1px solid black', + marginLeft: '-1px', + marginRight: '-1px', + boxSizing: 'border-box', + display: 'inline', + }, + '.cm-ySelectionCaretDot': { + borderRadius: '50%', + position: 'absolute', + width: '.4em', + height: '.4em', + top: '-.2em', + left: '-.2em', + backgroundColor: 'inherit', + transition: 'transform .3s ease-in-out', + boxSizing: 'border-box', + }, + '.cm-ySelectionCaret:hover > .cm-ySelectionCaretDot': { + transformOrigin: 'bottom center', + transform: 'scale(0)', + }, + '.cm-ySelectionInfo': { + position: 'absolute', + top: '-1.05em', + left: '-1px', + fontSize: '.75em', + fontFamily: 'serif', + fontStyle: 'normal', + fontWeight: 'normal', + lineHeight: 'normal', + userSelect: 'none', + color: 'white', + paddingLeft: '2px', + paddingRight: '2px', + zIndex: 101, + transition: 'opacity .3s ease-in-out', + backgroundColor: 'inherit', + // these should be separate + opacity: 0, + transitionDelay: '0s', + whiteSpace: 'nowrap', + }, + '.cm-ySelectionCaret:hover > .cm-ySelectionInfo': { + opacity: 1, + transitionDelay: '0s', + }, +}) + +/** + * @todo specify the users that actually changed. Currently, we recalculate positions for every user. + */ +const yRemoteSelectionsAnnotation = cmState.Annotation.define() + +class YRemoteCaretWidget extends cmView.WidgetType { + constructor( + readonly color: string, + readonly name: string, + ) { + super() + } + + toDOM() { + return dom.element( + 'span', + [ + pair.create('class', 'cm-ySelectionCaret'), + pair.create('style', `background-color: ${this.color}; border-color: ${this.color}`), + ], + [ + dom.text('\u2060'), + dom.element('div', [pair.create('class', 'cm-ySelectionCaretDot')]), + dom.text('\u2060'), + dom.element('div', [pair.create('class', 'cm-ySelectionInfo')], [dom.text(this.name)]), + dom.text('\u2060'), + ], + ) as HTMLElement + } + + override eq(widget: unknown) { + assert(widget instanceof YRemoteCaretWidget) + return widget.color === this.color + } + + compare(widget: unknown) { + assert(widget instanceof YRemoteCaretWidget) + return widget.color === this.color + } + + override updateDOM() { + return false + } + + override get estimatedHeight() { + return -1 + } + + override ignoreEvent() { + return true + } +} + +/** TODO: Add docs */ +export class YRemoteSelectionsPluginValue { + private readonly conf: YSyncConfig + private readonly _awareness: Awareness + decorations: cmView.DecorationSet + private readonly _listener: ({ added, updated, removed }: any) => void + + /** TODO: Add docs */ + constructor(view: cmView.EditorView) { + this.conf = view.state.facet(ySyncFacet) + assert(this.conf.awareness != null) + this._listener = ({ added, updated, removed }: any) => { + const clients = added.concat(updated).concat(removed) + if (clients.findIndex((id: any) => id !== this._awareness.doc.clientID) >= 0) { + view.dispatch({ annotations: [yRemoteSelectionsAnnotation.of([])] }) + } + } + this._awareness = this.conf.awareness + this._awareness.on('change', this._listener) + this.decorations = cmState.RangeSet.of([]) + } + + /** TODO: Add docs */ + destroy() { + this._awareness.off('change', this._listener) + } + + /** TODO: Add docs */ + update(update: cmView.ViewUpdate) { + const ytext = this.conf.ytext + const ydoc = ytext.doc + const awareness = this._awareness + const decorations: cmState.Range[] = [] + const localAwarenessState = this._awareness.getLocalState() + + // set local awareness state (update cursors) + if (localAwarenessState != null) { + const hasFocus = update.view.hasFocus && update.view.dom.ownerDocument.hasFocus() + const sel = hasFocus ? update.state.selection.main : null + const currentAnchor = + localAwarenessState.cursor == null ? + null + : Y.createRelativePositionFromJSON(localAwarenessState.cursor.anchor) + const currentHead = + localAwarenessState.cursor == null ? + null + : Y.createRelativePositionFromJSON(localAwarenessState.cursor.head) + + if (sel != null) { + const anchor = Y.createRelativePositionFromTypeIndex(ytext, sel.anchor) + const head = Y.createRelativePositionFromTypeIndex(ytext, sel.head) + if ( + localAwarenessState.cursor == null || + !Y.compareRelativePositions(currentAnchor, anchor) || + !Y.compareRelativePositions(currentHead, head) + ) { + awareness.setLocalStateField('cursor', { + anchor, + head, + }) + } + } else if (localAwarenessState.cursor != null && hasFocus) { + awareness.setLocalStateField('cursor', null) + } + } + + // update decorations (remote selections) + awareness.getStates().forEach((state, clientid) => { + if (clientid === awareness.doc.clientID) { + return + } + const cursor = state.cursor + if (cursor == null || cursor.anchor == null || cursor.head == null) { + return + } + const anchor = Y.createAbsolutePositionFromRelativePosition(cursor.anchor, ydoc) + const head = Y.createAbsolutePositionFromRelativePosition(cursor.head, ydoc) + if (anchor == null || head == null || anchor.type !== ytext || head.type !== ytext) { + return + } + const { color = '#30bced', name = 'Anonymous' } = state.user || {} + const colorLight = (state.user && state.user.colorLight) || color + '33' + const start = math.min(anchor.index, head.index) + const end = math.max(anchor.index, head.index) + const startLine = update.view.state.doc.lineAt(start) + const endLine = update.view.state.doc.lineAt(end) + if (startLine.number === endLine.number) { + // selected content in a single line. + decorations.push({ + from: start, + to: end, + value: cmView.Decoration.mark({ + attributes: { style: `background-color: ${colorLight}` }, + class: 'cm-ySelection', + }), + }) + } else { + // selected content in multiple lines + // first, render text-selection in the first line + decorations.push({ + from: start, + to: startLine.from + startLine.length, + value: cmView.Decoration.mark({ + attributes: { style: `background-color: ${colorLight}` }, + class: 'cm-ySelection', + }), + }) + // render text-selection in the last line + decorations.push({ + from: endLine.from, + to: end, + value: cmView.Decoration.mark({ + attributes: { style: `background-color: ${colorLight}` }, + class: 'cm-ySelection', + }), + }) + for (let i = startLine.number + 1; i < endLine.number; i++) { + const linePos = update.view.state.doc.line(i).from + decorations.push({ + from: linePos, + to: linePos, + value: cmView.Decoration.line({ + attributes: { style: `background-color: ${colorLight}`, class: 'cm-yLineSelection' }, + }), + }) + } + } + decorations.push({ + from: head.index, + to: head.index, + value: cmView.Decoration.widget({ + side: head.index - anchor.index > 0 ? -1 : 1, // the local cursor should be rendered outside the remote selection + block: false, + widget: new YRemoteCaretWidget(color, name), + }), + }) + }) + this.decorations = cmView.Decoration.set(decorations, true) + } +} + +export const yRemoteSelections = cmView.ViewPlugin.fromClass(YRemoteSelectionsPluginValue, { + decorations: (v) => v.decorations, +}) diff --git a/app/gui/src/project-view/components/codemirror/yCollab/y-sync.ts b/app/gui/src/project-view/components/codemirror/yCollab/y-sync.ts new file mode 100644 index 000000000000..33daed4ef44b --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/y-sync.ts @@ -0,0 +1,156 @@ +import * as cmState from '@codemirror/state' +import * as cmView from '@codemirror/view' +import { type Awareness } from 'y-protocols/awareness.js' +import { assertDefined } from 'ydoc-shared/util/assert' +import * as Y from 'yjs' +import { YRange } from './y-range' + +/** TODO: Add docs */ +export class YSyncConfig { + readonly undoManager: Y.UndoManager + readonly ytext: Y.Text & { doc: Y.Doc } + + /** TODO: Add docs */ + constructor( + ytext: Y.Text & { doc: Y.Doc }, + readonly awareness: Awareness | null, + ) { + this.ytext = ytext as Y.Text & { doc: Y.Doc } + this.undoManager = new Y.UndoManager(ytext) + } + + /** + * Helper function to transform an absolute index position to a Yjs-based relative position + * (https://docs.yjs.dev/api/relative-positions). + * + * A relative position can be transformed back to an absolute position even after the document has changed. The position is + * automatically adapted. This does not require any position transformations. Relative positions are computed based on + * the internal Yjs document model. Peers that share content through Yjs are guaranteed that their positions will always + * synced up when using relatve positions. + * + * ```js + * import { ySyncFacet } from 'y-codemirror' + * + * .. + * const ysync = view.state.facet(ySyncFacet) + * // transform an absolute index position to a ypos + * const ypos = ysync.getYPos(3) + * // transform the ypos back to an absolute position + * ysync.fromYPos(ypos) // => 3 + * ``` + * + * It cannot be guaranteed that absolute index positions can be synced up between peers. + * This might lead to undesired behavior when implementing features that require that all peers see the + * same marked range (e.g. a comment plugin). + */ + toYPos(pos: number, assoc = 0) { + return Y.createRelativePositionFromTypeIndex(this.ytext, pos, assoc) + } + + /** TODO: Add docs */ + fromYPos(rpos: Y.RelativePosition | object) { + const pos = Y.createAbsolutePositionFromRelativePosition( + Y.createRelativePositionFromJSON(rpos), + this.ytext.doc, + ) + if (pos == null || pos.type !== this.ytext) { + throw new Error( + '[y-codemirror] The position you want to retrieve was created by a different document', + ) + } + return { + pos: pos.index, + assoc: pos.assoc, + } + } + + /** TODO: Add docs */ + toYRange(range: cmState.SelectionRange) { + const assoc = range.assoc + const yanchor = this.toYPos(range.anchor, assoc) + const yhead = this.toYPos(range.head, assoc) + return new YRange(yanchor, yhead) + } + + /** TODO: Add docs */ + fromYRange(yrange: YRange) { + const anchor = this.fromYPos(yrange.yanchor) + const head = this.fromYPos(yrange.yhead) + if (anchor.pos === head.pos) { + return cmState.EditorSelection.cursor(head.pos, head.assoc) + } + return cmState.EditorSelection.range(anchor.pos, head.pos) + } +} + +export const ySyncFacet = cmState.Facet.define({ + combine(inputs) { + return inputs[inputs.length - 1]! + }, +}) + +export const ySyncAnnotation = cmState.Annotation.define() + +class YSyncPluginValue implements cmView.PluginValue { + private readonly _ytext: Y.Text & { doc: Y.Doc } + private readonly conf: YSyncConfig + private readonly _observer: (event: Y.YTextEvent, tr: Y.Transaction) => void + + constructor(private readonly view: cmView.EditorView) { + this.conf = view.state.facet(ySyncFacet) + this._observer = (event: Y.YTextEvent, tr: Y.Transaction) => { + if (tr.origin !== this.conf) { + const delta = event.delta + const changes: { from: number; to: number; insert: string }[] = [] + let pos = 0 + for (const d of delta) { + if (d.insert != null) { + changes.push({ from: pos, to: pos, insert: d.insert as any }) + } else if (d.delete != null) { + changes.push({ from: pos, to: pos + d.delete, insert: '' }) + pos += d.delete + } else { + assertDefined(d.retain) + pos += d.retain + } + } + view.dispatch({ changes, annotations: [ySyncAnnotation.of(this.conf)] }) + } + } + this._ytext = this.conf.ytext + this._ytext.observe(this._observer) + } + + update(update: cmView.ViewUpdate) { + if ( + !update.docChanged || + (update.transactions.length > 0 && + update.transactions[0]!.annotation(ySyncAnnotation) === this.conf) + ) { + return + } + const ytext = this.conf.ytext + ytext.doc.transact(() => { + /** + * This variable adjusts the fromA position to the current position in the Y.Text type. + */ + let adj = 0 + update.changes.iterChanges((fromA, toA, fromB, toB, insert) => { + const insertText = insert.sliceString(0, insert.length, '\n') + if (fromA !== toA) { + ytext.delete(fromA + adj, toA - fromA) + } + if (insertText.length > 0) { + ytext.insert(fromA + adj, insertText) + } + adj += insertText.length - (toA - fromA) + }) + }, this.conf) + } + + destroy() { + this._ytext.unobserve(this._observer) + } +} + +export const ySync = cmView.ViewPlugin.fromClass(YSyncPluginValue) diff --git a/app/gui/src/project-view/components/codemirror/yCollab/y-undomanager.ts b/app/gui/src/project-view/components/codemirror/yCollab/y-undomanager.ts new file mode 100644 index 000000000000..c497a8534196 --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/y-undomanager.ts @@ -0,0 +1,138 @@ +import { type StackItemEvent } from '@/components/codemirror/yCollab/yjsTypes' +import * as cmState from '@codemirror/state' +import * as cmView from '@codemirror/view' +import { createMutex } from 'lib0/mutex' +import * as Y from 'yjs' +import { type YRange } from './y-range' +import { ySyncAnnotation, type YSyncConfig, ySyncFacet } from './y-sync' + +/** TODO: Add docs */ +export class YUndoManagerConfig { + /** TODO: Add docs */ + constructor(readonly undoManager: Y.UndoManager) {} + + /** TODO: Add docs */ + addTrackedOrigin(origin: unknown) { + this.undoManager.addTrackedOrigin(origin) + } + + /** TODO: Add docs */ + removeTrackedOrigin(origin: unknown) { + this.undoManager.removeTrackedOrigin(origin) + } + + /** + * @returns Whether a change was undone. + */ + undo(): boolean { + return this.undoManager.undo() != null + } + + /** + * @returns Whether a change was redone. + */ + redo(): boolean { + return this.undoManager.redo() != null + } +} + +export const yUndoManagerFacet = cmState.Facet.define({ + combine(inputs) { + return inputs[inputs.length - 1]! + }, +}) + +export const yUndoManagerAnnotation = cmState.Annotation.define() + +class YUndoManagerPluginValue implements cmView.PluginValue { + private readonly conf: YUndoManagerConfig + private readonly syncConf: YSyncConfig + private _beforeChangeSelection: null | YRange + private readonly _undoManager: Y.UndoManager + private readonly _mux: (cb: () => void, elseCb?: (() => void) | undefined) => any + private readonly _storeSelection: () => void + private readonly _onStackItemAdded: (event: StackItemEvent) => void + private readonly _onStackItemPopped: (event: StackItemEvent) => void + + constructor(readonly view: cmView.EditorView) { + this.conf = view.state.facet(yUndoManagerFacet) + this._undoManager = this.conf.undoManager + this.syncConf = view.state.facet(ySyncFacet) + this._beforeChangeSelection = null + this._mux = createMutex() + + this._onStackItemAdded = ({ stackItem, changedParentTypes }: StackItemEvent) => { + // only store metadata if this type was affected + if ( + changedParentTypes.has(this.syncConf.ytext as any) && + this._beforeChangeSelection && + !stackItem.meta.has(this) + ) { + // do not overwrite previous stored selection + stackItem.meta.set(this, this._beforeChangeSelection) + } + } + this._onStackItemPopped = ({ stackItem }: StackItemEvent) => { + const sel = stackItem.meta.get(this) + if (sel) { + const selection = this.syncConf.fromYRange(sel) + view.dispatch( + view.state.update({ + selection, + effects: [cmView.EditorView.scrollIntoView(selection)], + }), + ) + this._storeSelection() + } + } + /** + * Do this without mutex, simply use the sync annotation + */ + this._storeSelection = () => { + // store the selection before the change is applied so we can restore it with the undo manager. + this._beforeChangeSelection = this.syncConf.toYRange(this.view.state.selection.main) + } + this._undoManager.on('stack-item-added', this._onStackItemAdded) + this._undoManager.on('stack-item-popped', this._onStackItemPopped) + this._undoManager.addTrackedOrigin(this.syncConf) + } + + update(update: cmView.ViewUpdate) { + if ( + update.selectionSet && + (update.transactions.length === 0 || + update.transactions[0]!.annotation(ySyncAnnotation) !== this.syncConf) + ) { + // This only works when YUndoManagerPlugin is included before the sync plugin + this._storeSelection() + } + } + + destroy() { + this._undoManager.off('stack-item-added', this._onStackItemAdded) + this._undoManager.off('stack-item-popped', this._onStackItemPopped) + this._undoManager.removeTrackedOrigin(this.syncConf) + } +} +export const yUndoManager = cmView.ViewPlugin.fromClass(YUndoManagerPluginValue) + +export const undo: cmState.StateCommand = ({ state }) => + state.facet(yUndoManagerFacet).undo() || true + +export const redo: cmState.StateCommand = ({ state }) => + state.facet(yUndoManagerFacet).redo() || true + +export const undoDepth = (state: cmState.EditorState): number => + state.facet(yUndoManagerFacet).undoManager.undoStack.length + +export const redoDepth = (state: cmState.EditorState): number => + state.facet(yUndoManagerFacet).undoManager.redoStack.length + +/** + * Default key bindings for the undo manager. + */ +export const yUndoManagerKeymap: cmView.KeyBinding[] = [ + { key: 'Mod-z', run: undo, preventDefault: true }, + { key: 'Mod-y', mac: 'Mod-Shift-z', run: redo, preventDefault: true }, + { key: 'Mod-Shift-z', run: redo, preventDefault: true }, +] diff --git a/app/gui/src/project-view/components/codemirror/yCollab/yjsTypes.d.ts b/app/gui/src/project-view/components/codemirror/yCollab/yjsTypes.d.ts new file mode 100644 index 000000000000..1883ab8c021f --- /dev/null +++ b/app/gui/src/project-view/components/codemirror/yCollab/yjsTypes.d.ts @@ -0,0 +1,28 @@ +/** @file Types exposed by Yjs APIs, but not exported by name. */ + +import * as Y from 'yjs' + +export interface StackItemEvent { + stackItem: StackItem + origin: unknown + type: 'undo' | 'redo' + changedParentTypes: Map>, Y.YEvent[]> +} + +export interface StackItem { + insertions: DeleteSet + deletions: DeleteSet + /** + * Use this to save and restore metadata like selection range + */ + meta: Map +} + +export interface DeleteSet { + clients: Map +} + +export interface DeleteItem { + clock: number + len: number +} diff --git a/app/gui/src/project-view/stores/project/executionContext.ts b/app/gui/src/project-view/stores/project/executionContext.ts index bad4087a5c44..9a530056865c 100644 --- a/app/gui/src/project-view/stores/project/executionContext.ts +++ b/app/gui/src/project-view/stores/project/executionContext.ts @@ -9,7 +9,6 @@ import { type Identifier, } from '@/util/qualifiedName' import * as array from 'lib0/array' -import * as object from 'lib0/object' import { ObservableV2 } from 'lib0/observable' import * as random from 'lib0/random' import { reactive } from 'vue' diff --git a/app/licenses/MIT-yCollab-LICENSE b/app/licenses/MIT-yCollab-LICENSE new file mode 100644 index 000000000000..d7df8d8c0594 --- /dev/null +++ b/app/licenses/MIT-yCollab-LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2024 + - Kevin Jahns . + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/ydoc-shared/package.json b/app/ydoc-shared/package.json index 73dc773745fe..9cd17c7e712a 100644 --- a/app/ydoc-shared/package.json +++ b/app/ydoc-shared/package.json @@ -35,6 +35,8 @@ }, "dependencies": { "enso-common": "workspace:*", + "@lezer/common": "^1.1.0", + "@lezer/markdown": "^1.3.1", "@noble/hashes": "^1.4.0", "@open-rpc/client-js": "^1.8.1", "@types/debug": "^4.1.12", diff --git a/app/ydoc-shared/src/ast/__tests__/documentation.test.ts b/app/ydoc-shared/src/ast/__tests__/documentation.test.ts index 6a42216235ff..b8f4c9919032 100644 --- a/app/ydoc-shared/src/ast/__tests__/documentation.test.ts +++ b/app/ydoc-shared/src/ast/__tests__/documentation.test.ts @@ -1,3 +1,4 @@ +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' @@ -91,7 +92,7 @@ test('Creating comments: indented', () => { expect(statement.module.root()?.code()).toBe(`main =\n ## ${docText}\n x = 1`) }) -describe('Markdown documentation', () => { +describe('Function documentation (Markdown)', () => { const cases = [ { source: '## My function', @@ -101,6 +102,10 @@ describe('Markdown documentation', () => { source: '## My function\n\n Second paragraph', markdown: 'My function\nSecond paragraph', }, + { + source: '## Trailing whitespace \n\n Second paragraph', + markdown: 'Trailing whitespace \nSecond paragraph', + }, { source: '## My function\n\n\n Second paragraph after extra gap', markdown: 'My function\n\nSecond paragraph after extra gap', @@ -141,14 +146,23 @@ describe('Markdown documentation', () => { 'the Enso syntax specification which requires line length not to exceed 100 characters.', ].join(' '), // TODO: This should be '\n ' when hard-wrapping is implemented. }, + { + source: '## Table below:\n | a | b |\n |---|---|', + markdown: 'Table below:\n| a | b |\n|---|---|', + }, + { + source: '## Table below:\n\n | a | b |\n |---|---|', + markdown: 'Table below:\n\n| a | b |\n|---|---|', + }, ] - test.each(cases)('Enso source comments to markdown', ({ source, markdown }) => { + test.each(cases)('Enso source comments to normalized markdown', ({ source, markdown }) => { const moduleSource = `${source}\nmain =\n x = 1` const topLevel = parseModule(moduleSource) topLevel.module.setRoot(topLevel) - const main = [...topLevel.statements()][0] + const main = iter.first(topLevel.statements()) assert(main instanceof MutableFunctionDef) + expect(main.name.code()).toBe('main') expect(main.mutableDocumentationMarkdown().toJSON()).toBe(markdown) }) @@ -156,7 +170,7 @@ describe('Markdown documentation', () => { const functionCode = 'main =\n x = 1' const topLevel = parseModule(functionCode) topLevel.module.setRoot(topLevel) - const main = [...topLevel.statements()][0] + const main = iter.first(topLevel.statements()) assert(main instanceof MutableFunctionDef) const markdownYText = main.mutableDocumentationMarkdown() expect(markdownYText.toJSON()).toBe('') @@ -202,7 +216,7 @@ describe('Markdown documentation', () => { const topLevel = parseModule(originalSourceWithDocComment) expect(topLevel.code()).toBe(originalSourceWithDocComment) - const main = [...topLevel.statements()][0] + const main = iter.first(topLevel.statements()) assert(main instanceof MutableFunctionDef) const markdownYText = main.mutableDocumentationMarkdown() markdownYText.delete(0, markdownYText.length) diff --git a/app/ydoc-shared/src/ast/documentation.ts b/app/ydoc-shared/src/ast/documentation.ts index a9b13b7b256c..c1b9d68609ad 100644 --- a/app/ydoc-shared/src/ast/documentation.ts +++ b/app/ydoc-shared/src/ast/documentation.ts @@ -1,4 +1,5 @@ import { LINE_BOUNDARIES } from 'enso-common/src/utilities/data/string' +import { markdownParser } from './ensoMarkdown' import { xxHash128 } from './ffi' import type { ConcreteChild, RawConcreteChild } from './print' import { ensureUnspaced, firstChild, preferUnspaced, unspaced } from './print' @@ -32,6 +33,8 @@ export function* docLineToConcrete( for (const newline of docLine.newlines) yield preferUnspaced(newline) } +// === Markdown === + /** * Render function documentation to concrete tokens. If the `markdown` content has the same value as when `docLine` was * parsed (as indicated by `hash`), the `docLine` will be used (preserving concrete formatting). If it is different, the @@ -42,95 +45,161 @@ export function functionDocsToConcrete( hash: string | undefined, docLine: DeepReadonly | undefined, indent: string | null, -): IterableIterator | undefined { +): Iterable | undefined { return ( hash && docLine && xxHash128(markdown) === hash ? docLineToConcrete(docLine, indent) - : markdown ? yTextToTokens(markdown, (indent || '') + ' ') + : markdown ? markdownYTextToTokens(markdown, (indent || '') + ' ') : undefined ) } +function markdownYTextToTokens(yText: string, indent: string): Iterable> { + const tokensBuilder = new DocTokensBuilder(indent) + standardizeMarkdown(yText, tokensBuilder) + return tokensBuilder.build() +} + /** * Given Enso documentation comment tokens, returns a model of their Markdown content. This model abstracts away details * such as the locations of line breaks that are not paragraph breaks (e.g. lone newlines denoting hard-wrapping of the * source code). */ export function abstractMarkdown(elements: undefined | TextToken[]) { - let markdown = '' - let newlines = 0 + const { tags, rawMarkdown } = toRawMarkdown(elements) + const markdown = [...tags, normalizeMarkdown(rawMarkdown)].join('\n') + const hash = xxHash128(markdown) + return { markdown, hash } +} + +function toRawMarkdown(elements: undefined | TextToken[]) { + const tags: string[] = [] let readingTags = true - let elidedNewline = false + let rawMarkdown = '' ;(elements ?? []).forEach(({ token: { node } }, i) => { if (node.tokenType_ === TokenType.Newline) { - if (readingTags || newlines > 0) { - markdown += '\n' - elidedNewline = false - } else { - elidedNewline = true + if (!readingTags) { + rawMarkdown += '\n' } - newlines += 1 } else { let nodeCode = node.code() if (i === 0) nodeCode = nodeCode.trimStart() - if (elidedNewline) markdown += ' ' - markdown += nodeCode - newlines = 0 if (readingTags) { - if (!nodeCode.startsWith('ICON ')) { + if (nodeCode.startsWith('ICON ')) { + tags.push(nodeCode) + } else { readingTags = false } } + if (!readingTags) { + rawMarkdown += nodeCode + } } }) - const hash = xxHash128(markdown) - return { markdown, hash } + return { tags, rawMarkdown } } -// TODO: Paragraphs should be hard-wrapped to fit within the column limit, but this requires: -// - Recognizing block elements other than paragraphs; we must not split non-paragraph elements. -// - Recognizing inline elements; some cannot be split (e.g. links), while some can be broken into two (e.g. bold). -// If we break inline elements, we must also combine them when encountered during parsing. -const ENABLE_INCOMPLETE_WORD_WRAP_SUPPORT = false +/** + * Convert the Markdown input to a format with rendered-style linebreaks: Hard-wrapped lines within a paragraph will be + * joined, and only a single linebreak character is used to separate paragraphs. + */ +function normalizeMarkdown(rawMarkdown: string): string { + let normalized = '' + let prevTo = 0 + let prevName: string | undefined = undefined + const cursor = markdownParser.parse(rawMarkdown).cursor() + cursor.firstChild() + do { + if (prevTo < cursor.from) { + const textBetween = rawMarkdown.slice(prevTo, cursor.from) + normalized += + cursor.name === 'Paragraph' && prevName !== 'Table' ? textBetween.slice(0, -1) : textBetween + } + const text = rawMarkdown.slice(cursor.from, cursor.to) + normalized += cursor.name === 'Paragraph' ? text.replaceAll(/ *\n */g, ' ') : text + prevTo = cursor.to + prevName = cursor.name + } while (cursor.nextSibling()) + return normalized +} -function* yTextToTokens(yText: string, indent: string): IterableIterator> { - yield unspaced(Token.new('##', TokenType.TextStart)) - const lines = yText.split(LINE_BOUNDARIES) +/** + * 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 - for (const [i, value] of lines.entries()) { - if (i) { - yield unspaced(Token.new('\n', TokenType.Newline)) - if (value && !printingTags) yield unspaced(Token.new('\n', TokenType.Newline)) - } - printingTags = printingTags && value.startsWith('ICON ') - let offset = 0 - while (offset < value.length) { - if (offset !== 0) yield unspaced(Token.new('\n', TokenType.Newline)) - let wrappedLineEnd = value.length - let printableOffset = offset - if (i !== 0) { - while (printableOffset < value.length && value[printableOffset] === ' ') - printableOffset += 1 + 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() } - if (ENABLE_INCOMPLETE_WORD_WRAP_SUPPORT && !printingTags) { - const ENSO_SOURCE_MAX_COLUMNS = 100 - const MIN_DOC_COLUMNS = 40 - const availableWidth = Math.max( - ENSO_SOURCE_MAX_COLUMNS - indent.length - (i === 0 && offset === 0 ? '## '.length : 0), - MIN_DOC_COLUMNS, - ) - if (availableWidth < wrappedLineEnd - printableOffset) { - const wrapIndex = value.lastIndexOf(' ', printableOffset + availableWidth) - if (printableOffset < wrapIndex) { - wrappedLineEnd = wrapIndex + if (cursor.name === 'Paragraph' && prevName !== 'Table') { + textConsumer.newline() + } + } + 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 } } - } - while (printableOffset < value.length && value[printableOffset] === ' ') printableOffset += 1 - const whitespace = i === 0 && offset === 0 ? ' ' : indent - const wrappedLine = value.substring(printableOffset, wrappedLineEnd) - yield { whitespace, node: Token.new(wrappedLine, TokenType.TextSection) } - offset = wrappedLineEnd + if (!printingTags) { + if (i > 0) { + textConsumer.newline() + if (printingNonTags) textConsumer.newline() + } + textConsumer.wrapText(line) + printingNonTags = true + } + }) + } else { + lines.forEach((line, i) => { + if (i > 0) textConsumer.newline() + textConsumer.text(line) + }) + printingTags = false } + prevTo = cursor.to + prevName = cursor.name + } while (cursor.nextSibling()) +} + +interface TextConsumer { + text: (text: string) => void + wrapText: (text: string) => void + newline: () => void +} + +class DocTokensBuilder implements TextConsumer { + private readonly tokens: ConcreteChild[] = [unspaced(Token.new('##', TokenType.TextStart))] + + constructor(private readonly indent: string) {} + + text(text: string): void { + const whitespace = this.tokens.length === 1 ? ' ' : this.indent + this.tokens.push({ whitespace, node: Token.new(text, TokenType.TextSection) }) + } + + wrapText(text: string): void { + this.text(text) + } + + newline(): void { + this.tokens.push(unspaced(Token.new('\n', TokenType.Newline))) + } + + build(): ConcreteChild[] { + this.newline() + return this.tokens } - yield unspaced(Token.new('\n', TokenType.Newline)) } diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts b/app/ydoc-shared/src/ast/ensoMarkdown.ts similarity index 94% rename from app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts rename to app/ydoc-shared/src/ast/ensoMarkdown.ts index 589a2a00ca22..035805c91b72 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts +++ b/app/ydoc-shared/src/ast/ensoMarkdown.ts @@ -1,6 +1,4 @@ -import { markdown as baseMarkdown, markdownLanguage } from '@codemirror/lang-markdown' -import type { Extension } from '@codemirror/state' -import type { Tree } from '@lezer/common' +import { TreeCursor } from '@lezer/common' import type { BlockContext, BlockParser, @@ -12,31 +10,11 @@ import type { MarkdownParser, NodeSpec, } from '@lezer/markdown' -import { Element } from '@lezer/markdown' +import { parser as baseParser, Element, Emoji, GFM, Subscript, Superscript } from '@lezer/markdown' import { assertDefined } from 'ydoc-shared/util/assert' -/** - * Enso Markdown extension. Differences from CodeMirror's base Markdown extension: - * - It defines the flavor of Markdown supported in Enso documentation. Currently, this is mostly CommonMark except we - * don't support setext headings. Planned features include support for some GFM extensions. - * - Many of the parsers differ from the `@lezer/markdown` parsers in their treatment of whitespace, in order to support - * a rendering mode where markup (and some associated spacing) is hidden. - */ -export function markdown(): Extension { - return baseMarkdown({ - base: markdownLanguage, - extensions: [ - { - parseBlock: [headerParser, bulletList, orderedList, blockquoteParser, disableSetextHeading], - parseInline: [linkParser, imageParser, linkEndParser], - defineNodes: [blockquoteNode], - }, - ], - }) -} - function getType({ parser }: { parser: MarkdownParser }, name: string) { - const ty = parser.nodeSet.types.find((ty) => ty.name === name) + const ty = parser.nodeSet.types.find(ty => ty.name === name) assertDefined(ty) return ty.id } @@ -424,12 +402,12 @@ export interface DebugTree { // noinspection JSUnusedGlobalSymbols /** @returns A debug representation of the provided {@link Tree} */ -export function debugTree(tree: Tree): DebugTree { +export function debugTree(tree: { cursor: () => TreeCursor }): DebugTree { const cursor = tree.cursor() let current: DebugTree[] = [] const stack: DebugTree[][] = [] cursor.iterate( - (node) => { + node => { const children: DebugTree[] = [] current.push({ name: node.name, @@ -463,3 +441,25 @@ function isAtxHeading(line: Line) { function isSpace(ch: number) { return ch == 32 || ch == 9 || ch == 10 || ch == 13 } + +const ensoMarkdownLanguageExtension = { + parseBlock: [headerParser, bulletList, orderedList, blockquoteParser, disableSetextHeading], + parseInline: [linkParser, imageParser, linkEndParser], + defineNodes: [blockquoteNode], +} + +/** + * Lezer (CodeMirror) parser for the Enso documentation Markdown dialect. + * Differences from CodeMirror's base Markdown language: + * - It defines the flavor of Markdown supported in Enso documentation. Currently, this is mostly CommonMark except we + * don't support setext headings. Planned features include support for some GFM extensions. + * - Many of the parsers differ from the `@lezer/markdown` parsers in their treatment of whitespace, in order to support + * a rendering mode where markup (and some associated spacing) is hidden. + */ +export const markdownParser: MarkdownParser = baseParser.configure([ + GFM, + Subscript, + Superscript, + Emoji, + ensoMarkdownLanguageExtension, +]) diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts b/app/ydoc-shared/src/ast/lezerMarkdown.d.ts similarity index 100% rename from app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts rename to app/ydoc-shared/src/ast/lezerMarkdown.d.ts diff --git a/app/ydoc-shared/src/ast/parse.ts b/app/ydoc-shared/src/ast/parse.ts index 99f8d5a85754..77f54548aaa8 100644 --- a/app/ydoc-shared/src/ast/parse.ts +++ b/app/ydoc-shared/src/ast/parse.ts @@ -534,3 +534,38 @@ export function setExternalIds(edit: MutableModule, spans: SpanMap, ids: IdMap): } return astsMatched } + +/** + * Determines the context of `ast`: module root, body block, statement, or expression; parses the given code in the same + * context. + */ +export function parseInSameContext( + module: MutableModule, + code: string, + ast: Ast, +): { root: Owned; spans: SpanMap; toRaw: Map } { + const rawParsed = rawParseInContext(code, getParseContext(ast)) + return abstract(module, rawParsed, code) +} + +type ParseContext = 'module' | 'block' | 'expression' | 'statement' + +function getParseContext(ast: Ast): ParseContext { + const astModuleRoot = ast.module.root() + if (ast instanceof BodyBlock) return astModuleRoot && ast.is(astModuleRoot) ? 'module' : 'block' + return ast.isExpression() ? 'expression' : 'statement' +} + +function rawParseInContext(code: string, context: ParseContext): RawAst.Tree { + if (context === 'module') return rawParseModule(code) + const block = rawParseBlock(code) + if (context === 'block') return block + const statement = iter.tryGetSoleValue(block.statements)?.expression + if (!statement) return block + if (context === 'statement') return statement + if (context === 'expression') + return statement.type === RawAst.Tree.Type.ExpressionStatement ? + statement.expression + : statement + return context satisfies never +} diff --git a/app/ydoc-shared/src/ast/syncToCode.ts b/app/ydoc-shared/src/ast/syncToCode.ts index f99d03974e94..e67afb70a3c7 100644 --- a/app/ydoc-shared/src/ast/syncToCode.ts +++ b/app/ydoc-shared/src/ast/syncToCode.ts @@ -1,30 +1,35 @@ import * as iter from 'enso-common/src/utilities/data/iter' import * as map from 'lib0/map' import { assert, assertDefined } from '../util/assert' -import type { SourceRangeEdit, SpanTree } from '../util/data/text' import { + type SourceRangeEdit, + type SpanTree, applyTextEdits, applyTextEditsToSpans, enclosingSpans, textChangeToEdits, trimEnd, } from '../util/data/text' -import type { SourceRange, SourceRangeKey } from '../yjsModel' -import { rangeLength, sourceRangeFromKey, sourceRangeKey } from '../yjsModel' +import { + type SourceRange, + type SourceRangeKey, + rangeLength, + sourceRangeFromKey, + sourceRangeKey, +} from '../yjsModel' import { xxHash128 } from './ffi' -import * as RawAst from './generated/ast' -import type { NodeKey, NodeSpanMap } from './idMap' -import { newExternalId } from './idMap' +import { type NodeKey, type NodeSpanMap, newExternalId } from './idMap' import type { Module, MutableModule } from './mutableModule' -import { abstract, rawParseBlock, rawParseModule } from './parse' +import { parseInSameContext } from './parse' import { printWithSpans } from './print' import { isTokenId } from './token' -import type { AstId, MutableAst, Owned } from './tree' import { Assignment, Ast, + type AstId, MutableAssignment, - MutableBodyBlock, + type MutableAst, + type Owned, rewriteRefs, syncFields, syncNodeMetadata, @@ -32,7 +37,6 @@ import { /** * Recursion helper for {@link syntaxHash}. - * @internal */ function hashSubtreeSyntax(ast: Ast, hashesOut: Map): SyntaxHash { let content = '' @@ -53,6 +57,7 @@ function hashSubtreeSyntax(ast: Ast, hashesOut: Map): SyntaxH declare const brandHash: unique symbol /** See {@link syntaxHash}. */ type SyntaxHash = string & { [brandHash]: never } + /** Applies the syntax-data hashing function to the input, and brands the result as a `SyntaxHash`. */ function hashString(input: string): SyntaxHash { return xxHash128(input) as SyntaxHash @@ -170,32 +175,18 @@ export function applyTextEditsToAst( ) { const printed = printWithSpans(ast) const code = applyTextEdits(printed.code, textEdits) - const astModuleRoot = ast.module.root() - const rawParsedBlock = - ast instanceof MutableBodyBlock && astModuleRoot && ast.is(astModuleRoot) ? - rawParseModule(code) - : rawParseBlock(code) - const rawParsedStatement = - ast instanceof MutableBodyBlock ? undefined : ( - iter.tryGetSoleValue(rawParsedBlock.statements)?.expression + ast.module.transact(() => { + const parsed = parseInSameContext(ast.module, code, ast) + const toSync = calculateCorrespondence( + ast, + printed.info.nodes, + parsed.root, + parsed.spans.nodes, + textEdits, + code, ) - const rawParsedExpression = - ast.isExpression() ? - rawParsedStatement?.type === RawAst.Tree.Type.ExpressionStatement ? - rawParsedStatement.expression - : undefined - : undefined - const rawParsed = rawParsedExpression ?? rawParsedStatement ?? rawParsedBlock - const parsed = abstract(ast.module, rawParsed, code) - const toSync = calculateCorrespondence( - ast, - printed.info.nodes, - parsed.root, - parsed.spans.nodes, - textEdits, - code, - ) - syncTree(ast, parsed.root, toSync, ast.module, metadataSource) + syncTree(ast, parsed.root, toSync, ast.module, metadataSource) + }) } /** Replace `target` with `newContent`, reusing nodes according to the correspondence in `toSync`. */ diff --git a/app/ydoc-shared/src/ast/tree.ts b/app/ydoc-shared/src/ast/tree.ts index ca6c33c0b660..0451c24cc4d2 100644 --- a/app/ydoc-shared/src/ast/tree.ts +++ b/app/ydoc-shared/src/ast/tree.ts @@ -565,8 +565,11 @@ export function syncFields(ast1: MutableAst, ast2: Ast, f: (id: AstId) => AstId } function syncYText(target: Y.Text, source: Y.Text) { - target.delete(0, target.length) - target.insert(0, source.toJSON()) + const sourceString = source.toJSON() + if (target.toJSON() !== sourceString) { + target.delete(0, target.length) + target.insert(0, sourceString) + } } /** TODO: Add docs */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fafdbf8f02c7..1d3fc8fd8a52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,9 +172,6 @@ importers: '@lezer/highlight': specifier: ^1.1.6 version: 1.2.0 - '@lezer/markdown': - specifier: ^1.3.1 - version: 1.3.1 '@monaco-editor/react': specifier: 4.6.0 version: 4.6.0(monaco-editor@0.48.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -343,9 +340,6 @@ importers: vue-component-type-helpers: specifier: ^2.0.29 version: 2.0.29 - y-codemirror.next: - specifier: ^0.3.2 - version: 0.3.5(@codemirror/state@6.4.1)(@codemirror/view@6.28.3)(yjs@13.6.18) y-protocols: specifier: ^1.0.5 version: 1.0.6(yjs@13.6.18) @@ -755,6 +749,12 @@ importers: app/ydoc-shared: dependencies: + '@lezer/common': + specifier: ^1.1.0 + version: 1.2.1 + '@lezer/markdown': + specifier: ^1.3.1 + version: 1.3.1 '@noble/hashes': specifier: ^1.4.0 version: 1.4.0 @@ -7311,13 +7311,6 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y-codemirror.next@0.3.5: - resolution: {integrity: sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==} - peerDependencies: - '@codemirror/state': ^6.0.0 - '@codemirror/view': ^6.0.0 - yjs: ^13.5.6 - y-leveldb@0.1.2: resolution: {integrity: sha512-6ulEn5AXfXJYi89rXPEg2mMHAyyw8+ZfeMMdOtBbV8FJpQ1NOrcgi6DTAcXof0dap84NjHPT2+9d0rb6cFsjEg==} peerDependencies: @@ -15325,13 +15318,6 @@ snapshots: xtend@4.0.2: {} - y-codemirror.next@0.3.5(@codemirror/state@6.4.1)(@codemirror/view@6.28.3)(yjs@13.6.18): - dependencies: - '@codemirror/state': 6.4.1 - '@codemirror/view': 6.28.3 - lib0: 0.2.94 - yjs: 13.6.18 - y-leveldb@0.1.2(yjs@13.6.18): dependencies: level: 6.0.1