diff --git a/Nodexr/Shared/Components/ContentEditableDiv.razor b/Nodexr/Shared/Components/ContentEditableDiv.razor new file mode 100644 index 00000000..ba531023 --- /dev/null +++ b/Nodexr/Shared/Components/ContentEditableDiv.razor @@ -0,0 +1,42 @@ +@inject IJSRuntime JS; + +
+ +@code { + + [Parameter] + public string Text { get; set; } + + [Parameter] + public string CssClass { get; set; } + + [Parameter] + public EventCallback TextChanged { get; set; } + + ElementReference divElement; + + protected string textToDisplay; + + protected override void OnInitialized() + { + //Text = Text.Replace(Environment.NewLine, "
"); + } + + //send initial text (if any) to javascript to place in the div + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("contentEditable.initContentEditable", divElement, DotNetObjectReference.Create(this), Text); + } + } + + //receive input text from javascript and invoke callback to parent component + [JSInvokable] + public async Task GetUpdatedTextFromJavascript(string textFromJavascript) + { + Text = textFromJavascript; + await TextChanged.InvokeAsync(textFromJavascript); + } +} diff --git a/Nodexr/Shared/Components/OutputDisplay.razor b/Nodexr/Shared/Components/OutputDisplay.razor index c0a5de81..ade66b76 100644 --- a/Nodexr/Shared/Components/OutputDisplay.razor +++ b/Nodexr/Shared/Components/OutputDisplay.razor @@ -9,46 +9,55 @@

Output:

-
@foreach (var segment in @NodeHandler.CachedOutput.Contents) - {@*This must not be surrounded by whitespace*@}
+
@if (isEditing) + {} + else + { + @foreach (var segment in @NodeHandler.CachedOutput.Contents) + {@*This must not be surrounded by whitespace*@} + } +
- - - + + +
@functions{ + bool isEditing = false; + protected override void OnInitialized() { NodeHandler.OutputChanged += (sender, e) => StateHasChanged(); } - private async Task OnEditButtonClick() + private void OnEditButtonClick() { - var modalParameters = new ModalParameters(); - modalParameters.Add(nameof(EditRegexDialog.previousRegex), NodeHandler.CachedOutput.Expression); + isEditing = !isEditing; + } - var modal = ModalService.Show("Custom Expression", modalParameters); - var result = await modal.Result; - if (result.Cancelled) - { - Console.WriteLine("Modal was cancelled"); - } - else if (result.Data is string customRegex) - { - Console.WriteLine("Custom Regex: " + customRegex); - NodeHandler.TryCreateTreeFromRegex(customRegex); - } + private void OnEditSubmitted(string newExpression) + { + isEditing = false; + NodeHandler.TryCreateTreeFromRegex(newExpression); + } + + private void OnEditCancelled() + { + isEditing = false; + StateHasChanged(); } private async Task OnCreateLinkButtonClick() { var urlParams = new Dictionary - { - { "parse", NodeHandler.CachedOutput.Expression } - }; + { + { "parse", NodeHandler.CachedOutput.Expression } + }; if (RegexReplaceHandler.SearchText != RegexReplaceHandler.DefaultSearchText) { diff --git a/Nodexr/Shared/Components/OutputDisplaySegment.razor b/Nodexr/Shared/Components/OutputDisplaySegment.razor index fdf3858b..abd2d36c 100644 --- a/Nodexr/Shared/Components/OutputDisplaySegment.razor +++ b/Nodexr/Shared/Components/OutputDisplaySegment.razor @@ -1,7 +1,7 @@ @implements IDisposable @inject INodeHandler NodeHandler - + +
+ Input your own regular expression here to convert it to a node tree.
+ + +
+ + +@code { + + string expression; + + [Parameter] public string StartExpression + { + set => expression = value; + } + + [Parameter] public EventCallback OnSubmitted { get; set; } + [Parameter] public EventCallback OnCanceled { get; set; } + + protected async Task OnKeyPress(KeyboardEventArgs e) + { + if(e.Key == "Enter" && !e.ShiftKey) + { + await Submit(); + } + } + + protected async Task OnKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Escape") + { + await Cancel(); + } + } + + protected async Task Submit() + { + await OnSubmitted.InvokeAsync(expression); + } + + protected async Task Cancel() + { + await OnCanceled.InvokeAsync(null); + } +} diff --git a/Nodexr/Shared/Components/ToastButton.razor b/Nodexr/Shared/Components/ToastButton.razor new file mode 100644 index 00000000..daa9abe4 --- /dev/null +++ b/Nodexr/Shared/Components/ToastButton.razor @@ -0,0 +1,22 @@ +@if (!string.IsNullOrEmpty(Info)) +{ + @Info +} + + +@code { + [Parameter] public Action OnClick { get; set; } + [Parameter] public string ButtonText { get; set; } + [Parameter] public string Info { get; set; } + + private bool hasBeenPressed = false; + + private void OnButtonClicked() + { + hasBeenPressed = true; + OnClick?.Invoke(); + } + + public static RenderFragment GetRenderFragment(Action onClick, string buttonText, string info = null) + => @; +} \ No newline at end of file diff --git a/Nodexr/Shared/MainLayout.razor b/Nodexr/Shared/MainLayout.razor index 8c734fe1..95891918 100644 --- a/Nodexr/Shared/MainLayout.razor +++ b/Nodexr/Shared/MainLayout.razor @@ -11,7 +11,7 @@ *@ + Timeout="13"/>
@Body
diff --git a/Nodexr/Shared/Services/NodeHandler.cs b/Nodexr/Shared/Services/NodeHandler.cs index e98843ba..f6190477 100644 --- a/Nodexr/Shared/Services/NodeHandler.cs +++ b/Nodexr/Shared/Services/NodeHandler.cs @@ -28,8 +28,9 @@ public interface INodeHandler void ForceRefreshNoodles(); void SelectNode(INode node); void DeselectAllNodes(); - bool TryCreateTreeFromRegex(string regex); + void TryCreateTreeFromRegex(string regex); bool IsNodeSelected(INode node); + void RevertPreviousParse(); } public class NodeHandler : INodeHandler @@ -74,6 +75,9 @@ private set private readonly IToastService toastService; + //Stores the previous tree from before the most recent parse, so that the parse can be reverted. + private NodeTree treePrevious; + public NodeHandler(NavigationManager navManager, IToastService toastService) { this.toastService = toastService; @@ -98,25 +102,46 @@ public NodeHandler(NavigationManager navManager, IToastService toastService) /// /// The regular expression to parse, in string format /// Whether or not the parse attempt succeeded - public bool TryCreateTreeFromRegex(string regex) + public void TryCreateTreeFromRegex(string regex) { var parseResult = RegexParser.Parse(regex); if (parseResult.Success) { + treePrevious = tree; Tree = parseResult.Value; ForceRefreshNodeGraph(); OnOutputChanged(this, EventArgs.Empty); - return true; + + if(CachedOutput.Expression == regex) + { + var fragment = Components.ToastButton.GetRenderFragment(RevertPreviousParse, "Revert to previous"); + toastService.ShowSuccess(fragment, "Converted to node tree successfully"); + } + else + { + var fragment = Components.ToastButton.GetRenderFragment( + RevertPreviousParse, + "Revert to previous", + "Your expression was parsed, but the resulting output is slighty different to your input. " + + "This is most likely due to a simplification that has been performed automatically.\n"); + toastService.ShowInfo(fragment, "Converted to node tree"); + } } else { toastService.ShowError(parseResult.Error.ToString(), "Couldn't parse input"); Console.WriteLine("Couldn't parse input: " + parseResult.Error); - return false; } } + public void RevertPreviousParse() + { + Tree = treePrevious; + ForceRefreshNodeGraph(); + OnOutputChanged(this, EventArgs.Empty); + } + public void ForceRefreshNodeGraph() { OnRequireNodeGraphRefresh?.Invoke(this, EventArgs.Empty); diff --git a/Nodexr/wwwroot/css/node.css b/Nodexr/wwwroot/css/node.css index c08e2dd1..e7899997 100644 --- a/Nodexr/wwwroot/css/node.css +++ b/Nodexr/wwwroot/css/node.css @@ -18,6 +18,7 @@ padding: 2px 4px 0px 4px; /*margin-bottom: 0px;*/ border-radius: 8px 8px 0px 0px; + color: var(--col-text-invert); } .node .node-title.collapsed { border-radius: 8px @@ -34,13 +35,14 @@ border: none; font-size: 0.8rem; padding: 0 0 0 7px; + color: inherit; } .node .icon-button { position: relative; background: none; border: none; - color: var(--col-text-strong2); + color: inherit; font-size: 0.9rem; padding: 1px 0 0 0; outline: 0 !important; @@ -206,16 +208,9 @@ padding-left: 7px; } - - - .add-button { position: relative; margin: 5px 0px 0px 0px; - background-color: rgba(200,200,256,0.09); - border: none; - border-radius: 6px; - color: var(--col-text-medium); font-size: 0.9rem; } diff --git a/Nodexr/wwwroot/css/site.css b/Nodexr/wwwroot/css/site.css index b60282ae..e35b947e 100644 --- a/Nodexr/wwwroot/css/site.css +++ b/Nodexr/wwwroot/css/site.css @@ -36,6 +36,7 @@ --col-text-strong2: #eaeaea; --col-text-medium: #d0d0d0; --col-text-subtle: #acacac; + --col-text-invert: #262626; --col-field: hsl(218, 12%, 28%); --col-field-editable: hsl(218, 10%, 28%); /**/ @@ -257,26 +258,34 @@ input { margin: 5px; } - .output-regex-container :first-child { + .output-regex-container > *:first-child { border-radius: 5px 0 0 5px; } - .output-regex-container :last-child { + .output-regex-container > *:last-child { border-radius: 0 5px 5px 0; } .output-regex { + border: none; background-color: var(--col-field); color: var(--col-text-medium); - border: none; + min-width: 250px; + padding: 0px 5px; + height: auto; +} + +.output-regex-container > .output-regex:focus-within { + box-shadow: inset 0 0 4px 1px #0078f5; +} + +.output-regex-text { font-size: 30px; font-family: Consolas, monospace; line-height: 34px; - min-width: 250px; - padding: 0px 5px; white-space: pre-wrap; word-break: break-all; - height: auto; + outline: 0; } .output-segment { @@ -296,12 +305,44 @@ input { min-width: unset; background-color: hsl(223, 13%, 44%); font-size: 24px; + border-radius: 0; } .output-regex-button:hover { background-color: hsl(222, 14%, 22%); } +.output-edit-textarea { + position: absolute; + resize: none; + background-color: transparent; + color: inherit; + overflow: hidden; + border: none; + padding: 0; + width: 100%; +/* height: 100%;*/ +} + +.output-edit-prompt { + font-size: 1.1em; + position: absolute; + width: 20em; + background-color: rgba(0, 0, 0, 0.8); + border-radius: 5px; + padding: 0.2em 0.2em 0.2em 0.5em; + margin-top: 6px; + margin-left: -5px; +} + + .output-edit-prompt button { + margin: 2px; + float: right; + } + .output-edit-prompt button:hover { + background-color: #c0c0ff60; + } + .search-text-highlight, .search-textarea { padding: 3px; /*letter-spacing: 1px;*/ @@ -363,6 +404,28 @@ mark { padding: 0; } +.toast-button { + background-color: transparent; + font-size: large; + border: 1px solid white; + color: white; +} + +.toast-button:hover:enabled { + background-color: #ffffff44; +} + +.toast-button[disabled] { + color: #ffffffAA; +} + +button { + background-color: rgba(200,200,256,0.2); + border: none; + border-radius: 6px; + color: var(--col-text-medium); +} + button:focus { outline: none; } diff --git a/Nodexr/wwwroot/index.html b/Nodexr/wwwroot/index.html index c5bad6b6..84af50f1 100644 --- a/Nodexr/wwwroot/index.html +++ b/Nodexr/wwwroot/index.html @@ -99,6 +99,7 @@ + diff --git a/Nodexr/wwwroot/js/ContentEditable.js b/Nodexr/wwwroot/js/ContentEditable.js new file mode 100644 index 00000000..5573048c --- /dev/null +++ b/Nodexr/wwwroot/js/ContentEditable.js @@ -0,0 +1,67 @@ + +window.contentEditable = { + getInnerHtml: function (element) { + return element.innerHTML; + }, + + initContentEditable: function (div, instance, textToDisplay) { + div.innerText = textToDisplay; + div.addEventListener("input", function () { + instance.invokeMethodAsync("GetUpdatedTextFromJavascript", div.innerText); + }); + + try { + div.contentEditable = "plaintext-only"; + } + catch (e) { + contentEditable.setupFallbackPlaintextOnly(div); + } + contentEditable.moveCursorToEnd(div); + }, + + moveCursorToEnd: function (elem) { + let s = window.getSelection(); + let r = document.createRange(); + r.setStart(elem, 1); + r.setEnd(elem, 1); + s.removeAllRanges(); + s.addRange(r); + }, + + setupFallbackPlaintextOnly: function (elem) { + elem.contentEditable = "true"; + contentEditable.forcePlaintextPaste(elem); + + elem.addEventListener("drop", function (e) { + e.preventDefault(); + return false; + }); + }, + + forcePlaintextPaste: function (elem) { + elem.addEventListener("paste", function (e) { + e.preventDefault(); + if (e.clipboardData && e.clipboardData.getData) { + var text = e.clipboardData.getData("text/plain"); + document.execCommand("insertHTML", false, text); + } else if (window.clipboardData && window.clipboardData.getData) { + var text = window.clipboardData.getData("Text"); + contentEditable.insertTextAtCursor(text); + } + }); + }, + + insertTextAtCursor: function(text) { + var sel, range; + if (window.getSelection) { + sel = window.getSelection(); + if (sel.getRangeAt && sel.rangeCount) { + range = sel.getRangeAt(0); + range.deleteContents(); + range.insertNode(document.createTextNode(text)); + } + } else if (document.selection && document.selection.createRange) { + document.selection.createRange().text = text; + } + }, +} \ No newline at end of file