From 94a02480a9c964aa07d2154d253175f8ff26e94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Sat, 3 Feb 2024 17:57:51 +0100 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20Add=20commands=20to=20insert=20?= =?UTF-8?q?markdown=20table=20and=20horizontal=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/Commands/CMCommandDispatcher.cs | 9 +++++++++ .../Models/CodeMirrorCommandTwoParameters.cs | 15 +++++++++++++++ CodeMirror6/Models/CodeMirrorSimpleCommand.cs | 5 +++++ CodeMirror6/NodeLib/src/CmCommands.ts | 14 ++++++++++++++ CodeMirror6/NodeLib/src/index.ts | 5 +++++ 5 files changed, 48 insertions(+) create mode 100644 CodeMirror6/Models/CodeMirrorCommandTwoParameters.cs diff --git a/CodeMirror6/Commands/CMCommandDispatcher.cs b/CodeMirror6/Commands/CMCommandDispatcher.cs index 290f6ea0..78df1575 100644 --- a/CodeMirror6/Commands/CMCommandDispatcher.cs +++ b/CodeMirror6/Commands/CMCommandDispatcher.cs @@ -23,4 +23,13 @@ public class CMCommandDispatcher /// /// public Task Dispatch(CodeMirrorCommandOneParameter command, TValue value) => cmJsInterop.ModuleInvokeVoidAsync("dispatchCommand", command, value); + + /// + /// Invoke a built-in CodeMirror command with two parameters + /// + /// + /// + /// + /// + public Task Dispatch(CodeMirrorCommandTwoParameters command, TValue1 value1, TValue2 value2) => cmJsInterop.ModuleInvokeVoidAsync("dispatchCommand", command, value1, value2); } diff --git a/CodeMirror6/Models/CodeMirrorCommandTwoParameters.cs b/CodeMirror6/Models/CodeMirrorCommandTwoParameters.cs new file mode 100644 index 00000000..8b0defee --- /dev/null +++ b/CodeMirror6/Models/CodeMirrorCommandTwoParameters.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + +namespace GaelJ.BlazorCodeMirror6.Models; + +/// +/// Built-in CodeMirror commands expecting 2 parameters +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum CodeMirrorCommandTwoParameters +{ + /// + /// Insert a markdown table at the current selection + /// + InsertTable, +} diff --git a/CodeMirror6/Models/CodeMirrorSimpleCommand.cs b/CodeMirror6/Models/CodeMirrorSimpleCommand.cs index c9cee2b4..e9f099e5 100644 --- a/CodeMirror6/Models/CodeMirrorSimpleCommand.cs +++ b/CodeMirror6/Models/CodeMirrorSimpleCommand.cs @@ -63,6 +63,11 @@ public enum CodeMirrorSimpleCommand /// ToggleMarkdownTaskList, + /// + /// Insert a markdown horizontal rule above the current cursor position + /// + InsertMarkdownHorizontalRule, + /// /// Undo the last change /// diff --git a/CodeMirror6/NodeLib/src/CmCommands.ts b/CodeMirror6/NodeLib/src/CmCommands.ts index 0633f489..cae94b0c 100644 --- a/CodeMirror6/NodeLib/src/CmCommands.ts +++ b/CodeMirror6/NodeLib/src/CmCommands.ts @@ -180,6 +180,20 @@ function modifyHeaderLevelAtSelections(view: EditorView, delta: number): boolean return true } +export function insertTableAboveCommand(view: EditorView, x: number, y: number) { + var header = "| Header ".repeat(x) + "|" + var sp = "| ------ ".repeat(x) + "|" + var row = "| ".repeat(x) + "|\n" + const table = ` +${header} +${sp} +${row.repeat(y)} +` + insertTextAboveCommand(view, table) +} +export function insertHorizontalRuleAboveCommand(view: EditorView) { + insertTextAboveCommand(view, "\n---\n") +} export const toggleMarkdownBold: Command = (view: EditorView) => toggleCharactersAroundRanges(view, "**") export const toggleMarkdownItalic: Command = (view: EditorView) => toggleCharactersAroundRanges(view, "*") export const toggleMarkdownStrikethrough: Command = (view: EditorView) => toggleCharactersAroundRanges(view, "~~") diff --git a/CodeMirror6/NodeLib/src/index.ts b/CodeMirror6/NodeLib/src/index.ts index f94ed9a7..90c237ce 100644 --- a/CodeMirror6/NodeLib/src/index.ts +++ b/CodeMirror6/NodeLib/src/index.ts @@ -35,6 +35,8 @@ import { insertTextAboveCommand, increaseMarkdownHeadingLevel, decreaseMarkdownHeadingLevel, + insertTableAboveCommand, + insertHorizontalRuleAboveCommand, } from "./CmCommands" import { dynamicImagesExtension } from "./CmImages" import { externalLintSource, getExternalLinterConfig } from "./CmLint" @@ -414,6 +416,9 @@ export function dispatchCommand(id: string, functionName: string, ...args: any[] case 'LineUncomment': lineUncomment(view); break; case 'ToggleLineComment': toggleLineComment(view); break; + case 'InsertTable': insertTableAboveCommand(view, args[0] as number, args[1] as number); break; + case 'InsertMarkdownHorizontalRule': insertHorizontalRuleAboveCommand(view); break; + case 'Focus': break; default: throw new Error(`Function ${functionName} does not exist.`); From f9538cf2f5267c1462fe3de2aa8e9f704a5253bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Sun, 4 Feb 2024 23:03:24 +0100 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20Implement=20csv=20and=20tsv=20w?= =?UTF-8?q?ith=20padded=20column=20widths=20(Does=20not=20support=20multil?= =?UTF-8?q?ine=20cells)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/Models/CodeMirrorLanguage.cs | 10 ++ CodeMirror6/NodeLib/src/CmColumns.ts | 130 +++++++++++++++++++++++ CodeMirror6/NodeLib/src/CmInstance.ts | 1 + CodeMirror6/NodeLib/src/CmKeymap.ts | 9 +- CodeMirror6/NodeLib/src/CmLanguage.ts | 2 + CodeMirror6/NodeLib/src/index.ts | 30 +++++- 6 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 CodeMirror6/NodeLib/src/CmColumns.ts diff --git a/CodeMirror6/Models/CodeMirrorLanguage.cs b/CodeMirror6/Models/CodeMirrorLanguage.cs index 8aed7e1b..b034a4ed 100644 --- a/CodeMirror6/Models/CodeMirrorLanguage.cs +++ b/CodeMirror6/Models/CodeMirrorLanguage.cs @@ -15,6 +15,16 @@ public enum CodeMirrorLanguage /// [JsonStringValue("Plain Text")] PlainText, + /// + /// Comma-separated values + /// + [JsonStringValue("CSV")] Csv, + + /// + /// Tabulation-separated values + /// + [JsonStringValue("TSV")] Tsv, + /// /// APL /// diff --git a/CodeMirror6/NodeLib/src/CmColumns.ts b/CodeMirror6/NodeLib/src/CmColumns.ts new file mode 100644 index 00000000..691aedaf --- /dev/null +++ b/CodeMirror6/NodeLib/src/CmColumns.ts @@ -0,0 +1,130 @@ +import { Decoration, ViewPlugin, WidgetType, EditorView, ViewUpdate, DecorationSet } from "@codemirror/view"; +import { Extension, RangeSetBuilder } from "@codemirror/state"; +import { buildWidget } from "./lib/codemirror-kit"; + + +function createColumnReplaceDecoration(content: string, from: number) { + return Decoration.replace({ + widget: buildWidget({ + eq: (other) => other.content === content, + toDOM: (view: EditorView) => { + const span = document.createElement("span") + span.style.whiteSpace = "pre"; + span.textContent = content; + span.onclick = () => { + view.dispatch(view.state.update({selection: {anchor: from}})) + } + return span + }, + ignoreEvent: () => true, + content: content, + }), + block: false, + inclusive: true, + }) +} + +export function columnStylingPlugin(separator: string): Extension { + return ViewPlugin.define((view: EditorView) => { + return { + update: () => { + const maxWidths = findMaxColumnWidthsInCsv(view.state.doc.toString(), separator); + const builder = new RangeSetBuilder() + for (const { from, to } of view.visibleRanges) { + let pos = from; + while (pos < to) { + const line = view.state.doc.lineAt(pos); + let cell = ""; + let remaining = line.text; + let index = 0; + let cellStartOffset = 0; + let paddingSize = 0 + if (remaining !== "") { + while (remaining !== null) { + [cell, remaining] = extractNextCell(remaining, separator); + if (index > 0) { + const padding = " ".repeat(paddingSize) + separator + const widget = createColumnReplaceDecoration(padding, line.from + cellStartOffset - 1) + builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset, widget); + } + paddingSize = maxWidths[index] - cell.length + 1; + cellStartOffset += cell.length + 1; // For the cell and the comma + index++; + } + } + pos = line.to + 1; // Move to the start of the next line + } + } + return builder.finish(); + }, + } + }, + { + decorations: plugin => plugin.update() + }) +} + +// extract first csv cell from a line of text. Ignore the separator if it is inside quotes. Ignore quotes if they are escaped by another quote. Return the extracted cell and the remaining text after the cell. +function extractNextCell(line: string, separator: string): string[] { + let cell = ""; + let inQuotes = false; + let escapeNext = false; + let separatorFound = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (escapeNext) { + cell += char; + escapeNext = false; + } else if (char === '"' && i < (line.length - 1) && line[i + 1] === '"') { + cell += char + escapeNext = true; + } else if (char === '"') { + inQuotes = !inQuotes; + cell += char; + } else if (char === '\\') { + escapeNext = true; + cell += char; + } else if (char === separator && !inQuotes) { + separatorFound = true; + break; + } else { + cell += char; + } + } + return [cell, separatorFound === false ? null : line.slice(cell.length + 1)]; +} + +function extractAllRowCells(line: string, separator: string): string[] { + let remaining = line; + let cells = []; + while (remaining != null) { + const [cell, newRemaining] = extractNextCell(remaining, separator); + cells.push(cell); + remaining = newRemaining; + } + return cells; +} + +function findMaxColumnWidthsInCsv(csvData: string, separator: string): number[] { + const data = parseCSV(csvData, separator); + return findMaxColumnWidths(data); +} + +function findMaxColumnWidths(data: string[][]): number[] { + let maxWidths: number[] = []; + + data.forEach(row => { + row.forEach((cell, index) => { + const cellWidth = cell.length; + if (!maxWidths[index] || cellWidth > maxWidths[index]) { + maxWidths[index] = cellWidth; + } + }); + }); + + return maxWidths; +} + +function parseCSV(csvData: string, separator: string): string[][] { + return csvData.split('\n').map((row) => extractAllRowCells(row, separator)); +} diff --git a/CodeMirror6/NodeLib/src/CmInstance.ts b/CodeMirror6/NodeLib/src/CmInstance.ts index 3d8036fb..9126b1d4 100644 --- a/CodeMirror6/NodeLib/src/CmInstance.ts +++ b/CodeMirror6/NodeLib/src/CmInstance.ts @@ -26,6 +26,7 @@ export class CmInstance public unifiedMergeViewCompartment: Compartment = new Compartment public highlightTrailingWhitespaceCompartment: Compartment = new Compartment public highlightWhitespaceCompartment: Compartment = new Compartment + public columnsStylingCompartment: Compartment = new Compartment } export const CMInstances: { [id: string]: CmInstance} = {} diff --git a/CodeMirror6/NodeLib/src/CmKeymap.ts b/CodeMirror6/NodeLib/src/CmKeymap.ts index 425e9b6d..77a76005 100644 --- a/CodeMirror6/NodeLib/src/CmKeymap.ts +++ b/CodeMirror6/NodeLib/src/CmKeymap.ts @@ -1,6 +1,13 @@ import { toggleMarkdownBold, toggleMarkdownItalic } from "./CmCommands" +import { KeyBinding } from '@codemirror/view'; +import { insertTab } from '@codemirror/commands' -export const customMarkdownKeymap = [ +export const customMarkdownKeymap: KeyBinding[] = [ { key: 'Mod-b', run: toggleMarkdownBold }, // Cmd/Ctrl + B for bold { key: 'Mod-i', run: toggleMarkdownItalic }, // Cmd/Ctrl + I for italics ] + +export const insertTabKeymap: KeyBinding[] = [ + { key: 'Tab', run: insertTab }, + { key: 'Shift-Tab', run: () => true }, +] diff --git a/CodeMirror6/NodeLib/src/CmLanguage.ts b/CodeMirror6/NodeLib/src/CmLanguage.ts index 300f3841..e612ec12 100644 --- a/CodeMirror6/NodeLib/src/CmLanguage.ts +++ b/CodeMirror6/NodeLib/src/CmLanguage.ts @@ -30,6 +30,8 @@ export async function getLanguage(languageName: string, fileNameOrExtension: str console.log("getLanguage: " + languageName) switch (languageName) { case "Plain Text": + case "CSV": + case "TSV": return null case "Lezer": return lezer() diff --git a/CodeMirror6/NodeLib/src/index.ts b/CodeMirror6/NodeLib/src/index.ts index 90c237ce..f3a1d7ac 100644 --- a/CodeMirror6/NodeLib/src/index.ts +++ b/CodeMirror6/NodeLib/src/index.ts @@ -58,6 +58,8 @@ import { DotNet } from "@microsoft/dotnet-js-interop" import { markdownTableExtension } from "./CmMarkdownTable" import { dynamicDiagramsExtension } from "./CmDiagrams" import { hideMarksExtension } from "./CmHideMarkdownMarks" +import { columnStylingPlugin } from "./CmColumns" +import { insertTabKeymap } from "./CmKeymap" /** * Initialize a new CodeMirror instance @@ -74,15 +76,21 @@ export async function initCodeMirror( if (CMInstances[id] !== undefined) return; + console.log(`Initializing CodeMirror instance ${id}`) try { const minDelay = new Promise(res => setTimeout(res, 100)) CMInstances[id] = new CmInstance() CMInstances[id].dotNetHelper = dotnetHelper CMInstances[id].setup = setup + const customKeyMap = getLanguageKeyMaps(initialConfig.languageName, initialConfig.fileNameOrExtension) + if (initialConfig.languageName !== "CSV" && initialConfig.languageName !== "TSV") + customKeyMap.push(indentWithTab) + else + customKeyMap.push(...insertTabKeymap) let extensions = [ - CMInstances[id].keymapCompartment.of(keymap.of(getLanguageKeyMaps(initialConfig.languageName, initialConfig.fileNameOrExtension))), + CMInstances[id].keymapCompartment.of(keymap.of(customKeyMap)), CMInstances[id].languageCompartment.of(await getLanguage(initialConfig.languageName, initialConfig.fileNameOrExtension) ?? []), CMInstances[id].markdownStylingCompartment.of(initialConfig.languageName !== "Markdown" ? [] : autoFormatMarkdownExtensions(id, initialConfig.autoFormatMarkdown)), CMInstances[id].tabSizeCompartment.of(EditorState.tabSize.of(initialConfig.tabSize)), @@ -98,6 +106,11 @@ export async function initCodeMirror( CMInstances[id].unifiedMergeViewCompartment.of(initialConfig.mergeViewConfiguration ? unifiedMergeView(initialConfig.mergeViewConfiguration) : []), CMInstances[id].highlightTrailingWhitespaceCompartment.of(initialConfig.highlightTrailingWhitespace ? highlightTrailingWhitespace() : []), CMInstances[id].highlightWhitespaceCompartment.of(initialConfig.highlightWhitespace ? highlightWhitespace() : []), + CMInstances[id].columnsStylingCompartment.of( + initialConfig.languageName === "CSV" || initialConfig.languageName === "TSV" + ? columnStylingPlugin(initialConfig.languageName === "CSV" ? ',' : '\t') + : [] + ), EditorView.updateListener.of(async (update) => { await updateListenerExtension(id, update) }), keymap.of([ @@ -135,8 +148,6 @@ export async function initCodeMirror( ...foldKeymap, ...completionKeymap, ...lintKeymap, - - indentWithTab, ]) ] @@ -294,12 +305,22 @@ export function setUnifiedMergeView(id: string, mergeViewConfiguration: UnifiedM export async function setLanguage(id: string, languageName: string, fileNameOrExtension: string) { const language = await getLanguage(languageName, fileNameOrExtension) const customKeyMap = getLanguageKeyMaps(languageName, fileNameOrExtension) + if (languageName !== "CSV" && languageName !== "TSV") + customKeyMap.push(indentWithTab) + else + customKeyMap.push(...insertTabKeymap) + CMInstances[id].view.dispatch({ effects: [ CMInstances[id].languageCompartment.reconfigure(language ?? []), CMInstances[id].keymapCompartment.reconfigure(keymap.of(customKeyMap)), languageChangeEffect.of(language?.language), - CMInstances[id].markdownStylingCompartment.reconfigure(autoFormatMarkdownExtensions(id, languageName === 'Markdown')) + CMInstances[id].markdownStylingCompartment.reconfigure(autoFormatMarkdownExtensions(id, languageName === 'Markdown')), + CMInstances[id].columnsStylingCompartment.reconfigure( + languageName === "CSV" || languageName === "TSV" + ? columnStylingPlugin(languageName === "CSV" ? ',' : '\t') + : [] + ), ] }) } @@ -435,6 +456,7 @@ export function dispatchCommand(id: string, functionName: string, ...args: any[] * @param id */ export function dispose(id: string) { + console.log(`Disposing of CodeMirror instance ${id}`) CMInstances[id].dotNetHelper.dispose() CMInstances[id].dotNetHelper = undefined CMInstances[id].view.destroy() From 6553cb40725700a8461ae208891251bc54444a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Mon, 5 Feb 2024 22:50:41 +0100 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=90=9B=20Fix=20cursor=20movement=20ne?= =?UTF-8?q?ar=20csv=20delimiters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/NodeLib/src/CmColumns.ts | 144 +++++++++++++++++---------- 1 file changed, 91 insertions(+), 53 deletions(-) diff --git a/CodeMirror6/NodeLib/src/CmColumns.ts b/CodeMirror6/NodeLib/src/CmColumns.ts index 691aedaf..d411146c 100644 --- a/CodeMirror6/NodeLib/src/CmColumns.ts +++ b/CodeMirror6/NodeLib/src/CmColumns.ts @@ -1,26 +1,27 @@ -import { Decoration, ViewPlugin, WidgetType, EditorView, ViewUpdate, DecorationSet } from "@codemirror/view"; -import { Extension, RangeSetBuilder } from "@codemirror/state"; +import { Decoration, ViewPlugin, EditorView } from "@codemirror/view"; +import { Extension, RangeSetBuilder, Transaction } from "@codemirror/state"; import { buildWidget } from "./lib/codemirror-kit"; function createColumnReplaceDecoration(content: string, from: number) { - return Decoration.replace({ + return Decoration.widget({ widget: buildWidget({ - eq: (other) => other.content === content, + eq: (other) => other.content === content && other.from === from, toDOM: (view: EditorView) => { const span = document.createElement("span") + span.setAttribute("aria-hidden", "true") span.style.whiteSpace = "pre"; span.textContent = content; span.onclick = () => { - view.dispatch(view.state.update({selection: {anchor: from}})) + view.dispatch(view.state.update({ selection: { anchor: from } })) } return span }, - ignoreEvent: () => true, + ignoreEvent: () => false, content: content, + from: from, }), - block: false, - inclusive: true, + side: 1, }) } @@ -28,103 +29,140 @@ export function columnStylingPlugin(separator: string): Extension { return ViewPlugin.define((view: EditorView) => { return { update: () => { - const maxWidths = findMaxColumnWidthsInCsv(view.state.doc.toString(), separator); + const atomicDecoration = Decoration.mark({ atomic: true }) + const maxWidths = findMaxColumnWidthsInCsv(view.state.doc.toString(), separator) const builder = new RangeSetBuilder() for (const { from, to } of view.visibleRanges) { let pos = from; while (pos < to) { - const line = view.state.doc.lineAt(pos); - let cell = ""; - let remaining = line.text; - let index = 0; - let cellStartOffset = 0; + const line = view.state.doc.lineAt(pos) + let cell = "" + let remaining = line.text + let index = 0 + let cellStartOffset = 0 let paddingSize = 0 if (remaining !== "") { while (remaining !== null) { [cell, remaining] = extractNextCell(remaining, separator); if (index > 0) { - const padding = " ".repeat(paddingSize) + separator + const padding = " ".repeat(paddingSize) const widget = createColumnReplaceDecoration(padding, line.from + cellStartOffset - 1) - builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset, widget); + builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset - 1, widget) + builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset - 1, atomicDecoration) } - paddingSize = maxWidths[index] - cell.length + 1; - cellStartOffset += cell.length + 1; // For the cell and the comma - index++; + paddingSize = maxWidths[index] - cell.length + 1 + cellStartOffset += cell.length + 1 // For the cell and the comma + index++ } } - pos = line.to + 1; // Move to the start of the next line + pos = line.to + 1 } } - return builder.finish(); + return builder.finish() }, } }, { - decorations: plugin => plugin.update() + decorations: plugin => plugin.update(), + eventHandlers: { + keydown: (e, view) => { + if (e.ctrlKey === true || e.metaKey === true || e.altKey === true || e.shiftKey === true) + return + if (e.key === "ArrowLeft") { + if (moveCursor(view, 'left')) + e.preventDefault() + } + else if (e.key === "ArrowRight") { + if (moveCursor(view, 'right')) + e.preventDefault() + } + } + } }) } +function moveCursor(view: EditorView, direction: 'left' | 'right'): boolean { + console.log("moveCursors") + const { state } = view + const transactions: Transaction[] = [] + state.selection.main + const range = state.selection.main + const inc = direction === 'right' ? 1 : -1 + const newAnchor = Math.max(Math.min(state.doc.length, range.anchor + inc), 0) + transactions.push(state.update({ + selection: { anchor: newAnchor }, + scrollIntoView: true, + userEvent: 'input' + })) + if (transactions.length > 0) { + view.dispatch(...transactions) + return true + } + return false +} + + // extract first csv cell from a line of text. Ignore the separator if it is inside quotes. Ignore quotes if they are escaped by another quote. Return the extracted cell and the remaining text after the cell. function extractNextCell(line: string, separator: string): string[] { - let cell = ""; - let inQuotes = false; - let escapeNext = false; - let separatorFound = false; + let cell = "" + let inQuotes = false + let escapeNext = false + let separatorFound = false for (let i = 0; i < line.length; i++) { - const char = line[i]; + const char = line[i] if (escapeNext) { - cell += char; - escapeNext = false; + cell += char + escapeNext = false } else if (char === '"' && i < (line.length - 1) && line[i + 1] === '"') { cell += char - escapeNext = true; + escapeNext = true } else if (char === '"') { - inQuotes = !inQuotes; - cell += char; + inQuotes = !inQuotes + cell += char } else if (char === '\\') { - escapeNext = true; - cell += char; + escapeNext = true + cell += char } else if (char === separator && !inQuotes) { - separatorFound = true; - break; + separatorFound = true + break } else { - cell += char; + cell += char } } - return [cell, separatorFound === false ? null : line.slice(cell.length + 1)]; + return [cell, separatorFound === false ? null : line.slice(cell.length + 1)] } function extractAllRowCells(line: string, separator: string): string[] { - let remaining = line; - let cells = []; + let remaining = line + let cells = [] while (remaining != null) { - const [cell, newRemaining] = extractNextCell(remaining, separator); - cells.push(cell); - remaining = newRemaining; + const [cell, newRemaining] = extractNextCell(remaining, separator) + cells.push(cell) + remaining = newRemaining } - return cells; + return cells } function findMaxColumnWidthsInCsv(csvData: string, separator: string): number[] { - const data = parseCSV(csvData, separator); - return findMaxColumnWidths(data); + const data = parseCSV(csvData, separator) + return findMaxColumnWidths(data) } function findMaxColumnWidths(data: string[][]): number[] { - let maxWidths: number[] = []; + let maxWidths: number[] = [] data.forEach(row => { row.forEach((cell, index) => { - const cellWidth = cell.length; + const cellWidth = cell.length if (!maxWidths[index] || cellWidth > maxWidths[index]) { - maxWidths[index] = cellWidth; + maxWidths[index] = cellWidth } - }); - }); + }) + }) - return maxWidths; + return maxWidths } function parseCSV(csvData: string, separator: string): string[][] { - return csvData.split('\n').map((row) => extractAllRowCells(row, separator)); + return csvData.split('\n').map((row) => extractAllRowCells(row, separator)) } From d290b0b0d546d93518ecf6514dbf2a1471baa0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Mon, 5 Feb 2024 22:51:01 +0100 Subject: [PATCH 4/8] =?UTF-8?q?=F0=9F=8E=A8=20Improve=20eq()=20of=20emojiW?= =?UTF-8?q?idget?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/NodeLib/src/CmEmojiView.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CodeMirror6/NodeLib/src/CmEmojiView.ts b/CodeMirror6/NodeLib/src/CmEmojiView.ts index f7281221..3be866e3 100644 --- a/CodeMirror6/NodeLib/src/CmEmojiView.ts +++ b/CodeMirror6/NodeLib/src/CmEmojiView.ts @@ -8,12 +8,13 @@ import { isCursorInRange } from './CmHelpers' import * as emoji from 'node-emoji' const emojiWidget = (emoji: string) => buildWidget({ - eq: () => false, + eq: (other) => emoji == other.emoji, toDOM: () => { const span = document.createElement('span'); span.textContent = emoji return span; }, + emoji: emoji, }) export const viewEmojiExtension = (enabled: boolean = true): Extension => { From 7db72cd8eb1b5899d440b9a9e495595e62ebc148 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Tue, 6 Feb 2024 00:13:36 +0100 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20Add=20Tab=20and=20Shift-Tab=20k?= =?UTF-8?q?eymaps=20to=20csv=20mode,=20to=20navigate=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/NodeLib/src/CmColumns.ts | 73 ++++++++++++++++++++++------ CodeMirror6/NodeLib/src/CmKeymap.ts | 5 -- CodeMirror6/NodeLib/src/index.ts | 11 ++--- 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/CodeMirror6/NodeLib/src/CmColumns.ts b/CodeMirror6/NodeLib/src/CmColumns.ts index d411146c..a93e77bb 100644 --- a/CodeMirror6/NodeLib/src/CmColumns.ts +++ b/CodeMirror6/NodeLib/src/CmColumns.ts @@ -1,4 +1,4 @@ -import { Decoration, ViewPlugin, EditorView } from "@codemirror/view"; +import { Decoration, ViewPlugin, EditorView, KeyBinding } from "@codemirror/view"; import { Extension, RangeSetBuilder, Transaction } from "@codemirror/state"; import { buildWidget } from "./lib/codemirror-kit"; @@ -25,6 +25,42 @@ function createColumnReplaceDecoration(content: string, from: number) { }) } +// get next or previous column offset relative to the current position +function getRelativeColumnOffset(text: string, separator: string, position: number, previous: boolean): number { + let offset = 0 + let inQuotes = false + let escapeNext = false + let previousColumnOffset = 0 + for (let i = 0; i < text.length; i++) { + if (i === position && !previous) + offset = 0 + else if (i === position && previous) + return previousColumnOffset - position + const char = text[i] + if (escapeNext) { + offset++ + escapeNext = false + } else if (char === '"' && i < (text.length - 1) && text[i + 1] === '"') { + offset++ + escapeNext = true + } else if (char === '"') { + inQuotes = !inQuotes + offset++ + } else if (char === '\\') { + escapeNext = true + offset++ + } else if (char === separator && !inQuotes && previous) { + previousColumnOffset = offset + offset++ + } else if (char === separator && !inQuotes && i >= position) { + return offset + } else { + offset++ + } + } + return offset +} + export function columnStylingPlugin(separator: string): Extension { return ViewPlugin.define((view: EditorView) => { return { @@ -48,7 +84,7 @@ export function columnStylingPlugin(separator: string): Extension { const padding = " ".repeat(paddingSize) const widget = createColumnReplaceDecoration(padding, line.from + cellStartOffset - 1) builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset - 1, widget) - builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset - 1, atomicDecoration) + builder.add(line.from + cellStartOffset - 1, line.from + cellStartOffset, atomicDecoration) } paddingSize = maxWidths[index] - cell.length + 1 cellStartOffset += cell.length + 1 // For the cell and the comma @@ -66,39 +102,46 @@ export function columnStylingPlugin(separator: string): Extension { decorations: plugin => plugin.update(), eventHandlers: { keydown: (e, view) => { + console.log(e) if (e.ctrlKey === true || e.metaKey === true || e.altKey === true || e.shiftKey === true) return if (e.key === "ArrowLeft") { - if (moveCursor(view, 'left')) - e.preventDefault() + moveCursor(view, -1) + e.preventDefault() } else if (e.key === "ArrowRight") { - if (moveCursor(view, 'right')) - e.preventDefault() + moveCursor(view, 1) + e.preventDefault() } } } }) } -function moveCursor(view: EditorView, direction: 'left' | 'right'): boolean { +export const columnStylingKeymap: KeyBinding[] = [ + { key: 'Tab', run: (view) => { + const offset = getRelativeColumnOffset(view.state.doc.toString(), ",", view.state.selection.main.anchor, false) + moveCursor(view, offset + 1) + return true + }}, + { key: 'Shift-Tab', run: (view) => { + const offset = getRelativeColumnOffset(view.state.doc.toString(), ",", view.state.selection.main.anchor, true) + moveCursor(view, offset) + return true + }}, +] + +function moveCursor(view: EditorView, inc: number) { console.log("moveCursors") const { state } = view - const transactions: Transaction[] = [] state.selection.main const range = state.selection.main - const inc = direction === 'right' ? 1 : -1 const newAnchor = Math.max(Math.min(state.doc.length, range.anchor + inc), 0) - transactions.push(state.update({ + view.dispatch(state.update({ selection: { anchor: newAnchor }, scrollIntoView: true, userEvent: 'input' })) - if (transactions.length > 0) { - view.dispatch(...transactions) - return true - } - return false } diff --git a/CodeMirror6/NodeLib/src/CmKeymap.ts b/CodeMirror6/NodeLib/src/CmKeymap.ts index 77a76005..f586dae3 100644 --- a/CodeMirror6/NodeLib/src/CmKeymap.ts +++ b/CodeMirror6/NodeLib/src/CmKeymap.ts @@ -6,8 +6,3 @@ export const customMarkdownKeymap: KeyBinding[] = [ { key: 'Mod-b', run: toggleMarkdownBold }, // Cmd/Ctrl + B for bold { key: 'Mod-i', run: toggleMarkdownItalic }, // Cmd/Ctrl + I for italics ] - -export const insertTabKeymap: KeyBinding[] = [ - { key: 'Tab', run: insertTab }, - { key: 'Shift-Tab', run: () => true }, -] diff --git a/CodeMirror6/NodeLib/src/index.ts b/CodeMirror6/NodeLib/src/index.ts index f3a1d7ac..486541fb 100644 --- a/CodeMirror6/NodeLib/src/index.ts +++ b/CodeMirror6/NodeLib/src/index.ts @@ -58,8 +58,7 @@ import { DotNet } from "@microsoft/dotnet-js-interop" import { markdownTableExtension } from "./CmMarkdownTable" import { dynamicDiagramsExtension } from "./CmDiagrams" import { hideMarksExtension } from "./CmHideMarkdownMarks" -import { columnStylingPlugin } from "./CmColumns" -import { insertTabKeymap } from "./CmKeymap" +import { columnStylingKeymap, columnStylingPlugin } from "./CmColumns" /** * Initialize a new CodeMirror instance @@ -86,8 +85,6 @@ export async function initCodeMirror( const customKeyMap = getLanguageKeyMaps(initialConfig.languageName, initialConfig.fileNameOrExtension) if (initialConfig.languageName !== "CSV" && initialConfig.languageName !== "TSV") customKeyMap.push(indentWithTab) - else - customKeyMap.push(...insertTabKeymap) let extensions = [ CMInstances[id].keymapCompartment.of(keymap.of(customKeyMap)), @@ -108,7 +105,7 @@ export async function initCodeMirror( CMInstances[id].highlightWhitespaceCompartment.of(initialConfig.highlightWhitespace ? highlightWhitespace() : []), CMInstances[id].columnsStylingCompartment.of( initialConfig.languageName === "CSV" || initialConfig.languageName === "TSV" - ? columnStylingPlugin(initialConfig.languageName === "CSV" ? ',' : '\t') + ? [columnStylingPlugin(initialConfig.languageName === "CSV" ? ',' : '\t'), keymap.of(columnStylingKeymap)] : [] ), @@ -307,8 +304,6 @@ export async function setLanguage(id: string, languageName: string, fileNameOrEx const customKeyMap = getLanguageKeyMaps(languageName, fileNameOrExtension) if (languageName !== "CSV" && languageName !== "TSV") customKeyMap.push(indentWithTab) - else - customKeyMap.push(...insertTabKeymap) CMInstances[id].view.dispatch({ effects: [ @@ -318,7 +313,7 @@ export async function setLanguage(id: string, languageName: string, fileNameOrEx CMInstances[id].markdownStylingCompartment.reconfigure(autoFormatMarkdownExtensions(id, languageName === 'Markdown')), CMInstances[id].columnsStylingCompartment.reconfigure( languageName === "CSV" || languageName === "TSV" - ? columnStylingPlugin(languageName === "CSV" ? ',' : '\t') + ? [columnStylingPlugin(languageName === "CSV" ? ',' : '\t'), keymap.of(columnStylingKeymap)] : [] ), ] From 7704e955394fe53f5b989c583dd253a84b8c6dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Tue, 6 Feb 2024 00:17:25 +0100 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=93=9D=20Update=20changelog=20for=200?= =?UTF-8?q?.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 12 ++++++++++++ NEW_CHANGELOG.md | 14 ++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c1b19cd..5bef4f12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.3.0 - 2024-02-06 + +### ✨ Introduce new features + +- Add commands to insert markdown table and horizontal rule +- Implement csv and tsv with padded column widths (Does not support multiline cells) +- Add Tab and Shift-Tab keymaps to csv mode, to navigate columns + +### 🎨 Improve structure / format of the code + +- Improve eq() of emojiWidget + ## 0.2.2 - 2024-02-01 ### ⚡️ Improve performance diff --git a/NEW_CHANGELOG.md b/NEW_CHANGELOG.md index f8c50439..23619a48 100644 --- a/NEW_CHANGELOG.md +++ b/NEW_CHANGELOG.md @@ -1,11 +1,9 @@ -### ⚡️ Improve performance +### ✨ Introduce new features -- Call js module dispose on blazor component dispose +- Add commands to insert markdown table and horizontal rule +- Implement csv and tsv with padded column widths (Does not support multiline cells) +- Add Tab and Shift-Tab keymaps to csv mode, to navigate columns -### ⬆️ Upgrade dependencies +### 🎨 Improve structure / format of the code -- Update @codemirror/lint - -### 🐛 Fix a bug - -- Ensure the js init of an editor is done only once +- Improve eq() of emojiWidget From ef8bc67dd84e4fadd769092d699b2983f1e24b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Tue, 6 Feb 2024 00:17:25 +0100 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/CodeMirror6.csproj | 2 +- Examples.BlazorServer/Examples.BlazorServer.csproj | 2 +- .../Examples.BlazorServerInteractive.csproj | 2 +- Examples.BlazorWasm/Examples.BlazorWasm.csproj | 2 +- Examples.Common/Examples.Common.csproj | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CodeMirror6/CodeMirror6.csproj b/CodeMirror6/CodeMirror6.csproj index 8325a582..69576873 100644 --- a/CodeMirror6/CodeMirror6.csproj +++ b/CodeMirror6/CodeMirror6.csproj @@ -9,7 +9,7 @@ GaelJ.BlazorCodeMirror6 true GaelJ.BlazorCodeMirror6 - 0.2.2 + 0.3.0 true snupkg true diff --git a/Examples.BlazorServer/Examples.BlazorServer.csproj b/Examples.BlazorServer/Examples.BlazorServer.csproj index 0d22b689..4481d26b 100644 --- a/Examples.BlazorServer/Examples.BlazorServer.csproj +++ b/Examples.BlazorServer/Examples.BlazorServer.csproj @@ -4,7 +4,7 @@ enable false enable - 0.2.2 + 0.3.0 diff --git a/Examples.BlazorServerInteractive/Examples.BlazorServerInteractive.csproj b/Examples.BlazorServerInteractive/Examples.BlazorServerInteractive.csproj index ed69a7d1..a1228e5c 100644 --- a/Examples.BlazorServerInteractive/Examples.BlazorServerInteractive.csproj +++ b/Examples.BlazorServerInteractive/Examples.BlazorServerInteractive.csproj @@ -4,7 +4,7 @@ enable enable false - 0.2.2 + 0.3.0 diff --git a/Examples.BlazorWasm/Examples.BlazorWasm.csproj b/Examples.BlazorWasm/Examples.BlazorWasm.csproj index f270f908..30dded37 100644 --- a/Examples.BlazorWasm/Examples.BlazorWasm.csproj +++ b/Examples.BlazorWasm/Examples.BlazorWasm.csproj @@ -4,7 +4,7 @@ enable enable false - 0.2.2 + 0.3.0 diff --git a/Examples.Common/Examples.Common.csproj b/Examples.Common/Examples.Common.csproj index aabcbe10..a8940241 100644 --- a/Examples.Common/Examples.Common.csproj +++ b/Examples.Common/Examples.Common.csproj @@ -5,7 +5,7 @@ enable enable false - 0.2.2 + 0.3.0 From 34ceba016f48f0af064abd62d7b49c08d00df7e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20James?= Date: Tue, 6 Feb 2024 00:21:06 +0100 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=90=9B=20Fix=20hard=20coded=20separat?= =?UTF-8?q?or=20in=20tab=20keymaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CodeMirror6/NodeLib/src/CmColumns.ts | 6 +++--- CodeMirror6/NodeLib/src/index.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CodeMirror6/NodeLib/src/CmColumns.ts b/CodeMirror6/NodeLib/src/CmColumns.ts index a93e77bb..ec5b8571 100644 --- a/CodeMirror6/NodeLib/src/CmColumns.ts +++ b/CodeMirror6/NodeLib/src/CmColumns.ts @@ -118,14 +118,14 @@ export function columnStylingPlugin(separator: string): Extension { }) } -export const columnStylingKeymap: KeyBinding[] = [ +export const getColumnStylingKeymap = (separator: string): KeyBinding[] => [ { key: 'Tab', run: (view) => { - const offset = getRelativeColumnOffset(view.state.doc.toString(), ",", view.state.selection.main.anchor, false) + const offset = getRelativeColumnOffset(view.state.doc.toString(), separator, view.state.selection.main.anchor, false) moveCursor(view, offset + 1) return true }}, { key: 'Shift-Tab', run: (view) => { - const offset = getRelativeColumnOffset(view.state.doc.toString(), ",", view.state.selection.main.anchor, true) + const offset = getRelativeColumnOffset(view.state.doc.toString(), separator, view.state.selection.main.anchor, true) moveCursor(view, offset) return true }}, diff --git a/CodeMirror6/NodeLib/src/index.ts b/CodeMirror6/NodeLib/src/index.ts index 486541fb..a8a82c89 100644 --- a/CodeMirror6/NodeLib/src/index.ts +++ b/CodeMirror6/NodeLib/src/index.ts @@ -58,7 +58,7 @@ import { DotNet } from "@microsoft/dotnet-js-interop" import { markdownTableExtension } from "./CmMarkdownTable" import { dynamicDiagramsExtension } from "./CmDiagrams" import { hideMarksExtension } from "./CmHideMarkdownMarks" -import { columnStylingKeymap, columnStylingPlugin } from "./CmColumns" +import { getColumnStylingKeymap, columnStylingPlugin } from "./CmColumns" /** * Initialize a new CodeMirror instance @@ -105,7 +105,7 @@ export async function initCodeMirror( CMInstances[id].highlightWhitespaceCompartment.of(initialConfig.highlightWhitespace ? highlightWhitespace() : []), CMInstances[id].columnsStylingCompartment.of( initialConfig.languageName === "CSV" || initialConfig.languageName === "TSV" - ? [columnStylingPlugin(initialConfig.languageName === "CSV" ? ',' : '\t'), keymap.of(columnStylingKeymap)] + ? [columnStylingPlugin(initialConfig.languageName === "CSV" ? ',' : '\t'), keymap.of(getColumnStylingKeymap(initialConfig.languageName === "CSV" ? ',' : '\t'))] : [] ), @@ -313,7 +313,7 @@ export async function setLanguage(id: string, languageName: string, fileNameOrEx CMInstances[id].markdownStylingCompartment.reconfigure(autoFormatMarkdownExtensions(id, languageName === 'Markdown')), CMInstances[id].columnsStylingCompartment.reconfigure( languageName === "CSV" || languageName === "TSV" - ? [columnStylingPlugin(languageName === "CSV" ? ',' : '\t'), keymap.of(columnStylingKeymap)] + ? [columnStylingPlugin(languageName === "CSV" ? ',' : '\t'), keymap.of(getColumnStylingKeymap(languageName === "CSV" ? ',' : '\t'))] : [] ), ]