diff --git a/CHANGELOG.md b/CHANGELOG.md index d45a8dea3a0..682b71ad603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ - [Table Input Widget has now a limit of 256 cells.][11448] - [Added an error message screen displayed when viewing a deleted component.][11452] +- [New documentation editor provides improved Markdown editing experience, and + paves the way for new documentation features.][11469] [11151]: https://github.com/enso-org/enso/pull/11151 [11271]: https://github.com/enso-org/enso/pull/11271 @@ -37,6 +39,7 @@ [11447]: https://github.com/enso-org/enso/pull/11447 [11448]: https://github.com/enso-org/enso/pull/11448 [11452]: https://github.com/enso-org/enso/pull/11452 +[11469]: https://github.com/enso-org/enso/pull/11469 #### Enso Standard Library diff --git a/app/common/package.json b/app/common/package.json index d3bb6c880ee..2d34ca4e4e7 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -18,6 +18,8 @@ "./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts", "./src/utilities/data/newtype": "./src/utilities/data/newtype.ts", "./src/utilities/data/object": "./src/utilities/data/object.ts", + "./src/utilities/data/string": "./src/utilities/data/string.ts", + "./src/utilities/data/iter": "./src/utilities/data/iter.ts", "./src/utilities/style/tabBar": "./src/utilities/style/tabBar.ts", "./src/utilities/uniqueString": "./src/utilities/uniqueString.ts", "./src/text": "./src/text/index.ts", @@ -37,6 +39,7 @@ "@tanstack/query-persist-client-core": "^5.54.0", "@tanstack/vue-query": ">= 5.54.0 < 5.56.0", "idb-keyval": "^6.2.1", + "lib0": "^0.2.85", "react": "^18.3.1", "vitest": "^1.3.1", "vue": "^3.5.2" diff --git a/app/common/src/utilities/data/__tests__/iterator.test.ts b/app/common/src/utilities/data/__tests__/iterator.test.ts new file mode 100644 index 00000000000..6bf76baff14 --- /dev/null +++ b/app/common/src/utilities/data/__tests__/iterator.test.ts @@ -0,0 +1,146 @@ +import { expect, test } from 'vitest' +import * as iter from '../iter' + +interface IteratorCase { + iterable: Iterable + soleValue: T | undefined + first: T | undefined + last: T | undefined + count: number +} + +function makeCases(): IteratorCase[] { + return [ + { + iterable: iter.empty(), + soleValue: undefined, + first: undefined, + last: undefined, + count: 0, + }, + { + iterable: iter.chain(iter.empty(), iter.empty()), + soleValue: undefined, + first: undefined, + last: undefined, + count: 0, + }, + { + iterable: iter.chain(iter.empty(), ['a'], iter.empty()), + soleValue: 'a', + first: 'a', + last: 'a', + count: 1, + }, + { + iterable: iter.range(10, 11), + soleValue: 10, + first: 10, + last: 10, + count: 1, + }, + { + iterable: iter.range(10, 20), + soleValue: undefined, + first: 10, + last: 19, + count: 10, + }, + { + iterable: iter.range(20, 10), + soleValue: undefined, + first: 20, + last: 11, + count: 10, + }, + { + iterable: [], + soleValue: undefined, + first: undefined, + last: undefined, + count: 0, + }, + { + iterable: ['a'], + soleValue: 'a', + first: 'a', + last: 'a', + count: 1, + }, + { + iterable: ['a', 'b'], + soleValue: undefined, + first: 'a', + last: 'b', + count: 2, + }, + { + iterable: iter.filterDefined([undefined, 'a', undefined, 'b', undefined]), + soleValue: undefined, + first: 'a', + last: 'b', + count: 2, + }, + { + iterable: iter.filter([7, 'a', 8, 'b', 9], el => typeof el === 'string'), + soleValue: undefined, + first: 'a', + last: 'b', + count: 2, + }, + { + iterable: iter.zip(['a', 'b'], iter.range(1, 2)), + soleValue: ['a', 1], + first: ['a', 1], + last: ['a', 1], + count: 1, + }, + { + iterable: iter.zip(['a', 'b'], iter.range(1, 3)), + soleValue: undefined, + first: ['a', 1], + last: ['b', 2], + count: 2, + }, + { + iterable: iter.zip(['a', 'b'], iter.range(1, 4)), + soleValue: undefined, + first: ['a', 1], + last: ['b', 2], + count: 2, + }, + { + iterable: iter.zipLongest(['a', 'b'], iter.range(1, 2)), + soleValue: undefined, + first: ['a', 1], + last: ['b', undefined], + count: 2, + }, + { + iterable: iter.zipLongest(['a', 'b'], iter.range(1, 3)), + soleValue: undefined, + first: ['a', 1], + last: ['b', 2], + count: 2, + }, + { + iterable: iter.zipLongest(['a', 'b'], iter.range(1, 4)), + soleValue: undefined, + first: ['a', 1], + last: [undefined, 3], + count: 3, + }, + ] +} + +test.each(makeCases())('tryGetSoleValue: case %#', ({ iterable, soleValue }) => { + expect(iter.tryGetSoleValue(iterable)).toEqual(soleValue) +}) + +test.each(makeCases())('last: case %#', ({ iterable, last }) => { + expect(iter.last(iterable)).toEqual(last) +}) + +test.each(makeCases())('count: case %#', ({ iterable, count }) => { + expect(iter.count(iterable)).toEqual(count) +}) diff --git a/app/ydoc-shared/src/util/data/iterable.ts b/app/common/src/utilities/data/iter.ts similarity index 60% rename from app/ydoc-shared/src/util/data/iterable.ts rename to app/common/src/utilities/data/iter.ts index eb1a911d81a..aaaa66742e0 100644 --- a/app/ydoc-shared/src/util/data/iterable.ts +++ b/app/common/src/utilities/data/iter.ts @@ -1,4 +1,30 @@ -/** @file Functions for manipulating {@link Iterable}s. */ +/** @file Utilities for manipulating {@link Iterator}s and {@link Iterable}s. */ + +import { iteratorFilter, mapIterator } from 'lib0/iterator' + +/** Similar to {@link Array.prototype.reduce|}, but consumes elements from any iterable. */ +export function reduce( + iterable: Iterable, + f: (accumulator: A, element: T) => A, + initialAccumulator: A, +): A { + const iterator = iterable[Symbol.iterator]() + let accumulator = initialAccumulator + let result = iterator.next() + while (!result.done) { + accumulator = f(accumulator, result.value) + result = iterator.next() + } + return accumulator +} + +/** + * Iterates the provided iterable, returning the number of elements it yielded. Note that if the input is an iterator, + * it will be consumed. + */ +export function count(it: Iterable): number { + return reduce(it, a => a + 1, 0) +} /** An iterable with zero elements. */ export function* empty(): Generator {} @@ -26,22 +52,17 @@ export function* range(start: number, stop: number, step = start <= stop ? 1 : - } } -/** - * Return an {@link Iterable} that `yield`s values that are the result of calling the given - * function on the next value of the given source iterable. - */ -export function* map(iter: Iterable, map: (value: T) => U): IterableIterator { - for (const value of iter) { - yield map(value) - } +/** @returns An iterator that yields the results of applying the given function to each value of the given iterable. */ +export function map(it: Iterable, f: (value: T) => U): IterableIterator { + return mapIterator(it[Symbol.iterator](), f) } /** * Return an {@link Iterable} that `yield`s only the values from the given source iterable * that pass the given predicate. */ -export function* filter(iter: Iterable, include: (value: T) => boolean): IterableIterator { - for (const value of iter) if (include(value)) yield value +export function filter(iter: Iterable, include: (value: T) => boolean): IterableIterator { + return iteratorFilter(iter[Symbol.iterator](), include) } /** @@ -141,3 +162,45 @@ export class Resumable { } } } + +/** Returns an iterator that yields the values of the provided iterator that are not strictly-equal to `undefined`. */ +export function* filterDefined(iterable: Iterable): IterableIterator { + for (const value of iterable) { + if (value !== undefined) yield value + } +} + +/** + * Returns whether the predicate returned `true` for all values yielded by the provided iterator. Short-circuiting. + * Returns `true` if the iterator doesn't yield any values. + */ +export function every(iter: Iterable, f: (value: T) => boolean): boolean { + for (const value of iter) if (!f(value)) return false + return true +} + +/** Return the first element returned by the iterable which meets the condition. */ +export function find(iter: Iterable, f: (value: T) => boolean): T | undefined { + for (const value of iter) { + if (f(value)) return value + } + return undefined +} + +/** Returns the first element yielded by the iterable. */ +export function first(iterable: Iterable): T | undefined { + const iterator = iterable[Symbol.iterator]() + const result = iterator.next() + return result.done ? undefined : result.value +} + +/** + * Return last element returned by the iterable. + * NOTE: Linear complexity. This function always visits the whole iterable. Using this with an + * infinite generator will cause an infinite loop. + */ +export function last(iter: Iterable): T | undefined { + let last + for (const el of iter) last = el + return last +} diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index f8010da8aef..8480594c23c 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -162,3 +162,24 @@ export type ExtractKeys = { /** An instance method of the given type. */ export type MethodOf = (this: T, ...args: never) => unknown + +// =================== +// === useObjectId === +// =================== + +/** Composable providing support for managing object identities. */ +export function useObjectId() { + let lastId = 0 + const idNumbers = new WeakMap() + /** @returns A value that can be used to compare object identity. */ + function objectId(o: object): number { + const id = idNumbers.get(o) + if (id == null) { + lastId += 1 + idNumbers.set(o, lastId) + return lastId + } + return id + } + return { objectId } +} diff --git a/app/common/src/utilities/data/string.ts b/app/common/src/utilities/data/string.ts new file mode 100644 index 00000000000..c2aeebbc7db --- /dev/null +++ b/app/common/src/utilities/data/string.ts @@ -0,0 +1,2 @@ +/** See http://www.unicode.org/reports/tr18/#Line_Boundaries */ +export const LINE_BOUNDARIES = /\r\n|[\n\v\f\r\x85\u2028\u2029]/g diff --git a/app/gui/e2e/project-view/locate.ts b/app/gui/e2e/project-view/locate.ts index f4867422c64..134a65144bf 100644 --- a/app/gui/e2e/project-view/locate.ts +++ b/app/gui/e2e/project-view/locate.ts @@ -84,7 +84,7 @@ export const addNewNodeButton = componentLocator('.PlusButton') export const componentBrowser = componentLocator('.ComponentBrowser') export const nodeOutputPort = componentLocator('.outputPortHoverArea') export const smallPlusButton = componentLocator('.SmallPlusButton') -export const lexicalContent = componentLocator('.LexicalContent') +export const editorRoot = componentLocator('.EditorRoot') /** * A not-selected variant of Component Browser Entry. diff --git a/app/gui/e2e/project-view/rightPanel.spec.ts b/app/gui/e2e/project-view/rightPanel.spec.ts index 474231e5eca..bc907fbe2d6 100644 --- a/app/gui/e2e/project-view/rightPanel.spec.ts +++ b/app/gui/e2e/project-view/rightPanel.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'playwright/test' import * as actions from './actions' -import { mockMethodCallInfo } from './expressionUpdates' +import { mockCollapsedFunctionInfo, mockMethodCallInfo } from './expressionUpdates' import { CONTROL_KEY } from './keyboard' import * as locate from './locate' @@ -13,7 +13,7 @@ test('Main method documentation', async ({ page }) => { await expect(locate.rightDock(page)).toBeVisible() // Right-dock displays main method documentation. - await expect(locate.lexicalContent(locate.rightDock(page))).toHaveText('The main method') + await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('The main method') // Documentation hotkey closes right-dock.p await page.keyboard.press(`${CONTROL_KEY}+D`) @@ -70,3 +70,20 @@ test('Component help', async ({ page }) => { await locate.graphNodeByBinding(page, 'data').click() await expect(locate.rightDock(page)).toHaveText(/Reads a file into Enso/) }) + +test('Documentation reflects entered function', async ({ page }) => { + await actions.goToGraph(page) + + // Open the panel + await expect(locate.rightDock(page)).toBeHidden() + await page.keyboard.press(`${CONTROL_KEY}+D`) + await expect(locate.rightDock(page)).toBeVisible() + + // Enter the collapsed function + await mockCollapsedFunctionInfo(page, 'final', 'func1') + await locate.graphNodeByBinding(page, 'final').dblclick() + await expect(locate.navBreadcrumb(page)).toHaveText(['Mock Project', 'func1']) + + // Editor should contain collapsed function's docs + await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('A collapsed function') +}) diff --git a/app/gui/package.json b/app/gui/package.json index 2d386f285f0..9dfca52c6f8 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -83,22 +83,18 @@ "babel-plugin-react-compiler": "19.0.0-beta-9ee70a1-20241017", "@codemirror/commands": "^6.6.0", "@codemirror/language": "^6.10.2", + "@codemirror/lang-markdown": "^v6.3.0", "@codemirror/lint": "^6.8.1", "@codemirror/search": "^6.5.6", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.28.3", "@fast-check/vitest": "^0.0.8", "@floating-ui/vue": "^1.0.6", - "@lexical/code": "^0.16.0", "@lexical/link": "^0.16.0", - "@lexical/list": "^0.16.0", - "@lexical/markdown": "^0.16.0", "@lexical/plain-text": "^0.16.0", - "@lexical/rich-text": "^0.16.0", - "@lexical/selection": "^0.16.0", - "@lexical/table": "^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", diff --git a/app/gui/src/project-view/assets/base.css b/app/gui/src/project-view/assets/base.css index 14f89717c21..0f8cdc1e5bd 100644 --- a/app/gui/src/project-view/assets/base.css +++ b/app/gui/src/project-view/assets/base.css @@ -85,7 +85,7 @@ /* Resize handle override for the visualization container. */ --visualization-resize-handle-inside: 3px; --visualization-resize-handle-outside: 3px; - --right-dock-default-width: 40%; + --right-dock-default-width: 40vw; --code-editor-default-height: 30%; --scrollbar-scrollable-opacity: 100%; } diff --git a/app/gui/src/project-view/components/CodeEditor.vue b/app/gui/src/project-view/components/CodeEditor.vue index 89577636dba..65c2286e980 100644 --- a/app/gui/src/project-view/components/CodeEditor.vue +++ b/app/gui/src/project-view/components/CodeEditor.vue @@ -1,14 +1,16 @@ diff --git a/app/gui/src/project-view/components/GraphEditor.vue b/app/gui/src/project-view/components/GraphEditor.vue index 714a27467bf..9e76ecec4b2 100644 --- a/app/gui/src/project-view/components/GraphEditor.vue +++ b/app/gui/src/project-view/components/GraphEditor.vue @@ -9,7 +9,7 @@ import { import BottomPanel from '@/components/BottomPanel.vue' import CodeEditor from '@/components/CodeEditor.vue' import ComponentBrowser from '@/components/ComponentBrowser.vue' -import { type Usage } from '@/components/ComponentBrowser/input' +import type { Usage } from '@/components/ComponentBrowser/input' import { usePlacement } from '@/components/ComponentBrowser/placement' import ComponentDocumentation from '@/components/ComponentDocumentation.vue' import DockPanel from '@/components/DockPanel.vue' @@ -20,21 +20,21 @@ import { useGraphEditorClipboard } from '@/components/GraphEditor/clipboard' import { performCollapse, prepareCollapsedInfo } from '@/components/GraphEditor/collapsing' import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation' import { useGraphEditorToasts } from '@/components/GraphEditor/toasts' -import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload' +import { uploadedExpression, Uploader } from '@/components/GraphEditor/upload' import GraphMissingView from '@/components/GraphMissingView.vue' import GraphMouse from '@/components/GraphMouse.vue' import PlusButton from '@/components/PlusButton.vue' import SceneScroller from '@/components/SceneScroller.vue' import TopBar from '@/components/TopBar.vue' import { builtinWidgets } from '@/components/widgets' -import { useAstDocumentation } from '@/composables/astDocumentation' import { useDoubleClick } from '@/composables/doubleClick' import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events' import { groupColorVar } from '@/composables/nodeColors' import type { PlacementStrategy } from '@/composables/nodeCreation' import { useSyncLocalStorage } from '@/composables/syncLocalStorage' import { provideFullscreenContext } from '@/providers/fullscreenContext' -import { provideGraphNavigator, type GraphNavigator } from '@/providers/graphNavigator' +import type { GraphNavigator } from '@/providers/graphNavigator' +import { provideGraphNavigator } from '@/providers/graphNavigator' import { provideNodeColors } from '@/providers/graphNodeColors' import { provideNodeCreation } from '@/providers/graphNodeCreation' import { provideGraphSelection } from '@/providers/graphSelection' @@ -43,25 +43,25 @@ import { provideInteractionHandler } from '@/providers/interactionHandler' import { provideKeyboard } from '@/providers/keyboard' import { injectVisibility } from '@/providers/visibility' import { provideWidgetRegistry } from '@/providers/widgetRegistry' -import { provideGraphStore, type NodeId } from '@/stores/graph' +import type { NodeId } from '@/stores/graph' +import { provideGraphStore } from '@/stores/graph' import type { RequiredImport } from '@/stores/graph/imports' import { useProjectStore } from '@/stores/project' import { useSettings } from '@/stores/settings' import { provideSuggestionDbStore } from '@/stores/suggestionDatabase' -import type { SuggestionId } from '@/stores/suggestionDatabase/entry' -import { suggestionDocumentationUrl, type Typename } from '@/stores/suggestionDatabase/entry' +import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry' +import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry' import { provideVisualizationStore } from '@/stores/visualization' import { bail } from '@/util/assert' import { Ast } from '@/util/ast' -import type { AstId } from '@/util/ast/abstract' import { colorFromString } from '@/util/colors' import { partition } from '@/util/data/array' -import { every, filterDefined } from '@/util/data/iterable' import { Rect } from '@/util/data/rect' -import { Err, Ok, unwrapOr } from '@/util/data/result' +import { Err, Ok } from '@/util/data/result' import { Vec2 } from '@/util/data/vec2' import { computedFallback, useSelectRef } from '@/util/reactivity' import { until } from '@vueuse/core' +import * as iter from 'enso-common/src/utilities/data/iter' import { encoding, set } from 'lib0' import { computed, @@ -75,7 +75,6 @@ import { type ComponentInstance, } from 'vue' import { encodeMethodPointer } from 'ydoc-shared/languageServerTypes' -import * as iterable from 'ydoc-shared/util/data/iterable' import { isDevMode } from 'ydoc-shared/util/detect' const rootNode = ref() @@ -330,7 +329,7 @@ const graphBindingsHandler = graphBindings.handler({ }, toggleVisualization() { const selected = nodeSelection.selected - const allVisible = every( + const allVisible = iter.every( selected, (id) => graphStore.db.nodeIdToNode.get(id)?.vis?.visible === true, ) @@ -416,7 +415,7 @@ const documentationEditorArea = computed(() => unrefElement(docEditor)) const showRightDock = computedFallback( storedShowRightDock, // Show documentation editor when documentation exists on first graph visit. - () => !!documentation.state.value, + () => (markdownDocs.value?.length ?? 0) > 0, ) const rightDockTab = computedFallback(storedRightDockTab, () => 'docs') @@ -430,9 +429,11 @@ const documentationEditorHandler = documentationEditorBindings.handler({ }, }) -const { documentation } = useAstDocumentation(graphStore, () => - unwrapOr(graphStore.methodAst, undefined), -) +const markdownDocs = computed(() => { + const currentMethod = graphStore.methodAst + if (!currentMethod.ok) return + return currentMethod.value.mutableDocumentationMarkdown() +}) // === Component Browser === @@ -550,7 +551,7 @@ const componentBrowserElements = computed(() => [ interface NewNodeOptions { placement: PlacementStrategy - sourcePort?: AstId | undefined + sourcePort?: Ast.AstId | undefined } function addNodeDisconnected() { @@ -592,7 +593,7 @@ function createNodesFromSource(sourceNode: NodeId, options: NodeCreationOptions[ createWithComponentBrowser({ placement: { type: 'source', node: sourceNode }, sourcePort }) } -function handleNodeOutputPortDoubleClick(id: AstId) { +function handleNodeOutputPortDoubleClick(id: Ast.AstId) { const srcNode = graphStore.db.getPatternExpressionNodeId(id) if (srcNode == null) { console.error('Impossible happened: Double click on port not belonging to any node: ', id) @@ -601,7 +602,7 @@ function handleNodeOutputPortDoubleClick(id: AstId) { createWithComponentBrowser({ placement: { type: 'source', node: srcNode }, sourcePort: id }) } -function handleEdgeDrop(source: AstId, position: Vec2) { +function handleEdgeDrop(source: Ast.AstId, position: Vec2) { createWithComponentBrowser({ placement: { type: 'fixed', position }, sourcePort: source }) } @@ -609,7 +610,7 @@ function handleEdgeDrop(source: AstId, position: Vec2) { function collapseNodes() { const selected = new Set( - iterable.filter( + iter.filter( nodeSelection.selected, (id) => graphStore.db.nodeIdToNode.get(id)?.type === 'component', ), @@ -630,7 +631,7 @@ function collapseNodes() { if (!topLevel) { bail('BUG: no top level, collapsing not possible.') } - const selectedNodeRects = filterDefined(Array.from(selected, graphStore.visibleArea)) + const selectedNodeRects = iter.filterDefined(iter.map(selected, graphStore.visibleArea)) graphStore.edit((edit) => { const { collapsedCallRoot, collapsedNodeIds, outputAstId } = performCollapse( info.value, @@ -641,8 +642,8 @@ function collapseNodes() { const position = collapsedNodePlacement(selectedNodeRects) edit.get(collapsedCallRoot).mutableNodeMetadata().set('position', position.xy()) if (outputAstId != null) { - const collapsedNodeRects = filterDefined( - Array.from(collapsedNodeIds, graphStore.visibleArea), + const collapsedNodeRects = iter.filterDefined( + iter.map(collapsedNodeIds, graphStore.visibleArea), ) const { place } = usePlacement(collapsedNodeRects, graphNavigator.viewport) const position = place(collapsedNodeRects) @@ -785,9 +786,9 @@ const documentationEditorFullscreen = ref(false) > @@ -812,7 +813,7 @@ const documentationEditorFullscreen = ref(false) display: flex; flex-direction: row; - & :deep(.DockPanel) { + & .DockPanel { flex: none; } & .vertical { @@ -824,7 +825,7 @@ const documentationEditorFullscreen = ref(false) .vertical { display: flex; flex-direction: column; - & :deep(.BottomPanel) { + & .BottomPanel { flex: none; } & .viewport { diff --git a/app/gui/src/project-view/components/GraphEditor/GraphVisualization.vue b/app/gui/src/project-view/components/GraphEditor/GraphVisualization.vue index 8b029ca9c1d..dffdc5ff8da 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphVisualization.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphVisualization.vue @@ -1,15 +1,13 @@ - - diff --git a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/LexicalImage.vue b/app/gui/src/project-view/components/MarkdownEditor/DocumentationImage.vue similarity index 83% rename from app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/LexicalImage.vue rename to app/gui/src/project-view/components/MarkdownEditor/DocumentationImage.vue index 142489b8212..545fc82325c 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/LexicalImage.vue +++ b/app/gui/src/project-view/components/MarkdownEditor/DocumentationImage.vue @@ -1,6 +1,6 @@ - - diff --git a/app/gui/src/project-view/components/MarkdownEditor/FormattingToolbar.vue b/app/gui/src/project-view/components/MarkdownEditor/FormattingToolbar.vue deleted file mode 100644 index 972c94f9a3e..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/FormattingToolbar.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/imageNode.ts b/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/imageNode.ts deleted file mode 100644 index 8e0a585f8f2..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/imageNode.ts +++ /dev/null @@ -1,164 +0,0 @@ -import LexicalImage from '@/components/MarkdownEditor/ImagePlugin/LexicalImage.vue' -import type { - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - LexicalNode, - NodeKey, - SerializedLexicalNode, - Spread, -} from 'lexical' -import { $applyNodeReplacement, DecoratorNode } from 'lexical' -import { h, type Component } from 'vue' - -export interface ImagePayload { - altText: string - key?: NodeKey | undefined - src: string -} - -export interface UpdateImagePayload { - altText?: string -} - -function $convertImageElement(domNode: Node): null | DOMConversionOutput { - if (domNode instanceof HTMLImageElement) { - const { alt: altText, src } = domNode - const node = $createImageNode({ altText, src }) - return { node } - } - return null -} - -export type SerializedImageNode = Spread< - { - altText: string - src: string - }, - SerializedLexicalNode -> - -/** TODO: Add docs */ -export class ImageNode extends DecoratorNode { - __src: string - __altText: string - - /** TODO: Add docs */ - static override getType(): string { - return 'image' - } - - /** TODO: Add docs */ - static override clone(node: ImageNode): ImageNode { - return new ImageNode(node.__src, node.__altText, node.__key) - } - - /** TODO: Add docs */ - static override importJSON(serializedNode: SerializedImageNode): ImageNode { - const { altText, src } = serializedNode - return $createImageNode({ - altText, - src, - }) - } - - /** TODO: Add docs */ - static override importDOM(): DOMConversionMap | null { - return { - img: (_node: Node) => ({ - conversion: $convertImageElement, - priority: 0, - }), - } - } - - /** TODO: Add docs */ - constructor(src: string, altText: string, key?: NodeKey) { - super(key) - this.__src = src - this.__altText = altText - } - - /** TODO: Add docs */ - override exportDOM(): DOMExportOutput { - const element = document.createElement('img') - element.setAttribute('src', this.__src) - element.setAttribute('alt', this.__altText) - return { element } - } - - /** TODO: Add docs */ - override exportJSON(): SerializedImageNode { - return { - altText: this.getAltText(), - src: this.getSrc(), - type: 'image', - version: 1, - } - } - - /** TODO: Add docs */ - getSrc(): string { - return this.__src - } - - /** TODO: Add docs */ - getAltText(): string { - return this.__altText - } - - /** TODO: Add docs */ - setAltText(altText: string): void { - const writable = this.getWritable() - writable.__altText = altText - } - - /** TODO: Add docs */ - update(payload: UpdateImagePayload): void { - const writable = this.getWritable() - const { altText } = payload - if (altText !== undefined) { - writable.__altText = altText - } - } - - // View - - /** TODO: Add docs */ - override createDOM(config: EditorConfig): HTMLElement { - const span = document.createElement('span') - const className = config.theme.image - if (className !== undefined) { - span.className = className - } - return span - } - - /** TODO: Add docs */ - override updateDOM(_prevNode: ImageNode, dom: HTMLElement, config: EditorConfig): false { - const className = config.theme.image - if (className !== undefined) { - dom.className = className - } - return false - } - - /** TODO: Add docs */ - override decorate(): Component { - return h(LexicalImage, { - src: this.__src, - alt: this.__altText, - }) - } -} - -/** TODO: Add docs */ -export function $createImageNode({ altText, src, key }: ImagePayload): ImageNode { - return $applyNodeReplacement(new ImageNode(src, altText, key)) -} - -/** TODO: Add docs */ -export function $isImageNode(node: LexicalNode | null | undefined): node is ImageNode { - return node instanceof ImageNode -} diff --git a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/index.ts b/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/index.ts deleted file mode 100644 index be18cff854d..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/ImagePlugin/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - $createImageNode, - $isImageNode, - ImageNode, -} from '@/components/MarkdownEditor/ImagePlugin/imageNode' -import type { LexicalMarkdownPlugin } from '@/components/MarkdownEditor/markdown' -import type { TextMatchTransformer } from '@lexical/markdown' -import type { LexicalEditor } from 'lexical' -import { assertDefined } from 'ydoc-shared/util/assert' - -export const IMAGE: TextMatchTransformer = { - dependencies: [ImageNode], - export: (node) => { - if (!$isImageNode(node)) { - return null - } - return `![${node.getAltText()}](${node.getSrc()})` - }, - importRegExp: /!\[([^\]]*)]\(([^()\n]+)\)/, - regExp: /!\[([^\]]*)]\(([^()\n]+)\)$/, - replace: (textNode, match) => { - const [, altText, src] = match - assertDefined(altText) - assertDefined(src) - const imageNode = $createImageNode({ altText, src }) - textNode.replace(imageNode) - }, - trigger: ')', - type: 'text-match', -} - -export const imagePlugin: LexicalMarkdownPlugin = { - nodes: [ImageNode], - transformers: [IMAGE], - register(_editor: LexicalEditor): void {}, -} diff --git a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue index 20009017229..605128a10bc 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue +++ b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue @@ -1,80 +1,154 @@ - diff --git a/app/gui/src/project-view/components/MarkdownEditor/SelectionFormattingToolbar.vue b/app/gui/src/project-view/components/MarkdownEditor/SelectionFormattingToolbar.vue deleted file mode 100644 index 6148acc12ab..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/SelectionFormattingToolbar.vue +++ /dev/null @@ -1,41 +0,0 @@ - - - - - diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts new file mode 100644 index 00000000000..82625a18dd1 --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/markdown.test.ts @@ -0,0 +1,137 @@ +import { ensoMarkdown } from '@/components/MarkdownEditor/markdown' +import { EditorState } from '@codemirror/state' +import { Decoration, EditorView } from '@codemirror/view' +import { expect, test } from 'vitest' + +function decorations( + source: string, + recognize: (from: number, to: number, decoration: Decoration) => T | undefined, +) { + const vueHost = { + register: () => ({ unregister: () => {} }), + } + const state = EditorState.create({ + doc: source, + extensions: [ensoMarkdown({ vueHost })], + }) + const view = new EditorView({ state }) + const decorationSets = state.facet(EditorView.decorations) + const results = [] + for (const decorationSet of decorationSets) { + const resolvedDecorations = + decorationSet instanceof Function ? decorationSet(view) : decorationSet + const cursor = resolvedDecorations.iter() + while (cursor.value != null) { + const recognized = recognize(cursor.from, cursor.to, cursor.value) + if (recognized) results.push(recognized) + cursor.next() + } + } + return results +} + +function links(source: string) { + return decorations(source, (from, to, deco) => { + if (deco.spec.tagName === 'a') { + return { + text: source.substring(from, to), + href: deco.spec.attributes.href, + } + } + }) +} + +function images(source: string) { + return decorations(source, (from, to, deco) => { + if ('widget' in deco.spec && 'props' in deco.spec.widget && 'src' in deco.spec.widget.props) { + return { + from, + to, + src: deco.spec.widget.props.src, + alt: deco.spec.widget.props.alt, + } + } + }) +} + +test.each([ + { + markdown: '[Link text](https://www.example.com/index.html)', + expectedLinks: [ + { + text: 'Link text', + href: 'https://www.example.com/index.html', + }, + ], + }, + { + markdown: '[Unclosed url](https://www.example.com/index.html', + expectedLinks: [], + }, + { + markdown: '[](https://www.example.com/index.html)', + expectedLinks: [], + }, + { + markdown: '[With empty URL]()', + expectedLinks: [], + }, + { + markdown: '[With no URL]', + expectedLinks: [], + }, + { + markdown: '[Unclosed', + expectedLinks: [], + }, +])('Link decoration: $markdown', ({ markdown, expectedLinks }) => { + expect(links(markdown)).toEqual(expectedLinks) + expect(images(markdown)).toEqual([]) +}) + +test.each([ + { + markdown: '![Image](https://www.example.com/image.avif)', + image: { + src: 'https://www.example.com/image.avif', + alt: 'Image', + }, + }, + { + markdown: '![](https://www.example.com/image.avif)', + image: { + src: 'https://www.example.com/image.avif', + alt: '', + }, + }, + { + markdown: '![Image](https://www.example.com/image.avif', + image: null, + }, + { + markdown: '![Image]()', + image: null, + }, + { + markdown: '![Image]', + image: null, + }, + { + markdown: '![Image', + image: null, + }, +])('Image decoration: $markdown', ({ markdown, image }) => { + expect(links(markdown)).toEqual([]) + expect(images(markdown)).toEqual( + image == null ? + [] + : [ + { + from: markdown.length, + to: markdown.length, + src: image.src, + alt: image.alt, + }, + ], + ) +}) diff --git a/app/gui/src/project-view/components/MarkdownEditor/formatting.ts b/app/gui/src/project-view/components/MarkdownEditor/formatting.ts deleted file mode 100644 index 51f92fe1d3f..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/formatting.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { lexicalTheme } from '@/components/lexical' -import { useBufferedWritable } from '@/util/reactivity' -import { $createCodeNode } from '@lexical/code' -import { - $isListNode, - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - ListNode, -} from '@lexical/list' -import { - $createHeadingNode, - $createQuoteNode, - $isHeadingNode, - $isQuoteNode, - type HeadingTagType, -} from '@lexical/rich-text' -import { $setBlocksType } from '@lexical/selection' -import { $isTableSelection } from '@lexical/table' -import { - $findMatchingParent, - $getNearestBlockElementAncestorOrThrow, - $getNearestNodeOfType, -} from '@lexical/utils' -import type { EditorThemeClasses, LexicalEditor, RangeSelection, TextFormatType } from 'lexical' -import { - $createParagraphNode, - $getSelection, - $isRangeSelection, - $isRootOrShadowRoot, - $isTextNode, - COMMAND_PRIORITY_LOW, - FORMAT_TEXT_COMMAND, - SELECTION_CHANGE_COMMAND, -} from 'lexical' -import { ref } from 'vue' - -/** TODO: Add docs */ -export function useFormatting(editor: LexicalEditor) { - const selectionReaders = new Array<(selection: RangeSelection) => void>() - function onReadSelection(reader: (selection: RangeSelection) => void) { - selectionReaders.push(reader) - } - function $readState() { - const selection = $getSelection() - if ($isRangeSelection(selection)) { - for (const reader of selectionReaders) { - reader(selection) - } - } - } - editor.registerUpdateListener(({ editorState }) => { - editorState.read($readState) - }) - editor.registerCommand( - SELECTION_CHANGE_COMMAND, - (_payload, _newEditor) => { - $readState() - return false - }, - COMMAND_PRIORITY_LOW, - ) - return { - bold: useFormatProperty(editor, 'bold', onReadSelection), - italic: useFormatProperty(editor, 'italic', onReadSelection), - strikethrough: useFormatProperty(editor, 'strikethrough', onReadSelection), - subscript: useFormatProperty(editor, 'subscript', onReadSelection), - superscript: useFormatProperty(editor, 'superscript', onReadSelection), - blockType: useBlockType(editor, onReadSelection), - clearFormatting: () => editor.update($clearFormatting), - } -} -export type UseFormatting = ReturnType - -function useFormatProperty( - editor: LexicalEditor, - property: TextFormatType, - onReadSelection: ($readSelection: (selection: RangeSelection) => void) => void, -) { - const state = ref(false) - - onReadSelection((selection) => (state.value = selection.hasFormat(property))) - - // The editor only exposes a toggle interface, so we need to model its state to ensure the setter is only called when - // the desired value is different from its current value. - const writable = useBufferedWritable({ - get: state, - set: (_value: boolean) => editor.dispatchCommand(FORMAT_TEXT_COMMAND, property), - }) - - return { state, set: (value: boolean) => (writable.value = value) } -} - -function $clearFormatting() { - const selection = $getSelection() - if ($isRangeSelection(selection) || $isTableSelection(selection)) { - const anchor = selection.anchor - const focus = selection.focus - const nodes = selection.getNodes() - const extractedNodes = selection.extract() - - if (anchor.key === focus.key && anchor.offset === focus.offset) { - return - } - - nodes.forEach((node, idx) => { - // We split the first and last node by the selection - // So that we don't format unselected text inside those nodes - if ($isTextNode(node)) { - // Use a separate variable to ensure TS does not lose the refinement - let textNode = node - if (idx === 0 && anchor.offset !== 0) { - textNode = textNode.splitText(anchor.offset)[1] || textNode - } - if (idx === nodes.length - 1) { - textNode = textNode.splitText(focus.offset)[0] || textNode - } - /** - * If the selected text has one format applied - * selecting a portion of the text, could - * clear the format to the wrong portion of the text. - * - * The cleared text is based on the length of the selected text. - */ - // We need this in case the selected text only has one format - const extractedTextNode = extractedNodes[0] - if (nodes.length === 1 && $isTextNode(extractedTextNode)) { - textNode = extractedTextNode - } - - if (textNode.__style !== '') { - textNode.setStyle('') - } - if (textNode.__format !== 0) { - textNode.setFormat(0) - $getNearestBlockElementAncestorOrThrow(textNode).setFormat('') - } - node = textNode - } else if ($isHeadingNode(node) || $isQuoteNode(node)) { - node.replace($createParagraphNode(), true) - } - }) - } -} - -export const blockTypeToBlockName = { - bullet: 'Bulleted List', - code: 'Code Block', - h1: 'Heading 1', - h2: 'Heading 2', - h3: 'Heading 3', - number: 'Numbered List', - paragraph: 'Normal', - quote: 'Quote', -} -export type BlockType = keyof typeof blockTypeToBlockName -export const blockTypes = Object.keys(blockTypeToBlockName) as BlockType[] - -const smallestEnabledHeading = ['h6', 'h5', 'h4', 'h3', 'h2', 'h1'].find( - isBlockType, -) as HeadingTagType & BlockType - -function isBlockType(value: string): value is BlockType { - return value in blockTypeToBlockName -} - -/** TODO: Add docs */ -export function normalizeHeadingLevel(heading: HeadingTagType): HeadingTagType & BlockType { - return isBlockType(heading) ? heading : smallestEnabledHeading -} - -function useBlockType( - editor: LexicalEditor, - onReadSelection: ($readSelection: (selection: RangeSelection) => void) => void, -) { - const state = ref('paragraph') - - onReadSelection((selection) => (state.value = $getBlockType(selection) ?? 'paragraph')) - - function $getBlockType(selection: RangeSelection): BlockType | undefined { - const anchorNode = selection.anchor.getNode() - const element = - anchorNode.getKey() === 'root' ? - anchorNode - : $findMatchingParent(anchorNode, (e) => { - const parent = e.getParent() - return parent !== null && $isRootOrShadowRoot(parent) - }) ?? anchorNode.getTopLevelElementOrThrow() - - if ($isListNode(element)) { - const parentList = $getNearestNodeOfType(anchorNode, ListNode) - const type = parentList ? parentList.getListType() : element.getListType() - if (type in blockTypeToBlockName) { - return type as keyof typeof blockTypeToBlockName - } - } else if ($isHeadingNode(element)) { - return normalizeHeadingLevel(element.getTag()) - } else { - const type = element.getType() - if (type in blockTypeToBlockName) { - return type as keyof typeof blockTypeToBlockName - } - } - } - - const $setBlockType: Record void> = { - bullet: () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined), - number: () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined), - paragraph: () => { - const selection = $getSelection() - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createParagraphNode()) - } - }, - quote: () => $setBlocksType($getSelection(), () => $createQuoteNode()), - code: () => { - let selection = $getSelection() - if (selection !== null) { - if (selection.isCollapsed()) { - $setBlocksType(selection, () => $createCodeNode()) - } else { - const textContent = selection.getTextContent() - const codeNode = $createCodeNode() - selection.insertNodes([codeNode]) - selection = $getSelection() - if ($isRangeSelection(selection)) { - selection.insertRawText(textContent) - } - } - } - }, - h1: () => $setBlocksType($getSelection(), () => $createHeadingNode('h1')), - h2: () => $setBlocksType($getSelection(), () => $createHeadingNode('h2')), - h3: () => $setBlocksType($getSelection(), () => $createHeadingNode('h3')), - } - - return { - state, - set: (value: BlockType) => editor.update($setBlockType[value]), - } -} - -/** TODO: Add docs */ -export function lexicalRichTextTheme(themeCss: Record): EditorThemeClasses { - const theme = lexicalTheme(themeCss) - if (theme.heading) { - for (const level of Object.keys(theme.heading)) { - const levelTag = level as keyof typeof theme.heading - const normalized = normalizeHeadingLevel(levelTag) - theme.heading[levelTag] = theme.heading[normalized] ?? 'lexical-unsupported-heading-level' - } - } - return theme -} diff --git a/app/gui/src/project-view/components/MarkdownEditor/highlight.ts b/app/gui/src/project-view/components/MarkdownEditor/highlight.ts new file mode 100644 index 00000000000..07631c7654e --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/highlight.ts @@ -0,0 +1,108 @@ +import { syntaxHighlighting } from '@codemirror/language' +import type { Extension } from '@codemirror/state' +import { type Tag, tagHighlighter, tags } from '@lezer/highlight' + +const tagNames: (keyof typeof tags)[] = [ + 'comment', + 'lineComment', + 'blockComment', + 'docComment', + 'name', + 'variableName', + 'typeName', + 'tagName', + 'propertyName', + 'attributeName', + 'className', + 'labelName', + 'namespace', + 'macroName', + 'literal', + 'string', + 'docString', + 'character', + 'attributeValue', + 'number', + 'integer', + 'float', + 'bool', + 'regexp', + 'escape', + 'color', + 'url', + 'keyword', + 'self', + 'null', + 'atom', + 'unit', + 'modifier', + 'operatorKeyword', + 'controlKeyword', + 'definitionKeyword', + 'moduleKeyword', + 'operator', + 'derefOperator', + 'arithmeticOperator', + 'logicOperator', + 'bitwiseOperator', + 'compareOperator', + 'updateOperator', + 'definitionOperator', + 'typeOperator', + 'controlOperator', + 'punctuation', + 'separator', + 'bracket', + 'angleBracket', + 'squareBracket', + 'paren', + 'brace', + 'content', + 'heading', + 'heading1', + 'heading2', + 'heading3', + 'heading4', + 'heading5', + 'heading6', + 'contentSeparator', + 'list', + 'quote', + 'emphasis', + 'strong', + 'link', + 'monospace', + 'strikethrough', + 'inserted', + 'deleted', + 'changed', + 'invalid', + 'meta', + 'documentMeta', + 'annotation', + 'processingInstruction', +] + +/** + * Defines an {@link Extension} that applies a highlighting CSS class for any {@link Tag} with a provided class mapping. + * @param css A mapping from {@link Tag} names to CSS class names. + */ +export function highlightStyle(css: Record): Extension { + const modTagClasses = (mod: keyof typeof tags) => + tagNames.map((tag) => ({ + tag: (tags[mod] as any)(tags[tag]) as Tag, + class: `${tags[mod]}-${tag}`, + })) + const tagClasses = tagNames.map((tag) => ({ tag: tags[tag] as Tag, class: css[tag] ?? tag })) + return syntaxHighlighting( + tagHighlighter([ + ...tagClasses, + ...modTagClasses('definition'), + ...modTagClasses('constant'), + ...modTagClasses('function'), + ...modTagClasses('standard'), + ...modTagClasses('local'), + ...modTagClasses('special'), + ]), + ) +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/imageUrlTransformer.ts b/app/gui/src/project-view/components/MarkdownEditor/imageUrlTransformer.ts index 259037bb73c..b3859ab168d 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/imageUrlTransformer.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/imageUrlTransformer.ts @@ -7,11 +7,11 @@ export type TransformUrlResult = Result<{ url: string; dispose?: () => void }> export type UrlTransformer = (url: string) => Promise export { - injectFn as injectLexicalImageUrlTransformer, - provideFn as provideLexicalImageUrlTransformer, + injectFn as injectDocumentationImageUrlTransformer, + provideFn as provideDocumentationImageUrlTransformer, } const { provideFn, injectFn } = createContextStore( - 'Lexical image URL transformer', + 'Documentation image URL transformer', (transformUrl: ToValue) => ({ transformUrl: (url: string) => toValue(transformUrl)?.(url), }), @@ -59,7 +59,10 @@ export function fetcherUrlTransformer( return Ok({ url: result.value.value, dispose: () => { - if (!(result.value.refs -= 1)) URL.revokeObjectURL(result.value.value) + if (!(result.value.refs -= 1)) { + URL.revokeObjectURL(result.value.value) + allocatedUrls.delete(uniqueId) + } }, }) } diff --git a/app/gui/src/project-view/components/MarkdownEditor/listPlugin.ts b/app/gui/src/project-view/components/MarkdownEditor/listPlugin.ts deleted file mode 100644 index e3cc0a14bd4..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/listPlugin.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { LexicalPlugin } from '@/components/lexical' -import { - $handleListInsertParagraph, - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - insertList, - ListItemNode, - ListNode, - REMOVE_LIST_COMMAND, - removeList, -} from '@lexical/list' -import { COMMAND_PRIORITY_LOW, INSERT_PARAGRAPH_COMMAND } from 'lexical' - -export const listPlugin: LexicalPlugin = { - nodes: [ListItemNode, ListNode], - register: (editor) => { - editor.registerCommand( - INSERT_ORDERED_LIST_COMMAND, - () => { - insertList(editor, 'number') - return true - }, - COMMAND_PRIORITY_LOW, - ) - editor.registerCommand( - INSERT_UNORDERED_LIST_COMMAND, - () => { - insertList(editor, 'bullet') - return true - }, - COMMAND_PRIORITY_LOW, - ) - editor.registerCommand( - REMOVE_LIST_COMMAND, - () => { - removeList(editor) - return true - }, - COMMAND_PRIORITY_LOW, - ) - editor.registerCommand( - INSERT_PARAGRAPH_COMMAND, - $handleListInsertParagraph, - COMMAND_PRIORITY_LOW, - ) - }, -} diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown.ts index b488ebe6f84..2f5980fb216 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown.ts @@ -1,70 +1,9 @@ -import type { LexicalPlugin } from '@/components/lexical' -import { useLexicalStringSync } from '@/components/lexical/sync' -import { CodeHighlightNode, CodeNode } from '@lexical/code' -import { AutoLinkNode, LinkNode } from '@lexical/link' -import { ListItemNode, ListNode } from '@lexical/list' -import { - $convertFromMarkdownString, - $convertToMarkdownString, - TRANSFORMERS, - registerMarkdownShortcuts, - type Transformer, -} from '@lexical/markdown' -import { HeadingNode, QuoteNode, registerRichText } from '@lexical/rich-text' -import { TableCellNode, TableNode, TableRowNode } from '@lexical/table' -import { $setSelection } from 'lexical' -import { watch, type Ref } from 'vue' +import { markdownDecorators } from '@/components/MarkdownEditor/markdown/decoration' +import { markdown } from '@/components/MarkdownEditor/markdown/parse' +import type { VueHost } from '@/components/VueComponentHost.vue' +import type { Extension } from '@codemirror/state' -export interface LexicalMarkdownPlugin extends LexicalPlugin { - transformers?: Transformer[] +/** Markdown extension, with customizations for Enso. */ +export function ensoMarkdown({ vueHost }: { vueHost: VueHost }): Extension { + return [markdown(), markdownDecorators({ vueHost })] } - -/** TODO: Add docs */ -export function markdownPlugin( - model: Ref, - extensions: LexicalMarkdownPlugin[], -): LexicalPlugin[] { - const transformers = new Array() - for (const extension of extensions) { - if (extension?.transformers) transformers.push(...extension.transformers) - } - transformers.push(...TRANSFORMERS) - return [...extensions, baseMarkdownPlugin(transformers), markdownSyncPlugin(model, transformers)] -} - -function baseMarkdownPlugin(transformers: Transformer[]): LexicalPlugin { - return { - nodes: [ - HeadingNode, - QuoteNode, - ListItemNode, - ListNode, - AutoLinkNode, - LinkNode, - CodeHighlightNode, - CodeNode, - TableCellNode, - TableNode, - TableRowNode, - ], - register: (editor) => { - registerRichText(editor) - registerMarkdownShortcuts(editor, transformers) - }, - } -} - -const markdownSyncPlugin = (model: Ref, transformers: Transformer[]): LexicalPlugin => ({ - register: (editor) => { - const { content } = useLexicalStringSync( - editor, - () => $convertToMarkdownString(transformers), - (value) => { - $convertFromMarkdownString(value, transformers) - $setSelection(null) - }, - ) - watch(model, (newContent) => content.set(newContent), { immediate: true }) - watch(content.editedContent, (newContent) => (model.value = newContent)) - }, -}) diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts new file mode 100644 index 00000000000..bce93559a1f --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/decoration.ts @@ -0,0 +1,270 @@ +import DocumentationImage from '@/components/MarkdownEditor/DocumentationImage.vue' +import type { VueHost } from '@/components/VueComponentHost.vue' +import { syntaxTree } from '@codemirror/language' +import { type EditorSelection, type Extension, RangeSetBuilder, type Text } from '@codemirror/state' +import { + Decoration, + type DecorationSet, + EditorView, + type PluginValue, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from '@codemirror/view' +import type { SyntaxNode, SyntaxNodeRef, Tree } from '@lezer/common' +import { h, markRaw } from 'vue' + +/** Extension applying decorators for Markdown. */ +export function markdownDecorators({ vueHost }: { vueHost: VueHost }): Extension { + const stateDecorator = new TreeStateDecorator(vueHost, [ + decorateImageWithClass, + decorateImageWithRendered, + ]) + const stateDecoratorExt = EditorView.decorations.compute(['doc'], (state) => + stateDecorator.decorate(syntaxTree(state), state.doc), + ) + const viewDecoratorExt = ViewPlugin.define( + (view) => new TreeViewDecorator(view, vueHost, [decorateLink]), + { + decorations: (v) => v.decorations, + }, + ) + const cursorDecoratorExt = EditorView.decorations.compute(['selection', 'doc'], (state) => + cursorDecorations(state.selection, state.doc), + ) + return [stateDecoratorExt, viewDecoratorExt, cursorDecoratorExt] +} + +interface NodeDecorator { + ( + nodeRef: SyntaxNodeRef, + doc: Text, + emitDecoration: (from: number, to: number, deco: Decoration) => void, + vueHost: VueHost, + ): void +} + +// === Tree state decorator === + +/** Maintains a set of decorations based on the tree. */ +class TreeStateDecorator { + constructor( + private readonly vueHost: VueHost, + private readonly nodeDecorators: NodeDecorator[], + ) {} + + decorate(tree: Tree, doc: Text): DecorationSet { + const builder = new RangeSetBuilder() + const emit = (from: number, to: number, value: Decoration) => { + builder.add(from, to, value) + } + tree.iterate({ + enter: (nodeRef) => { + for (const decorator of this.nodeDecorators) decorator(nodeRef, doc, emit, this.vueHost) + }, + }) + return builder.finish() + } +} + +// === Cursor decorator === + +function cursorDecorations(selection: EditorSelection, doc: Text): DecorationSet { + const builder = new RangeSetBuilder() + for (const range of selection.ranges) { + const line = doc.lineAt(range.from) + builder.add( + line.from, + line.from, + Decoration.line({ + class: 'cm-has-cursor', + }), + ) + if (range.to != range.from) { + // TODO: Add decorations to each line + } + } + return builder.finish() +} + +// === Tree view decorator === + +/** Maintains a set of decorations based on the tree, lazily-constructed for the visible range of the document. */ +class TreeViewDecorator implements PluginValue { + decorations: DecorationSet + + constructor( + view: EditorView, + private readonly vueHost: VueHost, + /** + * Functions that construct decorations based on tree. The decorations must not have significant impact on the + * height of the document, or scrolling issues would result, because decorations are lazily computed based on the + * current viewport. + */ + private readonly nodeDecorators: NodeDecorator[], + ) { + this.decorations = this.buildDeco(syntaxTree(view.state), view) + } + + update(update: ViewUpdate) { + // TODO + // Attaching widgets can change the geometry, so don't re-attach widgets in response to geometry changes. + // Reusing unchanged widgets would be a better solution, but this works correctly as long as rendering widgets + // within the `visibleRanges` doesn't bring any new content into the `visibleRanges`; in practice this should hold. + //if (!update.docChanged && !update.viewportChanged) return + if (!update.docChanged) return + this.decorations = this.buildDeco(syntaxTree(update.state), update.view) + } + + private buildDeco(tree: Tree, view: EditorView) { + if (!tree.length) return Decoration.none + const builder = new RangeSetBuilder() + const doc = view.state.doc + const emit = (from: number, to: number, value: Decoration) => { + builder.add(from, to, value) + } + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter: (nodeRef) => { + for (const decorator of this.nodeDecorators) decorator(nodeRef, doc, emit, this.vueHost) + }, + }) + } + return builder.finish() + } +} + +// === Links === + +/** Parse a link or image */ +function parseLinkLike(node: SyntaxNode, doc: Text) { + const textOpen = node.firstChild // [ or ![ + if (!textOpen) return + const textClose = textOpen.nextSibling // ] + if (!textClose) return + const urlOpen = textClose.nextSibling // ( + // The parser accepts partial links such as `[Missing url]`. + if (!urlOpen) return + const urlNode = urlOpen.nextSibling + // If the URL is empty, this will be the closing 'LinkMark'. + if (urlNode?.name !== 'URL') return + return { + textFrom: textOpen.to, + textTo: textClose.from, + url: doc.sliceString(urlNode.from, urlNode.to), + } +} + +function decorateLink( + nodeRef: SyntaxNodeRef, + doc: Text, + emitDecoration: (from: number, to: number, deco: Decoration) => void, +) { + if (nodeRef.name === 'Link') { + const parsed = parseLinkLike(nodeRef.node, doc) + if (!parsed) return + const { textFrom, textTo, url } = parsed + if (textFrom === textTo) return + emitDecoration( + textFrom, + textTo, + Decoration.mark({ + tagName: 'a', + attributes: { href: url }, + }), + ) + } +} + +// === Images === + +function decorateImageWithClass( + nodeRef: SyntaxNodeRef, + _doc: Text, + emitDecoration: (from: number, to: number, deco: Decoration) => void, +) { + if (nodeRef.name === 'Image') { + emitDecoration( + nodeRef.from, + nodeRef.to, + Decoration.mark({ + class: 'cm-image-markup', + }), + ) + } +} + +function decorateImageWithRendered( + nodeRef: SyntaxNodeRef, + doc: Text, + emitDecoration: (from: number, to: number, deco: Decoration) => void, + vueHost: VueHost, +) { + if (nodeRef.name === 'Image') { + const parsed = parseLinkLike(nodeRef.node, doc) + if (!parsed) return + const { textFrom, textTo, url } = parsed + const text = doc.sliceString(textFrom, textTo) + const widget = new ImageWidget({ alt: text, src: url }, vueHost) + emitDecoration( + nodeRef.to, + nodeRef.to, + Decoration.widget({ + 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, + }), + ) + } +} + +class ImageWidget extends WidgetType { + private container: HTMLElement | undefined + private vueHostRegistration: { unregister: () => void } | undefined + + constructor( + private readonly props: { + readonly alt: string + readonly src: string + }, + private readonly vueHost: VueHost, + ) { + super() + } + + override get estimatedHeight() { + return -1 + } + + override eq(other: WidgetType) { + return ( + other instanceof ImageWidget && + other.props.src == this.props.src && + other.props.alt == this.props.alt + ) + } + + override toDOM(): HTMLElement { + if (!this.container) { + const container = markRaw(document.createElement('span')) + container.className = 'cm-image-rendered' + this.vueHostRegistration = this.vueHost.register( + h(DocumentationImage, { + src: this.props.src, + alt: this.props.alt, + }), + container, + ) + this.container = container + } + return this.container + } + + override destroy() { + this.vueHostRegistration?.unregister() + this.container = undefined + } +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts new file mode 100644 index 00000000000..bf5d1a6726a --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/lezer.d.ts @@ -0,0 +1,35 @@ +/** @file Private lezer-markdown symbols used by lezer-markdown parsers we have customized versions of. */ + +import { Tree, TreeBuffer } from '@lezer/common' +import { Element } from '@lezer/markdown' + +declare module '@lezer/markdown' { + export interface BlockContext { + block: CompositeBlock + stack: CompositeBlock[] + readonly buffer: Buffer + + addNode: (block: number | Tree, from: number, to?: number) => void + startContext: (type: number, start: number, value?: number) => void + } + + export interface CompositeBlock { + readonly type: number + // Used for indentation in list items, markup character in lists + readonly value: number + readonly from: number + readonly hash: number + end: number + readonly children: (Tree | TreeBuffer)[] + readonly positions: number[] + } + + export interface Buffer { + content: number[] + nodes: Tree[] + + write: (type: number, from: number, to: number, children?: number) => Buffer + writeElements: (elts: readonly Element[], offset?: number) => Buffer + finish: (type: number, length: number) => Tree + } +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts new file mode 100644 index 00000000000..5bf27634bbc --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/parse.ts @@ -0,0 +1,248 @@ +import { markdown as baseMarkdown, markdownLanguage } from '@codemirror/lang-markdown' +import type { Extension } from '@codemirror/state' +import type { Tree } from '@lezer/common' +import type { BlockContext, BlockParser, Line, MarkdownParser, NodeSpec } from '@lezer/markdown' +import { Element } 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], + defineNodes: [blockquoteNode], + }, + ], + }) +} + +function getType({ parser }: { parser: MarkdownParser }, name: string) { + const ty = parser.nodeSet.types.find((ty) => ty.name === name) + assertDefined(ty) + return ty.id +} + +/** Parser override to include the space in the delimiter. */ +const headerParser: BlockParser = { + name: 'ATXHeading', + parse: (cx, line) => { + let size = isAtxHeading(line) + if (size < 0) return false + const level = size + // If the character after the hashes is a space, treat it as part of the `HeaderMark`. + if (isSpace(line.text.charCodeAt(size))) size += 1 + const off = line.pos + const from = cx.lineStart + off + // Trailing spaces at EOL + const endOfSpace = skipSpaceBack(line.text, line.text.length, off) + let after = endOfSpace + // Trailing sequence of # (before EOL spaces) + while (after > off && line.text.charCodeAt(after - 1) == line.next) after-- + if (after == endOfSpace || after == off || !isSpace(line.text.charCodeAt(after - 1))) + after = line.text.length + const headerMark = getType(cx, 'HeaderMark') + const buf = cx.buffer + .write(headerMark, 0, size) + .writeElements(cx.parser.parseInline(line.text.slice(off + size, after), from + size), -from) + if (after < line.text.length) buf.write(headerMark, after - off, endOfSpace - off) + const node = buf.finish(getType(cx, `ATXHeading${level}`), line.text.length - off) + cx.nextLine() + cx.addNode(node, from) + return true + }, +} + +/** Parser override to include the space in the delimiter. */ +const bulletList: BlockParser = { + name: 'BulletList', + parse: (cx, line) => { + const size = isBulletList(line, cx, false) + if (size < 0) return false + const length = size + (isSpace(line.text.charCodeAt(line.pos + 1)) ? 1 : 0) + const bulletList = getType(cx, 'BulletList') + if (cx.block.type != bulletList) cx.startContext(bulletList, line.basePos, line.next) + const newBase = getListIndent(line, line.pos + 1) + cx.startContext(getType(cx, 'ListItem'), line.basePos, newBase - line.baseIndent) + cx.addNode(getType(cx, 'ListMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + length) + line.moveBaseColumn(newBase) + return null + }, +} + +/** Parser override to include the space in the delimiter. */ +const orderedList: BlockParser = { + name: 'OrderedList', + parse: (cx, line) => { + const size = isOrderedList(line, cx, false) + if (size < 0) return false + const orderedList = getType(cx, 'OrderedList') + if (cx.block.type != orderedList) + cx.startContext(orderedList, line.basePos, line.text.charCodeAt(line.pos + size - 1)) + const newBase = getListIndent(line, line.pos + size) + cx.startContext(getType(cx, 'ListItem'), line.basePos, newBase - line.baseIndent) + cx.addNode(getType(cx, 'ListMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size) + line.moveBaseColumn(newBase) + return null + }, +} + +const ENSO_BLOCKQUOTE_TYPE = 'EnsoBlockquote' + +/** Parser override to include the space in the delimiter. */ +const blockquoteParser: BlockParser = { + name: ENSO_BLOCKQUOTE_TYPE, + parse: (cx, line) => { + const size = isBlockquote(line) + if (size < 0) return false + const type = getType(cx, ENSO_BLOCKQUOTE_TYPE) + cx.startContext(type, line.pos) + cx.addNode(getType(cx, 'QuoteMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size) + line.moveBase(line.pos + size) + return null + }, + before: 'Blockquote', +} + +/** + * Replaces setext heading parser with a parser that never matches. + * + * When starting a bulleted list, the `SetextHeading` parser can match when a `-` has been typed and a following space + * hasn't been entered yet; the resulting style changes are distracting. To prevent this, we don't support setext + * headings; ATX headings seem to be much more popular anyway. + */ +const disableSetextHeading: BlockParser = { + name: 'SetextHeading', + parse: () => false, +} + +const blockquoteNode: NodeSpec = { + name: ENSO_BLOCKQUOTE_TYPE, + block: true, + composite: (cx, line) => { + if (line.next != 62 /* '>' */) return false + const size = isSpace(line.text.charCodeAt(line.pos + 1)) ? 2 : 1 + line.addMarker( + elt(getType(cx, 'QuoteMark'), cx.lineStart + line.pos, cx.lineStart + line.pos + size), + ) + line.moveBase(line.pos + size) + //bl.end = cx.lineStart + line.text.length + return true + }, +} + +function elt(type: number, from: number, to: number): Element { + return new (Element as any)(type, from, to) +} + +function isBlockquote(line: Line) { + return ( + line.next != 62 /* '>' */ ? -1 + : line.text.charCodeAt(line.pos + 1) == 32 ? 2 + : 1 + ) +} + +function isBulletList(line: Line, cx: BlockContext, breaking: boolean) { + return ( + (line.next == 45 || line.next == 43 || line.next == 42) /* '-+*' */ && + (line.pos == line.text.length - 1 || isSpace(line.text.charCodeAt(line.pos + 1))) && + (!breaking || inList(cx, 'BulletList') || line.skipSpace(line.pos + 2) < line.text.length) + ) ? + 1 + : -1 +} + +function isOrderedList(line: Line, cx: BlockContext, breaking: boolean) { + let pos = line.pos + let next = line.next + for (;;) { + if (next >= 48 && next <= 57 /* '0-9' */) pos++ + else break + if (pos == line.text.length) return -1 + next = line.text.charCodeAt(pos) + } + if ( + pos == line.pos || + pos > line.pos + 9 || + (next != 46 && next != 41) /* '.)' */ || + (pos < line.text.length - 1 && !isSpace(line.text.charCodeAt(pos + 1))) || + (breaking && + !inList(cx, 'OrderedList') && + (line.skipSpace(pos + 1) == line.text.length || + pos > line.pos + 1 || + line.next != 49)) /* '1' */ + ) + return -1 + return pos + 1 - line.pos +} + +function inList(cx: BlockContext, typeName: string) { + const type = getType(cx, typeName) + for (let i = cx.stack.length - 1; i >= 0; i--) if (cx.stack[i]!.type == type) return true + return false +} + +function getListIndent(line: Line, pos: number) { + const indentAfter = line.countIndent(pos, line.pos, line.indent) + const indented = line.countIndent(line.skipSpace(pos), pos, indentAfter) + return indented >= indentAfter + 5 ? indentAfter + 1 : indented +} + +// === Debugging === + +/** Represents the structure of a @{link Tree} in a JSON-compatible format. */ +export interface DebugTree { + /** The name of the {@link NodeType} */ + name: string + children: DebugTree[] +} + +// noinspection JSUnusedGlobalSymbols +/** @returns A debug representation of the provided {@link Tree} */ +export function debugTree(tree: Tree): DebugTree { + const cursor = tree.cursor() + let current: DebugTree[] = [] + const stack: DebugTree[][] = [] + cursor.iterate( + (node) => { + const children: DebugTree[] = [] + current.push({ + name: node.name, + children, + }) + stack.push(current) + current = children + }, + () => (current = stack.pop()!), + ) + return current[0]! +} + +// === Helpers === + +function skipSpaceBack(line: string, i: number, to: number) { + while (i > to && isSpace(line.charCodeAt(i - 1))) i-- + return i +} + +/** Returns the number of hash marks at the beginning of the line, or -1 if it is not in the range [1, 6] */ +function isAtxHeading(line: Line) { + if (line.next != 35 /* '#' */) return -1 + let pos = line.pos + 1 + while (pos < line.text.length && line.text.charCodeAt(pos) == 35) pos++ + if (pos < line.text.length && line.text.charCodeAt(pos) != 32) return -1 + const size = pos - line.pos + return size > 6 ? -1 : size +} + +function isSpace(ch: number) { + return ch == 32 || ch == 9 || ch == 10 || ch == 13 +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/theme.css b/app/gui/src/project-view/components/MarkdownEditor/theme.css deleted file mode 100644 index 409ad4ae611..00000000000 --- a/app/gui/src/project-view/components/MarkdownEditor/theme.css +++ /dev/null @@ -1,65 +0,0 @@ -/* -Lexical theme. Class names are derived from the `LexicalThemeClasses` type from `lexical`, with the hierarchy flattened -using `_` to separate levels. See the `lexicalTheme` function in `lexical/formatting.ts`. -*/ - -.heading_h1 { - font-weight: 700; - font-size: 20px; - line-height: 1.75; -} -.heading_h2 { - font-weight: 700; - font-size: 16px; - line-height: 1.75; -} -.heading_h3, -.heading_h4, -.heading_h5, -.heading_h6 { - font-size: 14px; - line-height: 2; -} - -.text_strikethrough { - text-decoration: line-through; -} -.text_italic { - font-style: italic; -} -.text_bold { - font-weight: bold; -} - -.quote { - margin-left: 0.2em; - border-left: 0.3em solid #ccc; - padding-left: 1.6em; -} - -.paragraph { - margin-bottom: 0.5em; -} - -.list_ol { - list-style-type: decimal; - list-style-position: outside; - padding-left: 1.6em; -} -.list_ul { - list-style-type: disc; - list-style-position: outside; - padding-left: 1.6em; -} - -.image > img { - display: inline; - margin: 0 0.1em; -} - -.link { - color: #555; - &:hover { - text-decoration: underline; - } -} diff --git a/app/gui/src/project-view/components/SelectionDropdown.vue b/app/gui/src/project-view/components/SelectionDropdown.vue index 03ab5c62544..a0e8b7697ad 100644 --- a/app/gui/src/project-view/components/SelectionDropdown.vue +++ b/app/gui/src/project-view/components/SelectionDropdown.vue @@ -4,7 +4,7 @@ import DropdownMenu from '@/components/DropdownMenu.vue' import MenuButton from '@/components/MenuButton.vue' import SvgIcon from '@/components/SvgIcon.vue' -import { SelectionMenuOption } from '@/components/visualizations/toolbar' +import type { SelectionMenuOption } from '@/components/visualizations/toolbar' import { ref } from 'vue' type Key = number | string | symbol diff --git a/app/gui/src/project-view/components/ToggleIcon.vue b/app/gui/src/project-view/components/ToggleIcon.vue index 5d033111c9c..7c22bbaa1e0 100644 --- a/app/gui/src/project-view/components/ToggleIcon.vue +++ b/app/gui/src/project-view/components/ToggleIcon.vue @@ -8,7 +8,7 @@ import MenuButton from '@/components/MenuButton.vue' import SvgIcon from '@/components/SvgIcon.vue' -import { URLString } from '@/util/data/urlString' +import type { URLString } from '@/util/data/urlString' import type { Icon } from '@/util/iconName' const toggledOn = defineModel({ default: false }) diff --git a/app/gui/src/project-view/components/VueComponentHost.vue b/app/gui/src/project-view/components/VueComponentHost.vue new file mode 100644 index 00000000000..b785f728d2d --- /dev/null +++ b/app/gui/src/project-view/components/VueComponentHost.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts b/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts index 844d092308b..be6224c8dc2 100644 --- a/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts +++ b/app/gui/src/project-view/components/lexical/LinkPlugin/index.ts @@ -1,15 +1,9 @@ import { documentationEditorBindings } from '@/bindings' -import { IMAGE } from '@/components/MarkdownEditor/ImagePlugin' -import { $isImageNode } from '@/components/MarkdownEditor/ImagePlugin/imageNode' -import type { LexicalMarkdownPlugin } from '@/components/MarkdownEditor/markdown' import type { LexicalPlugin } from '@/components/lexical' -import { $createLinkNode, $isLinkNode, AutoLinkNode, LinkNode } from '@lexical/link' -import type { Transformer } from '@lexical/markdown' +import { AutoLinkNode, LinkNode } from '@lexical/link' import { $getNearestNodeOfType } from '@lexical/utils' import { - $createTextNode, $getSelection, - $isTextNode, CLICK_COMMAND, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_LOW, @@ -27,51 +21,6 @@ const EMAIL_REGEX = export const __TEST = { URL_REGEX, EMAIL_REGEX } -const LINK: Transformer = { - dependencies: [LinkNode], - export: (node, exportChildren, exportFormat) => { - if (!$isLinkNode(node)) { - return null - } - const title = node.getTitle() - const linkContent = - title ? - `[${node.getTextContent()}](${node.getURL()} "${title}")` - : `[${node.getTextContent()}](${node.getURL()})` - const firstChild = node.getFirstChild() - // Add text styles only if link has single text node inside. If it's more - // then one we ignore it as markdown does not support nested styles for links - if (node.getChildrenSize() === 1 && $isTextNode(firstChild)) { - return exportFormat(firstChild, linkContent) - } else if (node.getChildrenSize() === 1 && $isImageNode(firstChild)) { - // Images sometimes happen to be inside links (when importing nodes from HTML). - // The link is not important for us (this type of layout is not supported in markdown), - // but we want to display the image. - return IMAGE.export(firstChild, exportChildren, exportFormat) - } else { - return linkContent - } - }, - importRegExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))/, - regExp: /(?:\[([^[]+)\])(?:\((?:([^()\s]+)(?:\s"((?:[^"]*\\")*[^"]*)"\s*)?)\))$/, - replace: (textNode, match) => { - const [, linkText, linkUrl, linkTitle] = match - if (linkText && linkUrl) { - const linkNode = $createLinkNode(linkUrl, { - title: linkTitle ?? null, - rel: 'nofollow', - target: '_blank', - }) - const linkTextNode = $createTextNode(linkText) - linkTextNode.setFormat(textNode.getFormat()) - linkNode.append(linkTextNode) - textNode.replace(linkNode) - } - }, - trigger: ')', - type: 'text-match', -} - /** TODO: Add docs */ export function $getSelectedLinkNode() { const selection = $getSelection() @@ -87,17 +36,6 @@ export function $getSelectedLinkNode() { } } -const linkClickHandler = documentationEditorBindings.handler({ - openLink() { - const link = $getSelectedLinkNode() - if (link instanceof LinkNode) { - window.open(link.getURL(), '_blank')?.focus() - return true - } - return false - }, -}) - const autoLinkClickHandler = documentationEditorBindings.handler({ openLink() { const link = $getSelectedLinkNode() @@ -109,18 +47,6 @@ const autoLinkClickHandler = documentationEditorBindings.handler({ }, }) -export const linkPlugin: LexicalMarkdownPlugin = { - nodes: [LinkNode], - transformers: [LINK], - register(editor: LexicalEditor): void { - editor.registerCommand( - CLICK_COMMAND, - (event) => linkClickHandler(event), - COMMAND_PRIORITY_CRITICAL, - ) - }, -} - export const autoLinkPlugin: LexicalPlugin = { nodes: [AutoLinkNode], register(editor: LexicalEditor): void { diff --git a/app/gui/src/project-view/components/shared/AgGridTableView.vue b/app/gui/src/project-view/components/shared/AgGridTableView.vue index 3135cf1b369..786c74ff9f5 100644 --- a/app/gui/src/project-view/components/shared/AgGridTableView.vue +++ b/app/gui/src/project-view/components/shared/AgGridTableView.vue @@ -47,7 +47,7 @@ import { tsvTableToEnsoExpression, writeClipboard, } from '@/components/GraphEditor/clipboard' -import { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue' +import type { TextFormatOptions } from '@/components/visualizations/TableVisualization.vue' import { useAutoBlur } from '@/util/autoBlur' import type { CellEditingStartedEvent, @@ -66,6 +66,8 @@ import type { RowHeightParams, SortChangedEvent, } from 'ag-grid-enterprise' +import * as iter from 'enso-common/src/utilities/data/iter' +import { LINE_BOUNDARIES } from 'enso-common/src/utilities/data/string' import { type ComponentInstance, reactive, ref, shallowRef, watch } from 'vue' const DEFAULT_ROW_HEIGHT = 22 @@ -114,14 +116,11 @@ function getRowHeight(params: RowHeightParams): number { return DEFAULT_ROW_HEIGHT } - const returnCharsCount = textValues.map((text: string) => { - const crlfCount = (text.match(/\r\n/g) || []).length - const crCount = (text.match(/\r/g) || []).length - const lfCount = (text.match(/\n/g) || []).length - return crCount + lfCount - crlfCount - }) + const returnCharsCount = iter.map(textValues, (text) => + iter.count(text.matchAll(LINE_BOUNDARIES)), + ) - const maxReturnCharsCount = Math.max(...returnCharsCount) + const maxReturnCharsCount = iter.reduce(returnCharsCount, Math.max, 0) return (maxReturnCharsCount + 1) * DEFAULT_ROW_HEIGHT } diff --git a/app/gui/src/project-view/components/visualizations/TableVisualization.vue b/app/gui/src/project-view/components/visualizations/TableVisualization.vue index 6c15559e762..1de42873232 100644 --- a/app/gui/src/project-view/components/visualizations/TableVisualization.vue +++ b/app/gui/src/project-view/components/visualizations/TableVisualization.vue @@ -1,7 +1,7 @@