From e61cd2368c8c452810ddb7c682dbee8114b4f61d Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 18 Mar 2025 12:59:36 +0330 Subject: [PATCH] improve BitMarkdownEditor #10271 --- .../MarkdownEditor/BitMarkdownEditor.razor.cs | 58 +++- .../MarkdownEditor/BitMarkdownEditor.scss | 1 + .../MarkdownEditor/BitMarkdownEditor.ts | 328 ++++++++++-------- .../BitMarkdownEditorJsRuntimeExtensions.cs | 13 +- .../BitMarkdownEditorDemo.razor | 33 +- .../BitMarkdownEditorDemo.razor.cs | 50 ++- 6 files changed, 334 insertions(+), 149 deletions(-) diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.razor.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.razor.cs index bff1b45cbb..db417e597f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.razor.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.razor.cs @@ -6,6 +6,7 @@ public partial class BitMarkdownEditor : BitComponentBase { private ElementReference _textAreaRef = default!; + private DotNetObjectReference? _dotnetObj = null; @@ -13,6 +14,24 @@ public partial class BitMarkdownEditor : BitComponentBase + /// + /// The default text value of the editor to use at initialization. + /// + [Parameter] public string? DefaultValue { get; set; } + + /// + /// Callback for when the editor value changes. + /// + [Parameter] public EventCallback OnChange { get; set; } + + /// + /// The two-way bound text value of the editor. + /// + [Parameter, TwoWayBound] + public string? Value { get; set; } + + + /// /// Returns the current value of the editor. /// @@ -23,15 +42,46 @@ public async ValueTask GetValue() + [JSInvokable("OnChange")] + public async Task _OnChange(string? value) + { + await AssignValue(value); + await OnChange.InvokeAsync(value); + } + + + protected override string RootElementClass => "bit-mde"; - protected override Task OnAfterRenderAsync(bool firstRender) + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender is false) return; + + if ((ValueHasBeenSet && ValueChanged.HasDelegate) || OnChange.HasDelegate) + { + _dotnetObj = DotNetObjectReference.Create(this); + } + + await _js.BitMarkdownEditorInit(_Id, _textAreaRef, _dotnetObj, DefaultValue); + } + + + + protected override async ValueTask DisposeAsync(bool disposing) { - if (firstRender) + if (IsDisposed || disposing is false) return; + + _dotnetObj?.Dispose(); + + try { - _js.BitMarkdownEditorInit(_Id, _textAreaRef); + await _js.BitMarkdownEditorDispose(_Id); } + catch (JSDisconnectedException) { } // we can ignore this exception here + - return base.OnAfterRenderAsync(firstRender); + await base.DisposeAsync(disposing); } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss index 3220996f8b..014bcc0fbe 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.scss @@ -2,6 +2,7 @@ .bit-mde { width: 100%; + height: 100%; box-sizing: border-box; } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts index 0e766c5a7d..7387f44c6f 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditor.ts @@ -1,14 +1,18 @@ namespace BitBlazorUI { type Content = { value: string; - type: "inline" | "block" | "wrap"; + type: "inline" | "block" | "wrap" | "init"; }; export class MarkdownEditor { private static _editors: { [key: string]: MarkdownEditor } = {}; - public static init(id: string, textArea: HTMLTextAreaElement) { - const editor = new MarkdownEditor(textArea); + public static init(id: string, textArea: HTMLTextAreaElement, dotnetObj?: DotNetObject, defaultValue?: string) { + const editor = new MarkdownEditor(textArea, dotnetObj); + + if (defaultValue) { + editor.value = defaultValue; + } MarkdownEditor._editors[id] = editor; } @@ -20,6 +24,14 @@ namespace BitBlazorUI { return editor.value; } + public static dispose(id: string) { + if (!MarkdownEditor._editors[id]) return; + + MarkdownEditor._editors[id].#dispose(); + + delete MarkdownEditor._editors[id]; + } + #opens: string[] = []; #pairs: { [key: string]: string } = { @@ -29,12 +41,26 @@ namespace BitBlazorUI { "<": ">", '"': '"', "`": "`", + "*": "*", + "**": "**", }; #textArea: HTMLTextAreaElement; + #dotnetObj: DotNetObject | undefined | null; + + #clickHandler: (e: MouseEvent) => void; + #changeHandler: (e: Event) => void; + #dblClickHandler: (e: MouseEvent) => void; + #keydownHandler: (e: KeyboardEvent) => Promise; - constructor(textArea: HTMLTextAreaElement) { + constructor(textArea: HTMLTextAreaElement, dotnetObj?: DotNetObject) { this.#textArea = textArea; + this.#dotnetObj = dotnetObj; + + this.#clickHandler = this.#click.bind(this); + this.#changeHandler = this.#change.bind(this); + this.#dblClickHandler = this.#dblClick.bind(this); + this.#keydownHandler = this.#keydown.bind(this); this.#init(); } @@ -47,6 +73,14 @@ namespace BitBlazorUI { this.#textArea.value = value; } + #init() { + this.#textArea.addEventListener("click", this.#clickHandler); + this.#textArea.addEventListener("change", this.#changeHandler); + this.#textArea.addEventListener("input", this.#changeHandler); + this.#textArea.addEventListener("dblclick", this.#dblClickHandler); + this.#textArea.addEventListener("keydown", this.#keydownHandler); + } + get #block() { const codeBlocks = this.value.split("```"); let total = 0; @@ -90,30 +124,30 @@ namespace BitBlazorUI { } #insert( - element: Content, + content: Content, start: number, end: number, ) { - if (element.type === "inline") { - this.value = `${this.value.slice(0, end)}${element.value}${this.value.slice(end)}`; - } else if (element.type === "wrap") { - this.value = insert(this.value, element.value, start); + if (content.type === "inline") { + this.value = `${this.value.slice(0, end)}${content.value}${this.value.slice(end)}`; + } else if (content.type === "wrap") { + this.value = insert(this.value, content.value, start); this.value = insert( this.value, - this.#pairs[element.value] as string, - end + element.value.length, + this.#pairs[content.value] as string, + end + content.value.length, ); - if (element.value.length < 2) this.#opens.push(element.value); - } else if (element.type === "block") { + if (content.value.length < 2) this.#opens.push(content.value); + } else if (content.type === "block") { const { total, num } = this.#getLine(); - const first = element.value.at(0); + const first = content.value.at(0); if (first && total[num]?.startsWith(first)) { - total[num] = element.value.trim() + total[num]; + total[num] = content.value.trim() + total[num]; } else { - total[num] = element.value + total[num]; + total[num] = content.value + total[num]; } this.value = total.join("\n"); - } + } else if (content.type === "init") { } } #setCaret( @@ -144,12 +178,12 @@ namespace BitBlazorUI { this.#textArea.focus(); } - #add(element: Content) { + #add(content: Content) { const end = this.#end; const start = this.#start; - this.#insert(element, start, end); - this.#setCaret(element.value, start, end); + this.#insert(content, start, end); + this.#setCaret(content.value, start, end); } #getLists(str: string | undefined) { @@ -198,137 +232,155 @@ namespace BitBlazorUI { this.value = total.join("\n"); } - #init() { - this.#textArea.addEventListener("keydown", async (e) => { - const reseters = ["Delete", "ArrowUp", "ArrowDown"]; - const next = this.value[this.#end] ?? ""; - if (reseters.includes(e.key)) { - this.#opens = []; - } else if (e.key === "Backspace") { - const prev = this.value[this.#start - 1]; - if ( - prev && - prev in this.#pairs && - next === this.#pairs[prev] - ) { - e.preventDefault(); - const start = this.#start - 1; - const end = this.#end - 1; - this.value = remove(this.value, start); - this.value = remove(this.value, end); - setTimeout(() => { - this.#set(start, end); - }, 0); - this.#opens.pop(); - } - if (prev === "\n" && this.#start === this.#end) { - e.preventDefault(); - const pos = this.#start - 1; - const { num } = this.#getLine(); - this.#correct(num, true); - this.value = remove(this.value, pos); - setTimeout(async () => { - this.#set(pos, pos); - }, 0); - } - } else if (e.key === "Tab") { - if (this.#block % 2 !== 0) { - e.preventDefault(); - this.#add({ - type: "inline", - value: "\t", - }); + async #keydown(e: KeyboardEvent) { + const reseters = ["Delete", "ArrowUp", "ArrowDown"]; + const next = this.value[this.#end] ?? ""; + if (reseters.includes(e.key)) { + this.#opens = []; + } else if (e.key === "Backspace") { + const prev = this.value[this.#start - 1]; + if ( + prev && + prev in this.#pairs && + next === this.#pairs[prev] + ) { + e.preventDefault(); + const start = this.#start - 1; + const end = this.#end - 1; + this.value = remove(this.value, start); + this.value = remove(this.value, end); + setTimeout(() => { + this.#set(start, end); + }, 0); + this.#opens.pop(); + } + if (prev === "\n" && this.#start === this.#end) { + e.preventDefault(); + const pos = this.#start - 1; + const { num } = this.#getLine(); + this.#correct(num, true); + this.value = remove(this.value, pos); + setTimeout(async () => { + this.#set(pos, pos); + }, 0); + } + } else if (e.key === "Tab") { + if (this.#block % 2 !== 0) { + e.preventDefault(); + this.#add({ type: "inline", value: "\t" }); + } + } else if (e.key === "Enter") { + const { total, num, col } = this.#getLine(); + const line = total.at(num); + let rep = this.#getLists(line); + const orig = rep; + + const n = startsWithNumber(rep); + if (n) rep = `${n + 1}. `; + + if (rep && (orig && orig.length < col)) { + e.preventDefault(); + if (n) this.#correct(num); + this.#add({ type: "inline", value: `\n${rep}` }); + } else if (rep && (orig && orig.length === col)) { + e.preventDefault(); + + const origEnd = this.#end; + const pos = origEnd - orig.length; + + for (let i = 0; i < orig.length; i++) { + this.value = remove(this.value, origEnd - (i + 1)); } - } else if (e.key === "Enter") { - const { total, num, col } = this.#getLine(); - const line = total.at(num); - let rep = this.#getLists(line); - const orig = rep; - - const n = startsWithNumber(rep); - if (n) rep = `${n + 1}. `; - - if (rep && (orig && orig.length < col)) { - e.preventDefault(); - if (n) this.#correct(num); - this.#add({ - type: "inline", - value: `\n${rep}`, - }); - } else if (rep && (orig && orig.length === col)) { - e.preventDefault(); - const origEnd = this.#end; - const pos = origEnd - orig.length; - - for (let i = 0; i < orig.length; i++) { - this.value = remove(this.value, origEnd - (i + 1)); + setTimeout(async () => { + this.#set(pos, pos); + this.#textArea.focus(); + this.#add({ type: "inline", value: `\n` }); + }, 0); + } + } else { + const nextIsPaired = Object.values(this.#pairs).includes(next); + const isSelected = this.#start !== this.#end; + if (e.ctrlKey || e.metaKey) { + if (this.#start === this.#end) { + if (e.key === "c" || e.key === "x") { + e.preventDefault(); + const { total, num, col } = this.#getLine(); + + await navigator.clipboard.writeText(`${num === 0 && e.key === "x" ? "" : "\n"}${total[num]}`); + + if (e.key === "x") { + const pos = this.#start - col; + total.splice(num, 1); + this.value = total.join("\n"); + setTimeout(() => this.#set(pos, pos), 0); + } } + } + } + + if ((e.ctrlKey || e.metaKey) && e.key) { + let content: Content | undefined; - setTimeout(async () => { - this.#set(pos, pos); - this.#textArea.focus(); - this.#add({ - type: "inline", - value: `\n`, - }); - }, 0); + if (e.key === "h") { + content = { type: 'block', value: '# ' }; } - } else { - const nextIsPaired = Object.values(this.#pairs).includes(next); - const isSelected = this.#start !== this.#end; - if (e.ctrlKey || e.metaKey) { - if (this.#start === this.#end) { - if (e.key === "c" || e.key === "x") { - e.preventDefault(); - const { total, num, col } = this.#getLine(); - - await navigator.clipboard.writeText(`${num === 0 && e.key === "x" ? "" : "\n"}${total[num]}`); - - if (e.key === "x") { - const pos = this.#start - col; - total.splice(num, 1); - this.value = total.join("\n"); - setTimeout(() => this.#set(pos, pos), 0); - } - } - } + if (e.key === "b") { + content = { type: 'wrap', value: '**' }; + } + if (e.key === "i") { + content = { type: 'wrap', value: '*' }; } - if ((e.ctrlKey || e.metaKey) && e.key) { - // TODO: handle shortkeys for example - } else if ( - nextIsPaired && - (next === e.key || e.key === "ArrowRight") && - this.#opens.length && - !isSelected - ) { - e.preventDefault(); - this.#set(this.#start + 1, this.#end + 1); - this.#opens.pop(); - } else if (e.key in this.#pairs) { + if (content) { + this.#add(content); e.preventDefault(); - this.#add({ - type: "wrap", - value: e.key, - }); - this.#opens.push(e.key); } + + } else if ( + nextIsPaired && + (next === e.key || e.key === "ArrowRight") && + this.#opens.length && + !isSelected + ) { + e.preventDefault(); + this.#set(this.#start + 1, this.#end + 1); + this.#opens.pop(); + } else if (e.key in this.#pairs) { + e.preventDefault(); + this.#add({ type: "wrap", value: e.key }); + this.#opens.push(e.key); } - }); + } + } - this.#textArea.addEventListener("dblclick", () => { - if (this.#start !== this.#end) { - if (this.value[this.#start] === " ") { - this.#set(this.#start + 1, this.#end); - } - if (this.value[this.#end - 1] === " ") { - this.#set(this.#start, this.#end - 1); - } + #dblClick(e: MouseEvent) { + if (this.#start !== this.#end) { + if (this.value[this.#start] === " ") { + this.#set(this.#start + 1, this.#end); + } + if (this.value[this.#end - 1] === " ") { + this.#set(this.#start, this.#end - 1); } - }); + } + } + + #click(e: MouseEvent) { + this.#opens = []; + } + + #change(e: Event) { + this.#dotnetObj?.invokeMethodAsync("OnChange", this.value); + } + + #dispose() { + this.#textArea.removeEventListener("click", this.#clickHandler); + this.#textArea.removeEventListener("change", this.#changeHandler); + this.#textArea.removeEventListener("input", this.#changeHandler); + this.#textArea.removeEventListener("dblclick", this.#dblClickHandler); + this.#textArea.removeEventListener("keydown", this.#keydownHandler); - this.#textArea.addEventListener("click", () => (this.#opens = [])); + this.#dotnetObj = undefined; } } diff --git a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditorJsRuntimeExtensions.cs b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditorJsRuntimeExtensions.cs index 795bce58c9..9d8b685616 100644 --- a/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditorJsRuntimeExtensions.cs +++ b/src/BlazorUI/Bit.BlazorUI.Extras/Components/MarkdownEditor/BitMarkdownEditorJsRuntimeExtensions.cs @@ -2,13 +2,22 @@ internal static class BitMarkdownEditorJsRuntimeExtensions { - public static ValueTask BitMarkdownEditorInit(this IJSRuntime jsRuntime, string id, ElementReference element) + public static ValueTask BitMarkdownEditorInit(this IJSRuntime jsRuntime, + string id, + ElementReference element, + DotNetObjectReference? dotnetObj, + string? defaultValue) { - return jsRuntime.InvokeVoid("BitBlazorUI.MarkdownEditor.init", id, element); + return jsRuntime.InvokeVoid("BitBlazorUI.MarkdownEditor.init", id, element, dotnetObj, defaultValue); } public static ValueTask BitMarkdownEditorGetValue(this IJSRuntime jsRuntime, string id) { return jsRuntime.Invoke("BitBlazorUI.MarkdownEditor.getValue", id); } + + public static ValueTask BitMarkdownEditorDispose(this IJSRuntime jsRuntime, string id) + { + return jsRuntime.InvokeVoid("BitBlazorUI.MarkdownEditor.dispose", id); + } } diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor index 98ce765a56..c72e664016 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor @@ -9,13 +9,38 @@ Description="BitMarkdownEditor is a SEO friendly Blazor wrapper around the famous markedjs library." SecondaryNames="@(["MdViewer", "MD"])" Parameters="componentParameters"> - - Get Value -
+ + +
+ +
+
+ + +
-
+            =>
+            
                 @value
             
+ + +
+ +
+                @onChangeValue
+            
+
+
+ + +
+ +
+                @bindingValue
+            
+
+
diff --git a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor.cs b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor.cs index 743aa373b8..9adbdcfdbd 100644 --- a/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor.cs +++ b/src/BlazorUI/Demo/Client/Bit.BlazorUI.Demo.Client.Core/Pages/Components/Extras/MarkdownEditor/BitMarkdownEditorDemo.razor.cs @@ -4,6 +4,27 @@ public partial class BitMarkdownEditorDemo { private readonly List componentParameters = [ + new() + { + Name = "DefaultValue", + Type = "string?", + DefaultValue = "null", + Description = "The default text value of the editor to use at initialization.", + }, + new() + { + Name = "OnChange", + Type = "EventCallback", + DefaultValue = "", + Description = "Callback for when the editor value changes.", + }, + new() + { + Name = "Value", + Type = "string?", + DefaultValue = "null", + Description = "The two-way bound text value of the editor.", + }, ]; @@ -15,9 +36,16 @@ private async Task GetValue() value = await editorRef.GetValue(); } + private string? onChangeValue; + + private string? bindingValue; + private readonly string example1RazorCode = @" +"; + + private readonly string example2RazorCode = @" Get Value
@@ -25,11 +53,31 @@ private async Task GetValue() @value
"; - private readonly string example1CsharpCode = @" + private readonly string example2CsharpCode = @" private BitMarkdownEditor editorRef = default!; private string? value; private async Task GetValue() { value = await editorRef.GetValue(); }"; + + private readonly string example3RazorCode = @" +
+ onChangeValue = v"" /> +
+        @onChangeValue
+    
+
"; + private readonly string example3CsharpCode = @" +private string? onChangeValue;"; + + private readonly string example4RazorCode = @" +
+ +
+        @bindingValue
+    
+
"; + private readonly string example4CsharpCode = @" +private string? bindingValue;"; }