From a04deb2d0fd50484b7dd0e17abd4d7cb8b58a9d6 Mon Sep 17 00:00:00 2001 From: Kaz Date: Wed, 26 Feb 2025 08:19:43 -0800 Subject: [PATCH 1/5] Documentation editor: Code block support and block format status New features: - New button: Insert code block. Inserts a code block after the cursor, or converts the selected text to a code block. - Block type menu now shows current type. Bug fixes: - Fix: In a multiline block quote, the space was not included as part of the delimiter except when parsing the first line. - Fix: Fenced code block delimiters were not shown when the cursor was inside the block. - Fix: Inline formatting now respects block-formatting delimiters. - Fix: Formatting controls were enabled inside code blocks. - Fix: Inline-formatted text within list items was not rendered. --- .../MarkdownEditor/BlockTypeDropdown.vue | 113 +++-- .../MarkdownEditor/MarkdownEditorImpl.vue | 29 +- .../__tests__/blockFormatting.test.ts | 356 ++++++++++++---- .../__tests__/inlineFormatting.test.ts | 20 + .../__tests__/inlineFormattingTrees.test.ts | 22 +- .../MarkdownEditor/__tests__/testInput.ts | 2 + .../codemirror/decoration/editingAtCursor.ts | 33 +- .../codemirror/decoration/linksAndImages.ts | 2 +- .../codemirror/formatting/block.ts | 399 +++++++----------- .../codemirror/formatting/index.ts | 57 ++- .../codemirror/formatting/inline.ts | 114 +---- .../codemirror/formatting/markdownEdit.ts | 92 ++++ .../markdown/markdownDocument.ts | 2 +- .../MarkdownEditor/markdown/textDocument.ts | 5 + .../MarkdownEditor/markdown/trees.ts | 226 +++++++--- .../MarkdownEditor/markdown/types.ts | 80 ++++ .../components/SelectionDropdown.vue | 13 +- .../components/visualizations/toolbar.ts | 6 + .../project-view/util/ast/aliasAnalysis.ts | 4 +- .../src/project-view/util/codemirror/text.ts | 6 +- app/gui/src/project-view/util/data/range.ts | 4 +- app/lezer-markdown/src/markdown.ts | 5 +- .../src/ast/__tests__/ensoMarkdown.test.ts | 50 +++ app/ydoc-shared/src/util/data/range.ts | 11 + app/ydoc-shared/src/util/data/text.ts | 6 +- 25 files changed, 1072 insertions(+), 585 deletions(-) create mode 100644 app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/markdownEdit.ts diff --git a/app/gui/src/project-view/components/MarkdownEditor/BlockTypeDropdown.vue b/app/gui/src/project-view/components/MarkdownEditor/BlockTypeDropdown.vue index ec5d45a12460..e7b33ed2671d 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/BlockTypeDropdown.vue +++ b/app/gui/src/project-view/components/MarkdownEditor/BlockTypeDropdown.vue @@ -1,68 +1,67 @@ - - diff --git a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue index 784aa1ea7553..76c98f4a93fb 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue +++ b/app/gui/src/project-view/components/MarkdownEditor/MarkdownEditorImpl.vue @@ -3,6 +3,7 @@ import CodeMirrorRoot from '@/components/CodeMirrorRoot.vue' import { transformPastedText } from '@/components/DocumentationEditor/textPaste' import BlockTypeDropdown from '@/components/MarkdownEditor/BlockTypeDropdown.vue' import { ensoMarkdown, useMarkdownFormatting } from '@/components/MarkdownEditor/codemirror' +import { type BlockType } from '@/components/MarkdownEditor/codemirror/formatting' import SvgButton from '@/components/SvgButton.vue' import ToggleIcon from '@/components/ToggleIcon.vue' import VueHostRender, { VueHostInstance } from '@/components/VueHostRender.vue' @@ -36,8 +37,7 @@ const { editorView, readonly, putTextAt } = useCodeMirror(editorRoot, { ], vueHost: () => vueHost, }) -const { toggleHeader, toggleQuote, toggleList, italic, bold, insertLink } = - useMarkdownFormatting(editorView) +const { italic, bold, insertLink, blockType, insertCodeBlock } = useMarkdownFormatting(editorView) useLinkTitles(editorView, { readonly }) @@ -69,21 +69,20 @@ defineExpose({ @@ -227,7 +232,7 @@ defineExpose({ } } - .list:not(.content) { + .list:not(*) { /* Hide indentation spaces */ display: none; } diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/blockFormatting.test.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/blockFormatting.test.ts index aef6c392a73f..1d418de6a092 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/__tests__/blockFormatting.test.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/blockFormatting.test.ts @@ -1,12 +1,171 @@ -import { setupEditor } from '@/components/MarkdownEditor/__tests__/testInput' +import { printTestInput, setupEditor } from '@/components/MarkdownEditor/__tests__/testInput' import { - type HeaderLevel, - toggleHeader, - toggleList, - toggleQuote, + canInsertCodeBlock, + getBlockType, + insertCodeBlock, + removeCodeBlock, + setBlockType, } from '@/components/MarkdownEditor/codemirror/formatting/block' +import { + type DelimitedBlockType, + type SupportedBlockType, +} from '@/components/MarkdownEditor/markdown/types' +import { EditorView } from '@codemirror/view' +import * as objects from 'enso-common/src/utilities/data/object' import { expect, test } from 'vitest' +const blockFormatCases = [ + { + formatted: 'Normal text|', + type: 'Paragraph', + }, + { + formatted: '# Header|', + type: 'ATXHeading1', + paragraph: 'Header|', + }, + { + formatted: '## Header|', + type: 'ATXHeading2', + paragraph: 'Header|', + }, + { + formatted: '### Header|', + type: 'ATXHeading3', + paragraph: 'Header|', + }, + { + formatted: '- Bullet list|', + type: 'BulletList', + paragraph: 'Bullet list|', + }, + { + formatted: '23. Ordered list|', + type: 'OrderedList', + paragraph: 'Ordered list|', + normalized: '1. Ordered list|', + }, + { + formatted: '> Quoted text|', + type: 'Blockquote', + paragraph: 'Quoted text|', + }, + { + formatted: '# |Header 1\n# Header 1|', + type: 'ATXHeading1', + paragraph: '|Header 1\nHeader 1|', + }, + { + formatted: '# |Header 1\n## Header 2\n### Header 3|', + type: undefined, + paragraph: '|Header 1\nHeader 2\nHeader 3|', + }, +] satisfies { + paragraph?: string + type: (SupportedBlockType & DelimitedBlockType) | undefined + formatted: string + normalized?: string +}[] +const nonParagraphCases = blockFormatCases.filter(({ type }) => type && type !== 'Paragraph') + +test.each(blockFormatCases)('Get block type: $source', ({ formatted, type }) => + expect(getBlockType(setupEditor(formatted).state)).toEqual(type), +) +test.each(nonParagraphCases)('Remove block formatting: $formatted', ({ formatted, paragraph }) => { + const view = setupEditor(formatted) + view.dispatch(setBlockType(view.state, 'Paragraph')) + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual(paragraph) + expect(getBlockType(view.state)).toEqual('Paragraph') +}) +test.each(nonParagraphCases)( + 'Add block formatting: $formatted', + ({ formatted, type, paragraph, normalized }) => { + const view = setupEditor(paragraph!) + view.dispatch(setBlockType(view.state, type!)) + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual( + normalized ?? formatted, + ) + }, +) + +const crossTypeCasesInputs = [ + { + source: '# Single| line', + formats: { + ATXHeading2: '## Single| line', + ATXHeading3: '### Single| line', + Blockquote: '> Single| line', + OrderedList: '1. Single| line', + BulletList: '- Single| line', + }, + }, + { + source: '# Multiple| lines\n# Same| format', + formats: { + ATXHeading2: '## Multiple| lines\n## Same| format', + ATXHeading3: '### Multiple| lines\n### Same| format', + Blockquote: '> Multiple| lines\n> Same| format', + OrderedList: '1. Multiple| lines\n1. Same| format', + BulletList: '- Multiple| lines\n- Same| format', + }, + }, + { + source: '# Multiple| lines\n## Different| formats', + formats: { + ATXHeading1: '# Multiple| lines\n# Different| formats', + ATXHeading2: '## Multiple| lines\n## Different| formats', + ATXHeading3: '### Multiple| lines\n### Different| formats', + Blockquote: '> Multiple| lines\n> Different| formats', + OrderedList: '1. Multiple| lines\n1. Different| formats', + BulletList: '- Multiple| lines\n- Different| formats', + }, + }, + { + source: '- Multi-|line\n- Block| input', + formats: { + ATXHeading1: '# Multi-|line\n# Block| input', + ATXHeading2: '## Multi-|line\n## Block| input', + ATXHeading3: '### Multi-|line\n### Block| input', + Blockquote: '> Multi-|line\n> Block| input', + OrderedList: '1. Multi-|line\n1. Block| input', + }, + }, +] satisfies { source: string; formats: Partial> }[] +const crossTypeCases = crossTypeCasesInputs.map(({ source, formats }) => + objects.unsafeEntries(formats).map((input) => { + const [type, formatted] = input! + return { + source, + type: type!, + formatted: formatted!, + } + }), +) +test.each(crossTypeCases)('Format-to-format: $formatted', ({ source, type, formatted }) => { + const view = setupEditor(source) + view.dispatch(setBlockType(view.state, type)) + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual(formatted) +}) + +/** Supported header levels. */ +type HeaderLevel = 1 | 2 | 3 + +function toggleHeader(view: EditorView, level: HeaderLevel) { + const headerType = `ATXHeading${level}` as DelimitedBlockType & SupportedBlockType + const newType = getBlockType(view.state) === headerType ? 'Paragraph' : headerType + view.dispatch(setBlockType(view.state, newType)) +} + +function toggleList(view: EditorView, listType: 'BulletList' | 'OrderedList') { + const newType = getBlockType(view.state) === listType ? 'Paragraph' : listType + view.dispatch(setBlockType(view.state, newType)) +} + +function toggleQuote(view: EditorView) { + const newType = getBlockType(view.state) === 'Blockquote' ? 'Paragraph' : 'Blockquote' + view.dispatch(setBlockType(view.state, newType)) +} + interface TestCase { desc?: string source: string @@ -23,6 +182,16 @@ const headerTestCases: HeaderTestCase[] = [ headerLevel: 1, expected: '# Some text', }, + { + source: '|Some text', + headerLevel: 1, + expected: '# Some text', + }, + { + source: 'Some text|', + headerLevel: 1, + expected: '# Some text', + }, { source: '**Bold| text**', headerLevel: 1, @@ -73,16 +242,6 @@ const headerTestCases: HeaderTestCase[] = [ headerLevel: 1, expected: '# Don’t touch this one\n# Touch this one\n# Make this one header', }, - { - source: '```\nSome code\nHead|er in code block\nMore code\n```', - headerLevel: 1, - expected: '```\nSome code\n# Header in code block\nMore code\n```', - }, - { - source: 'Some paragraph\n```\nSome code\n# Head|er in code block\nMore code\n```', - headerLevel: 2, - expected: 'Some paragraph\n```\nSome code\n## Header in code block\nMore code\n```', - }, { source: '> This is a quote\nHeader| in quote', headerLevel: 1, @@ -91,7 +250,7 @@ const headerTestCases: HeaderTestCase[] = [ { source: '1. This is a list item\n2. This is| a future header', headerLevel: 1, - expected: '1. This is a list item\n# 2. This is a future header', + expected: '1. This is a list item\n# This is a future header', }, ] @@ -110,7 +269,7 @@ const quotesTestCases: TestCase[] = [ { desc: 'Multiline quote', source: 'This |is a quote\nThis is anoth|er quote', - expected: '> This is a quote\nThis is another quote', + expected: '> This is a quote\n> This is another quote', }, { desc: 'Disable quote', @@ -119,29 +278,9 @@ const quotesTestCases: TestCase[] = [ }, { desc: 'Disable multiline quote', - source: '> This is| a quote\nThis is |another quote\n\nThis is a new paragraph', + source: '> This is| a quote\n> This is |another quote\n\nThis is a new paragraph', expected: 'This is a quote\nThis is another quote\n\nThis is a new paragraph', }, - { - desc: 'Enable quote in code block', - source: '```\nSome code\nThis i|s a quote\nMore code\n```', - expected: '```\nSome code\n> This is a quote\nMore code\n```', - }, - { - desc: 'Enable multiline quote in code block', - source: '```\nSome code\nThis i|s a quote\nAlso |a quote\nMore code\n```', - expected: '```\nSome code\n> This is a quote\nAlso a quote\nMore code\n```', - }, - { - desc: 'Disable quote in code block', - source: '```\nSome code\n> This i|s a quote\nMore code\n```', - expected: '```\nSome code\nThis is a quote\nMore code\n```', - }, - { - desc: 'Disable multiline quote in code block', - source: '```\nSome code\n> This i|s a quote\nAlso a q|uote\n\nMore code\n```', - expected: '```\nSome code\nThis is a quote\nAlso a quote\n\nMore code\n```', - }, ] test.each(quotesTestCases)('markdown quotes $desc', ({ source, expected }) => { @@ -152,10 +291,15 @@ test.each(quotesTestCases)('markdown quotes $desc', ({ source, expected }) => { const unorderedListTestCases: TestCase[] = [ { - desc: 'Create unordered list from empty line', + desc: 'Create unordered list from empty document', source: '|', expected: '- ', }, + { + desc: 'Create unordered list from empty line', + source: 'Some text\n|', + expected: 'Some text\n- ', + }, { desc: 'Create simple unordered list', source: '|List item\nList item\nList |item', @@ -171,31 +315,11 @@ const unorderedListTestCases: TestCase[] = [ source: '1. List| item\n2. List item\n3. Lis|t item', expected: '- List item\n- List item\n- List item', }, - { - desc: 'Disable unordered list in code block', - source: '```\nSome code\n- Lis|t item\nMore code\n```', - expected: '```\nSome code\nList item\nMore code\n```', - }, - { - desc: 'Create unordered list in code block', - source: '```\nSome code\nLis|t item\nAnother |list item\n```', - expected: '```\nSome code\n- List item\n- Another list item\n```', - }, - { - desc: 'Change ordered list to unordered list in code block', - source: '```\nSome code\n1. List| item\n2. List item\n3. Lis|t item\nSome paragraph\n```', - expected: '```\nSome code\n- List item\n- List item\n- List item\nSome paragraph\n```', - }, - { - desc: 'Disable unordered list in code block', - source: '```\nSome code\n- List| item\n- List item\n- Lis|t item\nSome paragraph\n```', - expected: '```\nSome code\nList item\nList item\nList item\nSome paragraph\n```', - }, ] test.each(unorderedListTestCases)('markdown unordered list $desc', ({ source, expected }) => { const view = setupEditor(source) - toggleList(view, 'unordered') + toggleList(view, 'BulletList') expect(view.state.doc.toString()).toEqual(expected) }) @@ -208,7 +332,7 @@ const orderedListTestCases: TestCase[] = [ { desc: 'Create simple ordered list', source: 'Li|st item\nList item\nLis|t item', - expected: '1. List item\n2. List item\n3. List item', + expected: '1. List item\n1. List item\n1. List item', }, { desc: 'Disable ordered list', @@ -218,27 +342,109 @@ const orderedListTestCases: TestCase[] = [ { desc: 'Change unordered list to ordered list', source: '- List| item\n- List item\n- Lis|t item', - expected: '1. List item\n2. List item\n3. List item', + expected: '1. List item\n1. List item\n1. List item', + }, +] + +test.each(orderedListTestCases)('markdown ordered list $desc', ({ source, expected }) => { + const view = setupEditor(source) + toggleList(view, 'OrderedList') + expect(view.state.doc.toString()).toEqual(expected) +}) + +test.each([ + { + source: '|', + expected: '```\n|\n```', }, { - desc: 'Create ordered list in code block', - source: '```\nSome code\nLis|t item\nAnother |list item\n```', - expected: '```\nSome code\n1. List item\n2. Another list item\n```', + source: 'Paragraph |before', + expected: 'Paragraph before\n```\n|\n```', }, { - desc: 'Change unordered list to ordered list in code block', - source: '```\nSome code\n- List| item\n- List item\n- Lis|t item\nSome paragraph\n```', - expected: '```\nSome code\n1. List item\n2. List item\n3. List item\nSome paragraph\n```', + source: 'Paragraph before|', + expected: 'Paragraph before\n```\n|\n```', }, { - desc: 'Disable ordered list in code block', - source: '```\nSome code\n1. List| item\n2. List item\n3. Lis|t item\nSome paragraph\n```', - expected: '```\nSome code\nList item\nList item\nList item\nSome paragraph\n```', + source: '|Paragraph before', + expected: 'Paragraph before\n```\n|\n```', }, -] + { + source: 'Paragraph |before\nParagraph after', + expected: 'Paragraph before\n```\n|\n```\nParagraph after', + }, + { + source: '# Heading |before\n# Heading after', + expected: '# Heading before\n```\n|\n```\n# Heading after', + }, + { + source: '- List|\n- Before', + expected: '- List\n- Before\n```\n|\n```', + }, + { + source: '```\nCode before|\n```', + expected: '```\nCode before\n```\n```\n|\n```', + }, +])('Insert code block: $source', ({ source, expected }) => { + const view = setupEditor(source) + expect(canInsertCodeBlock(view.state)).toBe(true) + view.dispatch(insertCodeBlock(view.state)) + expect(getBlockType(view.state)).toBe('FencedCode') + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual(expected) +}) -test.each(orderedListTestCases)('markdown ordered list $desc', ({ source, expected }) => { +test.each([ + { + source: 'Text before\n|Selected text|\nText after', + expected: 'Text before\n```\n|Selected text|\n```\nText after', + }, + { + source: 'Text before\nSelected |text|\nText after', + expected: 'Text before\n```\nSelected |text|\n```\nText after', + }, + { + source: 'Text before\n|Selected| text\nText after', + expected: 'Text before\n```\n|Selected| text\n```\nText after', + }, + { + source: 'Text before\nSelected |text\non multiple| lines\nText after', + expected: 'Text before\n```\nSelected |text\non multiple| lines\n```\nText after', + }, +])('Convert to code block: $source', ({ source, expected }) => { const view = setupEditor(source) - toggleList(view, 'ordered') - expect(view.state.doc.toString()).toEqual(expected) + view.dispatch(insertCodeBlock(view.state)) + expect(getBlockType(view.state)).toBe('FencedCode') + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual(expected) +}) + +test.each([ + { + source: 'Text before\n```\nCode| block\n```\nText after', + expected: 'Text before\nCode| block\nText after', + }, + { + source: 'Text before\n```md\nCode| block\n```\nText after', + expected: 'Text before\nCode| block\nText after', + }, + { + source: 'Text before\n```|\n```\nText after', + expected: 'Text before|\nText after', + }, + { + source: 'Text before\n```|\n```\nText after', + expected: 'Text before|\nText after', + }, + { + source: 'Text before\n```\nUnclosed code|', + expected: 'Text before\nUnclosed code|', + }, + { + source: 'Text before\n```\nUnclosed code|\n', + expected: 'Text before\nUnclosed code|\n', + }, +])('Remove code block: $source', ({ source, expected }) => { + const view = setupEditor(source) + expect(getBlockType(view.state)).toBe('FencedCode') + view.dispatch(removeCodeBlock(view.state)) + expect(printTestInput(view.state.doc.toString(), view.state.selection.main)).toEqual(expected) }) diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormatting.test.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormatting.test.ts index 61cf932ba2a1..c0951f199b3a 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormatting.test.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormatting.test.ts @@ -389,6 +389,26 @@ const selectionCases: TestCase[] = [ italicToggled: '*~~Node extension i~~|nsid|~~e word~~*', }, */ + { + source: [ + 'Block |types', + '# Block types', + '## Block types', + '### Block types', + '> Block types', + '1. Block types', + '- Block| types', + ].join('\n'), + italicToggled: [ + 'Block |*types*', + '# *Block types*', + '## *Block types*', + '### *Block types*', + '> *Block types*', + '1. *Block types*', + '- *Block*| types', + ].join('\n'), + }, ] function checkGetFormat(input: TestCase) { diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormattingTrees.test.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormattingTrees.test.ts index f2ad752fe6bf..6676dadbaf36 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormattingTrees.test.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/inlineFormattingTrees.test.ts @@ -98,11 +98,31 @@ test.each([ source: '`Inline unformattable parts |before` and `after| selection`', ranges: ['`Inline unformattable parts before` |and| `after selection`'], }, + { + source: [ + 'Block |types', + '# Block types', + '## Block types', + '### Block types', + '> Block types', + '1. Block types', + '- Block| types', + ].join('\n'), + ranges: [ + 'Block |types|\n# Block types\n## Block types\n### Block types\n> Block types\n1. Block types\n- Block types', + 'Block types\n# |Block types|\n## Block types\n### Block types\n> Block types\n1. Block types\n- Block types', + 'Block types\n# Block types\n## |Block types|\n### Block types\n> Block types\n1. Block types\n- Block types', + 'Block types\n# Block types\n## Block types\n### |Block types|\n> Block types\n1. Block types\n- Block types', + 'Block types\n# Block types\n## Block types\n### Block types\n> |Block types|\n1. Block types\n- Block types', + 'Block types\n# Block types\n## Block types\n### Block types\n> Block types\n1. |Block types|\n- Block types', + 'Block types\n# Block types\n## Block types\n### Block types\n> Block types\n1. Block types\n- |Block| types', + ], + }, ])('Range-splitting', ({ source, ranges }) => { const input = parseTestInput(source) const md = new MarkdownDocument(Text.of([input.doc]), ensoMarkdownParser.parse(input.doc)) const trim = (range: Range) => md.trimRangeSpaces(trimRangeDelimiters(range, md.tree)) - const selection = trim(Range.tryFromBounds(input.selection.anchor, input.selection.head)!) + const selection = trim(Range.unsafeFromBounds(input.selection.anchor, input.selection.head)) const rangesFound: Range[] = [] splitRange(selection, md.tree, rangesFound.push.bind(rangesFound), trim) expect( diff --git a/app/gui/src/project-view/components/MarkdownEditor/__tests__/testInput.ts b/app/gui/src/project-view/components/MarkdownEditor/__tests__/testInput.ts index ffd0f867595d..60c02f699916 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/__tests__/testInput.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/__tests__/testInput.ts @@ -1,4 +1,5 @@ import { ensoMarkdownSyntax } from '@/components/MarkdownEditor/markdown/syntax' +import { assert } from '@/util/assert' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' @@ -23,6 +24,7 @@ export function parseTestInput(source: string): { selection: { anchor: number; head: number } } { const selectionStart = source.indexOf('|') + assert(selectionStart !== -1) const selectionEnd = source.indexOf('|', selectionStart + 1) const selection = { anchor: selectionStart, diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/editingAtCursor.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/editingAtCursor.ts index d6190aabe049..80e8bd414461 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/editingAtCursor.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/editingAtCursor.ts @@ -1,19 +1,40 @@ -import { type EditorSelection, type Extension, RangeSetBuilder, type Text } from '@codemirror/state' +import { expandRangeToIncludeFencedBlocks } from '@/components/MarkdownEditor/markdown/trees' +import { syntaxTree } from '@codemirror/language' +import { + type EditorSelection, + type Extension, + RangeSetBuilder, + type SelectionRange, + type Text, +} from '@codemirror/state' import { Decoration, type DecorationSet, EditorView } from '@codemirror/view' +import { type Tree } from '@lezer/common' +import { Range } from 'ydoc-shared/util/data/range' /** Extension applying a CSS class to identify the cursor's location in the document, for edit-mode rendering. */ export function cursorDecoratorExt(): Extension { return EditorView.decorations.compute(['selection', 'doc'], (state) => - cursorDecorations(state.selection, state.doc), + cursorDecorations(state.selection, state.doc, syntaxTree(state)), ) } -function cursorDecorations(selection: EditorSelection, doc: Text): DecorationSet { +function linesToDecorate(range: Range, doc: Text, tree: Tree): Range { + const expandedRange = expandRangeToIncludeFencedBlocks(tree, range) + return Range.unsafeFromBounds( + doc.lineAt(expandedRange.from).number, + doc.lineAt(expandedRange.to).number, + ) +} + +function selectionRange(selection: SelectionRange): Range { + return Range.unsafeFromBounds(selection.from, selection.to) +} + +function cursorDecorations(selection: EditorSelection, doc: Text, tree: Tree): DecorationSet { const builder = new RangeSetBuilder() for (const range of selection.ranges) { - const lineFrom = doc.lineAt(range.from) - const lineTo = doc.lineAt(range.to) - for (let i = lineFrom.number; i <= lineTo.number; i++) { + const lineRange = linesToDecorate(selectionRange(range), doc, tree) + for (let i = lineRange.from; i <= lineRange.to; i++) { const line = doc.line(i) builder.add( line.from, diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/linksAndImages.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/linksAndImages.ts index 92ddbaf95945..bafd9da6cdb7 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/linksAndImages.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/decoration/linksAndImages.ts @@ -70,7 +70,7 @@ function decorateLink( : undefined if (!parsed) return const { linkOrImage: link, text, url, title } = parsed - if (text.length === 0) return + if (text.empty) return emitDecoration( link, Decoration.mark({ diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/block.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/block.ts index eb198846e1ca..9d92fd2fad65 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/block.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/block.ts @@ -1,285 +1,170 @@ -/** - * Editing markdown with CodeMirror. - * Allows creating, removing and changing headers, lists and quotes. - * - * One trick used throughout this module implementation is related to editing markdown inside code blocks. - * If we detect such edit, internals of the codeblock are reparsed as a separate markdown document, and the edits - * are applied to it, adjusting text positions as needed. It allows to reuse the common code while providing - * basic support for editing codeblocks. - */ - -import { ChangeSpec, Line } from '@codemirror/state' -import { EditorView } from '@codemirror/view' -import { SyntaxNode, Tree } from '@lezer/common' -import { ensoMarkdownParser } from 'ydoc-shared/ast/ensoMarkdown' - -// =============== -// === Helpers === -// =============== +import { MarkdownEdit } from '@/components/MarkdownEditor/codemirror/formatting/markdownEdit' +import { + isBlockDelimiter, + nodeRange, + topLevelBlock, + visitBlocks, + visitLeafBlocks, +} from '@/components/MarkdownEditor/markdown/trees' +import { + type DelimitedBlockType, + type SupportedBlockType, + isSupportedBlockType, +} from '@/components/MarkdownEditor/markdown/types' +import { syntaxTree } from '@codemirror/language' +import { + type EditorState, + Line, + type SelectionRange, + type TransactionSpec, +} from '@codemirror/state' +import { Range } from 'ydoc-shared/util/data/range' /** - * Helper class for managing changes to the markdown document. - * Recorded changes can be adjusted by some `offset` for editing subparts of the document, like internals of codeblocks. + * @returns The block type to report to the user for the currently-selected text. `undefined` if + * the selection contains multiple block types, or is of an "unsupported" type that the parser + * recognizes. */ -class MutableChangeSet { - private addList: ChangeSpec[] = [] - private replaceList: ChangeSpec[] = [] - private removeList: ChangeSpec[] = [] - - public constructor(private offset: number) {} - - /** Merge changes from another changeset */ - public merge(other: MutableChangeSet) { - this.addList.push(...other.addList) - this.replaceList.push(...other.replaceList) - this.removeList.push(...other.removeList) - } - - /** Record a change adding text at the given position. */ - public add(from: number, to: number, insert: string) { - this.addList.push({ from: from + this.offset, to: to + this.offset, insert }) - } - - /** Record a change replacing text at the given position. */ - public replace(from: number, to: number, insert: string) { - this.replaceList.push({ from: from + this.offset, to: to + this.offset, insert }) - } - - /** Record a change removing text at the given position. */ - public remove(from: number, to: number) { - this.removeList.push({ from: from + this.offset, to: to + this.offset, insert: '' }) - } - - /** - * Dispatch the recorded changes to the editor. - * If `suppressRemoveWhenAdding` is true, `remove` changes are only applied when there are no `add` changes. - * It is used when toggling multiple lines at once, to make them consistent on first editing. - * (consider editing two lines at once using `toggleHeader` when one line is already a header, the other is not) - */ - public dispatch(view: EditorView, suppressRemoveWhenAdding: boolean = false) { - if (this.addList.length > 0 && suppressRemoveWhenAdding) this.removeList = [] - view.dispatch({ changes: [...this.addList, ...this.replaceList, ...this.removeList] }) - } -} - -/** Helper to reduce the number of arguments in functions. Simply wraps common arguments. */ -class Context { - private offset: number = 0 - public constructor( - public tree: Tree, - public src: string, - public line: Line, - ) {} - public withOffset(offset: number): Context { - const newContext = new Context(this.tree, this.src, this.line) - newContext.offset = offset - return newContext - } - public lineStart() { - return this.line.from - this.offset - } - public lineEnd() { - return this.line.to - this.offset - } - public lineText() { - return this.line.text - } - public makeChangeSet() { - return new MutableChangeSet(this.offset) - } +export function getBlockType(state: EditorState): SupportedBlockType | undefined { + const range = selectionRange(state.selection.main) + const tree = syntaxTree(state) + let type: string | undefined | null = undefined + visitBlocks(range, tree, (node) => { + if (type === undefined) type = node.name + else if (node.name !== type) type = null + }) + return type && isSupportedBlockType(type) ? type : undefined } -/** Resolve node at position, descending into the document if needed. */ -function resolveNodeAtPos(tree: Tree, pos: number) { - let node = tree.resolve(pos, -1) - if (node.type.name === 'Document' && node.firstChild != null) node = node.firstChild - return node +function selectionRange(selection: SelectionRange): Range { + return Range.unsafeFromBounds(selection.from, selection.to) } -/** Check whether via are inside a markdown code block. */ -function isCodeText(node: SyntaxNode) { - return node.type.name === 'CodeText' -} - -// =============== -// === Headers === -// =============== - -/** Supported header levels. */ -export type HeaderLevel = 1 | 2 | 3 - -/** Toggle headers of specified level at each of the selected lines. */ -export function toggleHeader(view: EditorView, level: HeaderLevel) { - const selection = view.state.selection.main - const startLine = view.state.doc.lineAt(selection.from) - const endLine = view.state.doc.lineAt(selection.to) - const src = view.state.doc.toString() - const tree = ensoMarkdownParser.parse(src) - const changeSet = new MutableChangeSet(0) - for (let lineIndex = startLine.number; lineIndex <= endLine.number; lineIndex++) { - const line = view.state.doc.line(lineIndex) - const context = new Context(tree, src, line) - const lineChanges = toggleHeaderInner(context, level) - changeSet.merge(lineChanges) - } - changeSet.dispatch(view, true) +const blockDelimiter: Record = { + // We can generally treat `Paragraph` as a line-delimited type with a 0-length delimiter. + Paragraph: '', + ATXHeading1: '# ', + ATXHeading2: '## ', + ATXHeading3: '### ', + BulletList: '- ', + OrderedList: '1. ', + Blockquote: '> ', } -function toggleHeaderInner(context: Context, level: number): MutableChangeSet { - const prefix = `${'#'.repeat(level)} ` - const node = resolveNodeAtPos(context.tree, context.lineEnd()) - const changeSet = context.makeChangeSet() - if (isCodeText(node)) { - const codeText = context.src.slice(node.from, node.to) - const codeTree = ensoMarkdownParser.parse(codeText) - const codeContext = new Context(codeTree, codeText, context.line).withOffset(node.from) - const codeChanges = toggleHeaderInner(codeContext, level) - changeSet.merge(codeChanges) - } else { - const headerMark = findAtxHeaderMark(node) - if (headerMark && headerMark.level === level) { - changeSet.remove(headerMark.from, headerMark.to) - } else if (headerMark) { - changeSet.replace(headerMark.from, headerMark.to, prefix) - } else { - changeSet.add(context.lineStart(), context.lineStart(), prefix) +/** Apply the specified block type to the selection. */ +export function setBlockType( + state: EditorState, + type: DelimitedBlockType & SupportedBlockType, +): TransactionSpec { + const selection = selectionRange(state.selection.main) + const md = new MarkdownEdit(state.doc, syntaxTree(state)) + const delimiter = blockDelimiter[type] + const cursor = md.tree.cursor() + if (selection.empty && md.doc.lineAt(selection.from).length === 0) + md.replace(selection, delimiter) + visitLeafBlocks(selection, md.tree, (node, parentList) => { + if (node.name === type || (node.name === 'ListItem' && parentList === type)) return + const isQuote = node.name === 'Blockquote' + cursor.moveTo(node.from, 1) + for (;;) { + const oldDelimiter = + isBlockDelimiter(cursor.name) ? nodeRange(cursor) : Range.emptyAt(node.from) + md.replace(oldDelimiter, delimiter) + if (isQuote && cursor.nextSibling()) { + while (cursor.name !== 'QuoteMark') { + if (!cursor.nextSibling()) break + } + if (cursor.name === 'QuoteMark') continue + } + break } + }) + const changes = state.changes(md.changes) + return { + changes, + selection: state.selection.main.map(changes), } - return changeSet } -function findAtxHeaderMark(node: SyntaxNode): { level: number; from: number; to: number } | null { - const cursor = node.cursor() - do { - if (cursor.type.name.startsWith('ATXHeading')) { - const headerMark = cursor.node.getChild('HeaderMark') - if (!headerMark) return null - const level = Number(cursor.type.name.slice(-1)) - return { level, from: headerMark.from, to: headerMark.to } - } - } while (cursor.parent()) - return null -} - -// ============= -// === Lists === -// ============= - -/** Distinguish between unordered (bullet) and ordered (numbered) lists. */ -export type ListType = 'unordered' | 'ordered' - -/** Toggle list items of specified type at each of the selected lines. */ -export function toggleList(view: EditorView, type: ListType) { - const tree = ensoMarkdownParser.parse(view.state.doc.toString()) - const startLine = view.state.doc.lineAt(view.state.selection.main.from) - const endLine = view.state.doc.lineAt(view.state.selection.main.to) - const changeSet = new MutableChangeSet(0) - const src = view.state.doc.toString() - let listIndex = 0 - for (let i = startLine.number; i <= endLine.number; i++) { - const line = view.state.doc.line(i) - const context = new Context(tree, src, line) - const lineChanges = toggleListInner(context, listIndex, type) - changeSet.merge(lineChanges) - listIndex++ - } - changeSet.dispatch(view, true) +/** + * @returns Whether {@link insertCodeBlock} is supported for the current cursor location or + * selected range. + */ +export function canInsertCodeBlock(_state: EditorState): boolean { + // TODO: Disable button when the cursor is already inside an unformattable block. + return true } -function toggleListInner(context: Context, listIndex: number, type: ListType): MutableChangeSet { - const node = resolveNodeAtPos(context.tree, context.lineEnd()) - const changeSet = context.makeChangeSet() - if (isCodeText(node)) { - const codeText = context.src.slice(node.from, node.to) - const codeTree = ensoMarkdownParser.parse(codeText) - const codeContext = new Context(codeTree, codeText, context.line).withOffset(node.from) - const codeChanges = toggleListInner(codeContext, listIndex, type) - changeSet.merge(codeChanges) +/** + * Insert a code block after the cursor, or if there is a selection convert the selected lines to a + * code block. + */ +export function insertCodeBlock(state: EditorState): TransactionSpec { + const selection = selectionRange(state.selection.main) + const md = new MarkdownEdit(state.doc, syntaxTree(state)) + + let newSelection: Range | undefined = undefined + if (selection.empty) { + const newCursorPos = insertCodeBlockAfter(md, selection.from) + newSelection = Range.emptyAt(newCursorPos) } else { - const listInfo = detectList(node) - if (listInfo != null && listInfo.listType === type) { - changeSet.remove(listInfo.listMark.from, listInfo.listMark.to) - } else if (listInfo != null && listInfo.listType !== type) { - changeSet.replace(listInfo.listMark.from, listInfo.listMark.to, listMark(type, listIndex)) - } else if (listInfo == null) { - changeSet.add(context.lineStart(), context.lineStart(), listMark(type, listIndex)) - } + const lines = md.expandRangeToFullLines(selection) + md.insert('```\n', lines.from) + md.insert('\n```', lines.to) } - return changeSet -} -function listMark(type: ListType, listIndex: number) { - if (type === 'unordered') return '- ' - return `${listIndex + 1}. ` + const changes = state.changes(md.changes) + return { + changes, + selection: newSelection ? rangeToSelection(newSelection) : state.selection.main.map(changes), + } } -function detectList( - node: SyntaxNode, -): { listMark: { from: number; to: number }; listType: ListType } | null { - const cursor = node.cursor() - let listMark: { from: number; to: number } | null = null - do { - if (cursor.type.name === 'ListItem') { - const mark = cursor.node.getChild('ListMark') - if (mark) listMark = { from: mark.from, to: mark.to } - } - if (cursor.type.name === 'BulletList') { - return listMark != null ? { listMark, listType: 'unordered' } : null - } - if (cursor.type.name === 'OrderedList') { - return listMark != null ? { listMark, listType: 'ordered' } : null - } - } while (cursor.parent()) - return null +function rangeToSelection(range: Range): { anchor: number; head: number } { + return { + anchor: range.from, + head: range.to, + } } -// ============== -// === Quotes === -// ============== - -/** Toggle markdown quote mark at the first selected line. */ -export function toggleQuote(view: EditorView) { - const changeSet = new MutableChangeSet(0) - const src = view.state.doc.toString() - const tree = ensoMarkdownParser.parse(src) - const selectionPos = view.state.selection.main.from - const lineStart = view.state.doc.lineAt(selectionPos).from - const node = resolveNodeAtPos(tree, selectionPos) - if (isCodeText(node)) { - const codeText = src.slice(node.from, node.to) - const codeTree = ensoMarkdownParser.parse(codeText) - const codeChanges = new MutableChangeSet(node.from) - toggleQuoteInner(codeTree, selectionPos - node.from, lineStart - node.from, codeChanges) - changeSet.merge(codeChanges) - } else { - toggleQuoteInner(tree, selectionPos, lineStart, changeSet) - } - changeSet.dispatch(view) +function insertCodeBlockAfter(md: MarkdownEdit, pos: number): number { + const currentBlock = topLevelBlock(md.tree, pos) + const initialNewline = currentBlock ? '\n' : '' + const insertAt = currentBlock?.to ?? pos + const beforeCursor = '```\n' + const afterCursor = '\n```' + md.insert(initialNewline + beforeCursor + afterCursor, insertAt) + return insertAt + initialNewline.length + beforeCursor.length } -function toggleQuoteInner( - tree: Tree, - selectionPos: number, - lineStart: number, - changeSet: MutableChangeSet, -) { - const node = resolveNodeAtPos(tree, selectionPos) - const quoteMark = findQuoteMark(node) - if (quoteMark) { - changeSet.remove(quoteMark.from, quoteMark.to) - } else { - changeSet.add(lineStart, lineStart, '> ') +/** Remove the code block containing the cursor or within the current selection. */ +export function removeCodeBlock(state: EditorState): TransactionSpec { + const md = new MarkdownEdit(state.doc, syntaxTree(state)) + const currentBlock = topLevelBlock(md.tree, state.selection.main.anchor) + if (!currentBlock) { + console.error('Cannot remove code block: Cursor is not inside a block.') + return {} + } + const cursor = md.tree.cursor() + cursor.moveTo(currentBlock.from, 1) + if (cursor.name !== 'CodeMark') { + console.error('Cannot remove code block: Failed to locate opening delimiter') + return {} + } + const firstLine = lineRange(md.doc.lineAt(currentBlock.from)) + md.remove(firstLine.from > 0 ? firstLine.expand(1, 0) : firstLine) + cursor.moveTo(currentBlock.to, -1) + if (cursor.name === 'CodeMark') { + const lastLine = lineRange(md.doc.lineAt(currentBlock.to)) + md.remove(lastLine.expand(1, 0)) + } + const changes = state.changes(md.changes) + return { + changes, + selection: state.selection.main.map(changes), } } -function findQuoteMark(node: SyntaxNode): { from: number; to: number } | null { - const cursor = node.cursor() - do { - if (cursor.type.name === 'Blockquote') { - const quoteMark = cursor.node.getChild('QuoteMark') - if (quoteMark == null) return null - return { from: quoteMark.from, to: quoteMark.to } - } - } while (cursor.parent()) - return null +function lineRange(line: Line): Range { + return Range.unsafeFromBounds(line.from, line.to) } diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/index.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/index.ts index 321cc555566c..7da68ba8ef82 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/index.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/index.ts @@ -1,10 +1,10 @@ /** @file Provides a Vue reactive API for Markdown formatting in CodeMirror. */ import { - HeaderLevel, - ListType, - toggleHeader, - toggleList, - toggleQuote, + canInsertCodeBlock, + getBlockType, + insertCodeBlock, + removeCodeBlock, + setBlockType, } from '@/components/MarkdownEditor/codemirror/formatting/block' import { canInsertLink, @@ -13,12 +13,19 @@ import { insertLink, setInlineFormatting, } from '@/components/MarkdownEditor/codemirror/formatting/inline' +import { type SupportedBlockType as BlockType } from '@/components/MarkdownEditor/markdown/types' +import { assert } from '@/util/assert' import { type Extension, Facet, Prec } from '@codemirror/state' import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' import * as objects from 'enso-common/src/utilities/data/object' import { computed, proxyRefs, readonly, type Ref, ref } from 'vue' +export { type BlockType } + +interface ReactiveFormatting { + inline: Record> + blockType: Ref +} -type ReactiveFormatting = Record> const reactiveFormattingFacet = Facet.define({ combine: (values) => values[values.length - 1]!, }) @@ -27,30 +34,47 @@ const reactiveFormattingFacet = Facet.define view.dispatch(setInlineFormatting(view.state, type, value)) return proxyRefs({ - value: readonly(reactiveFormatting[type]), - set: (value: boolean) => view.dispatch(setInlineFormatting(view.state, type, value)), + value: computed(() => !!reactiveFormatting.inline[type].value), + set: computed(() => (reactiveFormatting.inline[type] === undefined ? undefined : setter)), }) } return { - toggleHeader: (level: HeaderLevel) => toggleHeader(view, level), - toggleQuote: () => toggleQuote(view), - toggleList: (type: ListType) => toggleList(view, type), italic: inlineFormat('Emphasis'), bold: inlineFormat('StrongEmphasis'), strikethrough: inlineFormat('Strikethrough'), insertLink: computed( () => canInsertLink(view.state) && (() => view.dispatch(insertLink(view.state))), ), + insertCodeBlock: computed( + () => canInsertCodeBlock(view.state) && (() => view.dispatch(insertCodeBlock(view.state))), + ), + blockType: proxyRefs({ + value: readonly(reactiveFormatting.blockType), + set: (type: BlockType) => { + const currentType = getBlockType(view.state) + if (type === currentType) return + assert(type !== 'FencedCode') + view.dispatch( + currentType === 'FencedCode' ? + removeCodeBlock(view.state) + : setBlockType(view.state, type), + ) + }, + }), } } /** Returns an extension that supports reactively watch the formatting of the selected text. */ export function markdownFormatting(): Extension { const reactiveFormatting = { - Emphasis: ref(), - StrongEmphasis: ref(), - Strikethrough: ref(), + inline: { + Emphasis: ref(), + StrongEmphasis: ref(), + Strikethrough: ref(), + }, + blockType: ref(), } const reactiveFormattingFacetExt = reactiveFormattingFacet.of(reactiveFormatting) return [ @@ -58,8 +82,9 @@ export function markdownFormatting(): Extension { viewObserverExt((update) => { if (!update.docChanged && !update.selectionSet) return const formatting = getInlineFormatting(update.view.state) - for (const key of objects.unsafeKeys(reactiveFormatting)) - reactiveFormatting[key].value = formatting?.[key] + for (const key of objects.unsafeKeys(reactiveFormatting.inline)) + reactiveFormatting.inline[key].value = formatting?.[key] + reactiveFormatting.blockType.value = getBlockType(update.view.state) }), ] } diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/inline.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/inline.ts index 82720fe7129b..8a64d129b28c 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/inline.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/inline.ts @@ -1,4 +1,5 @@ /** @file CodeMirror state operations for getting and setting inline formatting status. */ +import { MarkdownEdit } from '@/components/MarkdownEditor/codemirror/formatting/markdownEdit' import { MarkdownDocument, nodeExtensionOrExpansions, @@ -15,24 +16,17 @@ import { type FormatStates, type NormalizedRange, } from '@/components/MarkdownEditor/markdown/types' -import { assert } from '@/util/assert' import { syntaxTree } from '@codemirror/language' -import { - type ChangeSpec, - type EditorState, - type SelectionRange, - type Text, - type TransactionSpec, -} from '@codemirror/state' -import { type Tree } from '@lezer/common' +import { type EditorState, type SelectionRange, type TransactionSpec } from '@codemirror/state' import * as iter from 'enso-common/src/utilities/data/iter' import { Range } from 'ydoc-shared/util/data/range' export { type FormatNode as InlineFormattingNode } from '@/components/MarkdownEditor/markdown/types' /** - * @returns `undefined` if it is not possible to apply formatting to the given range. Otherwise, for each inline - * formatting type, a boolean suitable for a button state. The boolean will be `false` if the format type could be - * applied to more of the content in the range, or `true` if the format can be removed from the given range. + * @returns `undefined` if it is not possible to apply formatting to the given range. Otherwise, for + * each inline formatting type, a boolean suitable for a button state. The boolean will be `false` + * if the format type could be applied to more of the content in the range, or `true` if the format + * can be removed from the given range. */ export function getInlineFormatting(state: EditorState): FormatStates | undefined { const range = state.selection.main @@ -48,7 +42,7 @@ export function setInlineFormatting( nodeType: FormatNode, value: boolean, ): TransactionSpec { - const md = new MDChangeBuilder(state.doc, syntaxTree(state)) + const md = new MarkdownEdit(state.doc, syntaxTree(state)) md.select(selectionRange(state.selection.main)) md.visitFormattableRanges(selectionRange(state.selection.main), (range) => setRangeFormatting(md, range, nodeType, value), @@ -68,7 +62,7 @@ export function setInlineFormatting( /** @returns Whether a link can be inserted. */ export function canInsertLink(state: EditorState): boolean { - const md = new MDChangeBuilder(state.doc, syntaxTree(state)) + const md = new MarkdownEdit(state.doc, syntaxTree(state)) const range = lastFormattableRange(md, selectionRange(state.selection.main)) // Note: Once formatting link text is allowed, we will have to check that we aren't already inside a link here. return range !== undefined @@ -76,7 +70,7 @@ export function canInsertLink(state: EditorState): boolean { /** Insert a link at the selection. */ export function insertLink(state: EditorState): TransactionSpec { - const md = new MDChangeBuilder(state.doc, syntaxTree(state)) + const md = new MarkdownEdit(state.doc, syntaxTree(state)) const range = lastFormattableRange(md, selectionRange(state.selection.main)) if (range === undefined) { console.error('Cannot insert link: No formattable range') @@ -84,19 +78,19 @@ export function insertLink(state: EditorState): TransactionSpec { } const beforeText = '[' const afterText = '](https://)' - const afterTextSelection = Range.tryFromBounds( + const afterTextSelection = Range.unsafeFromBounds( afterText.indexOf('(') + 1, afterText.indexOf(')'), - )! + ) let afterTextPos: number - if (range.length > 0) { - md.select(Range.emptyAt(range.to)) - insertAround(md, range, beforeText, afterText) - afterTextPos = md.adjustedSelection.to - } else { + if (range.empty) { const defaultText = 'Link' md.insert(`${beforeText}${defaultText}${afterText}`, range.to) afterTextPos = range.to + beforeText.length + defaultText.length + } else { + md.select(Range.emptyAt(range.to)) + insertAround(md, range, beforeText, afterText) + afterTextPos = md.adjustedSelection.to } return { changes: md.changes, @@ -119,7 +113,7 @@ function lastFormattableRange(md: MarkdownDocument, selection: Range): Normalize * needed. If the range touches the boundary of the selection, the given strings will be inserted * outside it. */ -function insertAround(md: MDChangeBuilder, range: NormalizedRange, before: string, after: string) { +function insertAround(md: MarkdownEdit, range: NormalizedRange, before: string, after: string) { const partlyOutside = analyzeSplits(md.tree, range) const { outside: closeBefore, inside: reopenInside } = nodeSplitDelimiters.from( md, @@ -142,77 +136,11 @@ function rangeToSelection(range: Range): { anchor: number; head: number } { } function selectionRange(selection: SelectionRange): Range { - return Range.tryFromBounds(selection.from, selection.to)! -} - -class MDChangeBuilder extends MarkdownDocument { - readonly changes: ChangeSpec[] = [] - adjustedSelection: Range - - constructor( - text: Text, - tree: Tree, - public selection: Range = Range.empty, - ) { - super(text, tree) - this.adjustedSelection = selection - } - - insertAroundRangeOutsideSelection(before: string, after: string, range: Range) { - this.insert(before, range.from, 'outside-before') - this.insert(after, range.to, 'outside-after') - } - - /** - * @param insert Text to insert. - * @param from Position for inserted text to start, relative to document before any uncommitted changes. - * @param positionRelativeToSelection Determines the result when the insertion position is at the boundary of the - * selection. - * - 'inside': The selection will be expanded to include the inserted text. - * - 'outside-before': The selection will not be expanded to include the inserted text. If the selection is 0-length, - * the insertion will be before it. - * - 'outside-after': The selection will not be expanded to include the inserted text. If the selection is 0-length, - * the insertion will be after it. - */ - insert( - insert: string, - from: number, - positionRelativeToSelection: 'outside-before' | 'outside-after' | 'inside' = 'inside', - ) { - if (!insert) return - this.changes.push({ from, to: from, insert }) - const atFrom = from === this.selection.from - const atTo = from === this.selection.to - const shiftFrom = - from < this.selection.from || - (atFrom && !(positionRelativeToSelection !== 'outside-before' || !atTo)) - const shiftTo = - from < this.selection.to || - (atTo && (positionRelativeToSelection !== 'outside-after' || !atFrom)) - assert(shiftTo || !shiftFrom) - this.adjustedSelection = Range.tryFromBounds( - this.adjustedSelection.from + (shiftFrom ? insert.length : 0), - this.adjustedSelection.to + (shiftTo ? insert.length : 0), - )! - } - - remove(range: Range) { - if (!range.length) return - this.changes.push(range) - this.adjustedSelection = Range.tryFromBounds( - this.adjustedSelection.from - (range.from <= this.selection.from ? range.length : 0), - this.adjustedSelection.to - (this.selection.to <= range.from ? range.length : 0), - )! - } - - select(range: Range) { - this.selection = range - this.adjustedSelection = range - } + return Range.unsafeFromBounds(selection.from, selection.to) } function setRangeFormatting( - md: MDChangeBuilder, + md: MarkdownEdit, range: NormalizedRange, nodeType: FormatNode, value: boolean, @@ -224,7 +152,7 @@ function setRangeFormatting( } function addFormat( - md: MDChangeBuilder, + md: MarkdownEdit, range: NormalizedRange, outsideRange: Range, nodeType: FormatNode, @@ -264,7 +192,7 @@ function addFormat( } function removeFormat( - md: MDChangeBuilder, + md: MarkdownEdit, range: NormalizedRange, outsideRange: Range, nodeType: FormatNode, diff --git a/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/markdownEdit.ts b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/markdownEdit.ts new file mode 100644 index 000000000000..e471eff6479c --- /dev/null +++ b/app/gui/src/project-view/components/MarkdownEditor/codemirror/formatting/markdownEdit.ts @@ -0,0 +1,92 @@ +import { MarkdownDocument } from '@/components/MarkdownEditor/markdown/markdownDocument' +import { type ChangeSpec, type Text } from '@codemirror/state' +import { type Tree } from '@lezer/common' +import { assert } from 'ydoc-shared/util/assert' +import { Range } from 'ydoc-shared/util/data/range' + +/** Supports building a transaction editing a particular Markdown document state. */ +export class MarkdownEdit extends MarkdownDocument { + readonly changes: ChangeSpec[] = [] + adjustedSelection: Range = Range.empty + + /** Constructor. */ + constructor( + text: Text, + tree: Tree, + public selection: Range = Range.empty, + ) { + super(text, tree) + this.adjustedSelection = selection + } + + /** + * Insert the given strings around the specified range, without expanding the selection if the + * insertion points coincide with its boundaries. + */ + insertAroundRangeOutsideSelection(before: string, after: string, range: Range) { + this.insert(before, range.from, 'outside-before') + this.insert(after, range.to, 'outside-after') + } + + /** + * @param insert Text to insert. + * @param from Position for inserted text to start, relative to document before any uncommitted + * changes. + * @param positionRelativeToSelection Determines the result when the insertion position is at the + * boundary of the selection. + * - 'inside': The selection will be expanded to include the inserted text. + * - 'outside-before': The selection will not be expanded to include the inserted text. If the + * selection is 0-length, the insertion will be before it. + * - 'outside-after': The selection will not be expanded to include the inserted text. If the + * selection is 0-length, the insertion will be after it. + */ + insert( + insert: string, + from: number, + positionRelativeToSelection: 'outside-before' | 'outside-after' | 'inside' = 'inside', + ) { + if (!insert) return + this.changes.push({ from, to: from, insert }) + const atFrom = from === this.selection.from + const atTo = from === this.selection.to + const shiftFrom = + from < this.selection.from || + (atFrom && !(positionRelativeToSelection !== 'outside-before' || !atTo)) + const shiftTo = + from < this.selection.to || + (atTo && (positionRelativeToSelection !== 'outside-after' || !atFrom)) + assert(shiftTo || !shiftFrom) + this.adjustedSelection = Range.unsafeFromBounds( + this.adjustedSelection.from + (shiftFrom ? insert.length : 0), + this.adjustedSelection.to + (shiftTo ? insert.length : 0), + ) + } + + /** Delete the given range, specified as positions before any edits are committed. */ + remove(range: Range) { + if (range.empty) return + this.changes.push(range) + this.adjustedSelection = Range.unsafeFromBounds( + this.adjustedSelection.from - (range.from < this.selection.from ? range.length : 0), + this.adjustedSelection.to - (range.to < this.selection.to ? range.length : 0), + ) + } + + /** + * Delete the given range, specified as positions before any edits are committed, and insert in + * its place the given text. + */ + replace(range: Range, text: string) { + this.changes.push({ from: range.from, to: range.to, insert: text }) + // TODO: Adjust selection + } + + /** + * Set the selection to the given range, to be adjusted by further edits. Note selection + * adjustment is not yet implemented for all operations on this type. + */ + select(range: Range) { + this.selection = range + this.adjustedSelection = range + } +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/markdownDocument.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/markdownDocument.ts index 64e6d078a38e..a8c8430a572f 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/markdownDocument.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/markdownDocument.ts @@ -285,6 +285,6 @@ class RangeGapVisitor { this.flush(this.to) } private flush(to: number) { - if (this.prevEnd < to) this.emit(Range.tryFromBounds(this.prevEnd, to)!) + if (this.prevEnd < to) this.emit(Range.unsafeFromBounds(this.prevEnd, to)) } } diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/textDocument.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/textDocument.ts index eaa79bce512d..6e3ea62e4227 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/textDocument.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/textDocument.ts @@ -68,6 +68,11 @@ export class TextDocument { const line = this.doc.lineAt(pos) return line.text.slice(pos - line.from) } + + /** @returns The given range expanded to fully-include any lines it partially-includes. */ + expandRangeToFullLines(range: Range): Range { + return Range.unsafeFromBounds(this.doc.lineAt(range.from).from, this.doc.lineAt(range.to).to) + } } function spacesBefore(text: string): number { diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/trees.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/trees.ts index e2a8f6750ea0..bf5c40405a08 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/trees.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/trees.ts @@ -1,5 +1,6 @@ /** @file Lezer Tree operations for reading and writing inline formatting state. */ import { + isLeafBlockType, zeroFormatDepths, type FormatDepths, type FormatNode, @@ -75,8 +76,16 @@ abstract class ExclusiveTreeVisitor extends TreeRangeVisitor { from: this.range.from, to: this.range.to, enter: (node) => { - // `iterate` is inclusive of nodes that just-meet the specified range - if (node.to === this.range.from || node.from === this.range.to) return false + // `iterate` is inclusive of nodes that just-meet the specified range; we exclude those + // nodes unless the input range is empty. + // FIXME: Expose a `side` parameter to configure this? Or always use a cursor to "iterate" a + // point? Currently only block-formatting logic treats a cursor as an empty range, and in + // that case inclusive behavior here is fine. + if ( + this.range.to !== this.range.from && + (node.to === this.range.from || node.from === this.range.to) + ) + return false return this.enter(node) }, leave: this.leave.bind(this), @@ -90,7 +99,8 @@ abstract class ContainedNodeVisitor extends TreeRangeVisitor { from: this.range.from, to: this.range.to, enter: (node) => { - // `iterate` is inclusive of nodes that just-meet the specified range (see {@link ExclusiveTreeVisitor}). + // `iterate` is inclusive of nodes that just-meet the specified range + // (see {@link ExclusiveTreeVisitor}). if (node.to === this.range.from || node.from === this.range.to) return false if (this.range.contains(nodeRange(node))) return this.enter(node) }, @@ -101,9 +111,72 @@ abstract class ContainedNodeVisitor extends TreeRangeVisitor { } } +/** Apply the specified visitor to the top-level block elements in the given range. */ +export function visitBlocks(range: Range, tree: Tree, visit: (node: SyntaxNodeRef) => void) { + new BlockVisitor(range, visit).visit(tree) +} +class BlockVisitor extends ExclusiveTreeVisitor { + constructor( + range: Range, + private emit: (node: SyntaxNodeRef) => void, + ) { + super(range) + } + + enter(node: SyntaxNodeRef): boolean { + if (node.name === 'Document') return true + this.emit(node) + return false + } +} + +/** Apply the specified visitor to the leaf (i.e. single-line) blocks in the given range. */ +export function visitLeafBlocks( + range: Range, + tree: Tree, + visit: (node: SyntaxNodeRef, parentList: 'BulletList' | 'OrderedList' | undefined) => void, +) { + new LeafBlockVisitor(range, visit).visit(tree) +} +class LeafBlockVisitor extends ExclusiveTreeVisitor { + private parentList: 'BulletList' | 'OrderedList' | undefined = undefined + + constructor( + range: Range, + private emit: ( + node: SyntaxNodeRef, + parentList: 'BulletList' | 'OrderedList' | undefined, + ) => void, + ) { + super(range) + } + + enter(node: SyntaxNodeRef): boolean { + if (node.name === 'Document') return true + if (node.name === 'BulletList' || node.name === 'OrderedList') { + this.parentList = node.name + return true + } + if (isLeafBlockType(node.name)) this.emit(node, this.parentList) + return false + } +} + +/** @returns Whether this is one of the syntax nodes introducing a {@link DelimitedBlockType}. */ +export function isBlockDelimiter(type: string) { + switch (type) { + case 'ListMark': + case 'HeaderMark': + case 'QuoteMark': + return true + default: + return false + } +} + /** - * @returns The depths of formatting nodes the point is within, or `undefined` if the point is within an unformattable - * block. + * @returns The depths of formatting nodes the point is within, or `undefined` if the point is + * within an unformattable block. */ export function pointFormatAncestorInfo( pos: number, @@ -111,7 +184,10 @@ export function pointFormatAncestorInfo( ): | { formatDepth: Readonly - /** True if the position is within an inline node where formatting delimiters are not recognized. */ + /** + * True if the position is within an inline node where formatting delimiters are not + * recognized. + */ unformattable: boolean } | undefined { @@ -140,7 +216,10 @@ export function pointFormatAncestorInfo( return { formatDepth, unformattable } } -/** @returns The containing unformattable inline node. The caller should first determine that such a node is present. */ +/** + * @returns The containing unformattable inline node. The caller should first determine that such a + * node is present. + */ export function getUnformattableAncestor(pos: number, tree: Tree): Range { const cursor = tree.cursorAt(pos, 0) LOOP: do { @@ -177,7 +256,10 @@ class AnalyzeContainedDelimiters extends ContainedNodeVisitor { } } -/** For each node of the specified type fully-contained in the given range, apply the visitor to its delimiters. */ +/** + * For each node of the specified type fully-contained in the given range, apply the visitor to its + * delimiters. + */ export function visitContainedDelimiters( range: Range, tree: Tree, @@ -191,7 +273,7 @@ export function visitContainedDelimiters( * Extract the current range from the given node. */ export function nodeRange(node: Readonly): Range { - return Range.tryFromBounds(node.from, node.to)! + return Range.unsafeFromBounds(node.from, node.to) } /** Returns whether the node is formatting markup. */ @@ -210,10 +292,10 @@ export function isDelimiter(nodeName: string) { /** Contract the range to exclude any delimiters that are oriented the wrong way. */ export function trimRangeDelimiters(range: Range, tree: Tree): TrimmedRange { const cursor = tree.cursor() - return Range.tryFromBounds( + return Range.unsafeFromBounds( trimDelimiter.from(range.from, cursor), trimDelimiter.to(range.to, cursor), - )! as TrimmedRange + ) as TrimmedRange } const trimDelimiter = sides(({ toOrFrom, inside }) => (pos: number, cursor: TreeCursor) => { while (cursor.moveTo(pos, inside) && isDelimiter(cursor.name)) { @@ -226,8 +308,8 @@ const trimDelimiter = sides(({ toOrFrom, inside }) => (pos: number, cursor: Tree }) /** - * Split the given range into parts that can have inline formatting applied to them, and yield them to the provided - * visitors. Note that ranges will not necessarily be yielded in document order. + * Split the given range into parts that can have inline formatting applied to them, and yield them + * to the provided visitors. Note that ranges will not necessarily be yielded in document order. */ export function splitRange( range: TrimmedRange, @@ -242,11 +324,12 @@ export function splitRange( } class RangeSplitter extends ExclusiveTreeVisitor { - /** Depth of currently-entered node in AST: 0 is the `Document`; 1 is a block-level node; deeper nodes are inline. */ + /** Depth of currently-entered node in AST: 0 is the `Document`; 1 is a top-level block node. */ private depth: number = 0 /** - * When a block node that is partially-covered by `range` has been entered, this is initially set to the intersection - * of the node, refined by visiting any children, and emitted when leaving the block node. + * When a block node that is partially-covered by `range` has been entered, this is initially set + * to the intersection of the node, refined by visiting any children, and emitted when leaving the + * block node. */ private currentRange: Range | undefined = undefined @@ -263,33 +346,22 @@ class RangeSplitter extends ExclusiveTreeVisitor { const nodeFromOutside = node.from < this.range.from const nodeToOutside = this.range.to < node.to if (this.depth === 1) { - if (!this.enterBlock(node, !nodeFromOutside && !nodeToOutside)) return false + switch (node.name) { + case 'CodeBlock': + case 'FencedCode': + return false + default: + } + this.currentRange = this.range.tryIntersect(nodeRange(node))! } else if (this.depth && nodeFromOutside !== nodeToOutside) { if (!this.enterPartialInline(node, nodeFromOutside)) return false + } else if (this.depth && node.from === this.currentRange?.from && isBlockDelimiter(node.name)) { + this.currentRange = this.trimRange(Range.unsafeFromBounds(node.to, this.currentRange.to)) } this.depth += 1 return true } - private enterBlock(node: SyntaxNodeRef, nodeFullyInRange: boolean): boolean { - // TODO: Exclude block delimiter - const blockRange = nodeRange(node) - if (nodeFullyInRange) { - if (blockRange.to !== blockRange.from) { - switch (node.name) { - case 'CodeBlock': - case 'FencedCode': - break - default: - this.emit(blockRange as NormalizedRange) - } - } - return false - } - this.currentRange = this.range.tryIntersect(blockRange)! - return true - } - private enterPartialInline(node: SyntaxNodeRef, nodeFromOutside: boolean): boolean { switch (node.name) { case 'Link': @@ -300,8 +372,8 @@ class RangeSplitter extends ExclusiveTreeVisitor { // Exclude the node from the range. this.currentRange = this.trimRange( nodeFromOutside ? - Range.tryFromBounds(node.to, this.currentRange!.to)! - : Range.tryFromBounds(this.currentRange!.from, node.from)!, + Range.unsafeFromBounds(node.to, this.currentRange!.to) + : Range.unsafeFromBounds(this.currentRange!.from, node.from), ) return false } @@ -325,25 +397,26 @@ export function normalizeRange( { from, to }: SeminormalizedRange, tree: Tree, ): NormalizedRange | undefined -// This delimiter operation can be applied to any trimmed range (i.e. before range splitting); in that case the result -// won't be a fully normalized range. +// This delimiter operation can be applied to any trimmed range (i.e. before range splitting); in +// that case the result won't be a fully normalized range. export function normalizeRange({ from, to }: TrimmedRange, tree: Tree): TrimmedRange | undefined /** - * Adjust the ends of the range to include/exclude delimiters based on tree structure (see {@link NormalizedRange}); - * returns `undefined` if the resulting range contains no formattable content. + * Adjust the ends of the range to include/exclude delimiters based on tree structure + * (see {@link NormalizedRange}). + * @returns `undefined` if the resulting range contains no formattable content. */ export function normalizeRange(range: TrimmedRange, tree: Tree): TrimmedRange | undefined { const cursor = tree.cursor() - const expandedRange = Range.tryFromBounds( + const expandedRange = Range.unsafeFromBounds( includeWrappingDelimiters.from(range.from, cursor), includeWrappingDelimiters.to(range.to, cursor), - )! + ) if (insideInlineUnformattable(expandedRange, cursor)) return - // For any allowed input, the result will be a {@link TrimmedRange}; if the input is a {@link SeminormalizedRange}, - // the result will be a {@link NormalizedRange}. + // For any allowed input, the result will be a {@link TrimmedRange}; if the input is a + // {@link SeminormalizedRange}, the result will be a {@link NormalizedRange}. return Range.tryFromBounds( excludeExcessDelimiters.from(expandedRange, cursor), excludeExcessDelimiters.to(expandedRange, cursor), @@ -372,8 +445,8 @@ const excludeExcessDelimiters = sides( cursor.parent() const currentRange = fromOrTo === 'from' ? - Range.tryFromBounds(pos, range.to)! - : Range.tryFromBounds(range.from, pos)! + Range.unsafeFromBounds(pos, range.to) + : Range.unsafeFromBounds(range.from, pos) if (currentRange.contains(nodeRange(cursor))) break pos = contracted } @@ -395,7 +468,10 @@ function insideInlineUnformattable(range: Range, cursor: TreeCursor) { return false } -/** Expand each end of the range to the outermost node that it includes that end of the non-delimiter content of. */ +/** + * Expand each end of the range to the outermost node that it includes that end of the non-delimiter + * content of. + */ export function denormalizeRange(range: NormalizedRange, tree: Tree): Range { const cursor = tree.cursor() return Range.tryFromBounds( @@ -405,7 +481,8 @@ export function denormalizeRange(range: NormalizedRange, tree: Tree): Range { } /** - * If nodes of the given type are closed just before or opened just after the provided (expanded) range, returns them. + * If nodes of the given type are closed just before or opened just after the provided (expanded) + * range, returns them. */ export function analyzeMerges( tree: Tree, @@ -573,7 +650,9 @@ export interface Autolink extends LinkOrImage { title: undefined } -/** Given a {@link SyntaxNodeRef} of type `Link` or `Image`, returns information about its contents. */ +/** + * Given a {@link SyntaxNodeRef} of type `Link` or `Image`, returns information about its contents. + */ export function analyzeLinkOrImage(nodeRef: SyntaxNodeRef): LinkOrImage | undefined { const cursor = nodeRef.node.cursor() const linkOrImage = nodeRange(nodeRef) @@ -591,7 +670,7 @@ export function analyzeLinkOrImage(nodeRef: SyntaxNodeRef): LinkOrImage | undefi const title = isNodeType(cursor, 'LinkTitle') ? nodeRange(cursor) : undefined return { linkOrImage, - text: Range.tryFromBounds(textFrom, textTo)!, + text: Range.unsafeFromBounds(textFrom, textTo), url, title, } @@ -612,3 +691,46 @@ export function analyzeAutolink(nodeRef: SyntaxNodeRef): Autolink | undefined { title: undefined, } } + +/** @returns The range of the top-level block element containing the given point. */ +export function topLevelBlock(tree: Tree, pos: number): Range | undefined { + const cursor = tree.cursor() + if (!cursor.firstChild()) return + while (cursor.to < pos) if (!cursor.nextSibling()) break + if (cursor.from <= pos && pos <= cursor.to) return nodeRange(cursor) +} + +/** + * @returns The given range expanded to include the full extent of any fenced blocks it + * partially-includes. + */ +export function expandRangeToIncludeFencedBlocks(tree: Tree, range: Range): Range { + const cursor = tree.cursor() + const efbFrom = enclosingFencedBlock(cursor, range.from, 1) + const efbTo = enclosingFencedBlock(cursor, range.to, -1) + return range.empty ? + (efbFrom ?? efbTo ?? range) + : Range.unsafeFromBounds( + efbFrom ? Math.min(efbFrom.from, range.from) : range.from, + efbTo ? Math.max(efbTo.to, range.to) : range.to, + ) +} + +function enclosingFencedBlock( + cursor: TreeCursor, + pos: number, + side: -1 | 0 | 1, +): Range | undefined { + cursor.moveTo(pos, side) + if (isFencedBlock(cursor.name)) return nodeRange(cursor) + cursor.parent() + if (isFencedBlock(cursor.name)) return nodeRange(cursor) +} + +/** + * @returns Whether the specified block type is a fenced block (introduced by fence lines), as + * opposed to a {@link DelimitedBlockType}. + */ +export function isFencedBlock(type: string): boolean { + return type === 'FencedCode' +} diff --git a/app/gui/src/project-view/components/MarkdownEditor/markdown/types.ts b/app/gui/src/project-view/components/MarkdownEditor/markdown/types.ts index 27093423dc43..735fed7276f0 100644 --- a/app/gui/src/project-view/components/MarkdownEditor/markdown/types.ts +++ b/app/gui/src/project-view/components/MarkdownEditor/markdown/types.ts @@ -29,6 +29,86 @@ declare const brandNormalized: unique symbol */ export type NormalizedRange = SeminormalizedRange & { [brandNormalized]: true } +declare const brandLeaf: unique symbol +declare const brandSupported: unique symbol +declare const brandDelimited: unique symbol +type BT< + LeafOrContainer extends 'leaf' | 'container', + SupportedOrHidden extends 'supported' | 'hidden', + DelimitedOrFenced extends 'delimited' | 'fenced', +> = { + [brandLeaf]: LeafOrContainer extends 'leaf' ? true : false + [brandSupported]: SupportedOrHidden extends 'supported' ? true : false + [brandDelimited]: DelimitedOrFenced extends 'delimited' ? true : false +} + +type BlockTypes = { + Paragraph: BT<'leaf', 'supported', 'delimited'> + ATXHeading1: BT<'leaf', 'supported', 'delimited'> + ATXHeading2: BT<'leaf', 'supported', 'delimited'> + ATXHeading3: BT<'leaf', 'supported', 'delimited'> + ATXHeading4: BT<'leaf', 'hidden', 'delimited'> + ATXHeading5: BT<'leaf', 'hidden', 'delimited'> + ATXHeading6: BT<'leaf', 'hidden', 'delimited'> + Blockquote: BT<'leaf', 'supported', 'delimited'> + FencedCode: BT<'leaf', 'supported', 'fenced'> + OrderedList: BT<'container', 'supported', 'delimited'> + BulletList: BT<'container', 'supported', 'delimited'> + ListItem: BT<'leaf', 'hidden', 'delimited'> +} + +/** Block types the parser recognizes, that correspond to one block per node. */ +export type LeafBlockType = keyof { + [K in keyof BlockTypes as BlockTypes[K][typeof brandLeaf] extends true ? K : never]: never +} + +/** Block types we support applying. */ +export type SupportedBlockType = keyof { + [K in keyof BlockTypes as BlockTypes[K][typeof brandSupported] extends true ? K : never]: never +} + +/** + * Block types that are defined by a delimited at the start of each line, not fence lines before and + * after. + */ +export type DelimitedBlockType = keyof { + [K in keyof BlockTypes as BlockTypes[K][typeof brandDelimited] extends true ? K : never]: never +} + +/** Type predicate for {@link SupportedBlockType} */ +export function isSupportedBlockType(type: string): type is SupportedBlockType { + switch (type) { + case 'Paragraph': + case 'ATXHeading1': + case 'ATXHeading2': + case 'ATXHeading3': + case 'Blockquote': + case 'FencedCode': + case 'OrderedList': + case 'BulletList': + return true + } + return false +} + +/** Type predicate for {@link LeafBlockType} */ +export function isLeafBlockType(type: string): type is LeafBlockType { + switch (type) { + case 'Paragraph': + case 'ATXHeading1': + case 'ATXHeading2': + case 'ATXHeading3': + case 'ATXHeading4': + case 'ATXHeading5': + case 'ATXHeading6': + case 'Blockquote': + case 'FencedCode': + case 'ListItem': + return true + } + return false +} + export type FormatNode = 'Emphasis' | 'StrongEmphasis' | 'Strikethrough' const MARK_TOKEN: Readonly> = { diff --git a/app/gui/src/project-view/components/SelectionDropdown.vue b/app/gui/src/project-view/components/SelectionDropdown.vue index aecc8e51158c..f77813a77c59 100644 --- a/app/gui/src/project-view/components/SelectionDropdown.vue +++ b/app/gui/src/project-view/components/SelectionDropdown.vue @@ -6,7 +6,7 @@ import MenuButton from '@/components/MenuButton.vue' import MenuPanel from '@/components/MenuPanel.vue' import SvgIcon from '@/components/SvgIcon.vue' import type { SelectionMenuOption } from '@/components/visualizations/toolbar' -import { ref } from 'vue' +import { ref, toValue } from 'vue' type Key = number | string | symbol const selected = defineModel({ required: true }) @@ -18,6 +18,10 @@ const _props = defineProps<{ }>() const open = ref(false) + +function onClick(option: SelectionMenuOption) { + if (!(option.disabled && toValue(option.disabled))) open.value = false +}