using AngleSharp.Dom; using AngleSharp.Html.Dom; using Jint; using Jint.Native; using Jint.Native.Object; namespace XFESystem.Browser; public sealed partial class DomBridge { private sealed class FormSelection { public int Start; public int End; public string Direction = "none"; } private readonly DependentIdentityMap selections = new(); public IElement? CurrentScript { get; set; } public void FocusNode(INode? node) { var element = node as IElement ?? node?.ParentElement; if (element is null) { if (focused is not null) engine.Invoke(Wrap(focused).AsObject().Get("blur"), Wrap(focused), []); engine.Evaluate("void 0"); return; } // A page's mousedown listener may have focused its hidden input. A nonfocusable // paint/hit-test node must not overwrite that focus after event dispatch. while (element is not null && !(element.LocalName is "input" or "textarea" or "button" or "select" || element.HasAttribute("tabindex") || element.HasAttribute("contenteditable") || element.LocalName == "a" && element.HasAttribute("href"))) element = element.ParentElement; if (element is null || element.HasAttribute("disabled")) return; engine.Invoke(Wrap(element).AsObject().Get("focus"), Wrap(element), []); engine.Evaluate("void 0"); } /// Default editable-control action after the caller has dispatched cancelable beforeinput. public bool ApplyTextInput(string text, int deleteDirection = 0) { ArgumentNullException.ThrowIfNull(text); if (focused is not IElement element || element is not (IHtmlInputElement or IHtmlTextAreaElement) || element.HasAttribute("disabled") || element.HasAttribute("readonly")) return false; if (element is IHtmlInputElement && (text.Contains('\n') || text.Contains('\r'))) return false; var value = FormValue(element); var selection = Selection(element); var start = Math.Clamp(selection.Start, 0, value.Length); var end = Math.Clamp(selection.End, start, value.Length); if (start == end) { if (deleteDirection < 0 && start > 0) { --start; if (start > 0 && char.IsLowSurrogate(value[start]) && char.IsHighSurrogate(value[start - 1])) --start; } if (deleteDirection > 0 && end < value.Length) { ++end; if (end < value.Length && char.IsHighSurrogate(value[end - 1]) && char.IsLowSurrogate(value[end])) ++end; } } var next = value[..start] + text + value[end..]; if (next == value) return false; SetFormValue(element, next); selection.Start = selection.End = start + text.Length; DispatchEvent(element, "input", new() { Cancelable = false, Data = text, InputType = deleteDirection < 0 ? "deleteContentBackward" : deleteDirection > 0 ? "deleteContentForward" : text == "\n" ? "insertLineBreak" : "insertText" }); SelectionChanged(element, true); return true; } /// Default control action after an uncanceled keydown. Page handlers (including Monaco) get first refusal. public bool ApplyControlKey(string key, bool shift = false, bool control = false) { if (key == "Tab" && !control) { var focusable = document.QuerySelectorAll("input:not([disabled]),textarea:not([disabled]),button:not([disabled]),select:not([disabled]),a[href],[tabindex]") .Where(element => element.GetAttribute("tabindex") != "-1" && !element.HasAttribute("hidden")).ToArray(); if (focusable.Length == 0) return false; var index = Array.IndexOf(focusable, focused); FocusNode(focusable[(index + (shift ? -1 : 1) + focusable.Length) % focusable.Length]); return true; } if (focused is not IElement element || element is not (IHtmlInputElement or IHtmlTextAreaElement) || element.HasAttribute("disabled")) return false; var value = FormValue(element); var selection = Selection(element); int start = Math.Clamp(selection.Start, 0, value.Length), end = Math.Clamp(selection.End, start, value.Length); if (control && key.Equals("a", StringComparison.OrdinalIgnoreCase)) { SetSelection(element, 0, value.Length, "forward"); SelectionChanged(element, true); return true; } if (key == "Enter") { if (element is not IHtmlTextAreaElement || element.HasAttribute("readonly")) return false; return DispatchEvent(element, "beforeinput", new() { Data = "\n", InputType = "insertLineBreak" }) && ApplyTextInput("\n"); } var anchor = selection.Direction == "backward" ? end : start; var caret = selection.Direction == "backward" ? start : end; int target; if (key is "ArrowLeft" or "ArrowRight") { var left = key == "ArrowLeft"; target = !shift && start != end ? left ? start : end : control ? WordBoundary(value, caret, left) : CodePointBoundary(value, caret, left); } else if (key is "Home" or "End") { target = key == "Home" ? control || element is IHtmlInputElement ? 0 : LineStart(value, caret) : control || element is IHtmlInputElement ? value.Length : LineEnd(value, caret); } else if (key is "ArrowUp" or "ArrowDown" or "PageUp" or "PageDown") { if (element is not IHtmlTextAreaElement) return false; int count = key is "PageUp" or "PageDown" ? Math.Max(1, (int)((host.Measure?.Invoke(element).Height ?? 160) / 20)) : 1; int column = caret - LineStart(value, caret); target = caret; for (var step = 0; step < count; ++step) { if (key is "ArrowUp" or "PageUp") { var line = LineStart(value, target); if (line == 0) { target = 0; break; } var previous = LineStart(value, line - 1); target = Math.Min(previous + column, line - 1); } else { var line = LineEnd(value, target); if (line == value.Length) { target = value.Length; break; } var next = line + 1; target = Math.Min(next + column, LineEnd(value, next)); } } } else return false; if (shift) SetSelection(element, Math.Min(anchor, target), Math.Max(anchor, target), target < anchor ? "backward" : "forward"); else SetSelection(element, target, target, "none"); SelectionChanged(element, true); return true; } private void SelectionChanged(IElement element, bool external) { host.Mutated?.Invoke(); if (external) { DispatchEvent(element, "select", new() { Cancelable = false }); DispatchEvent(document, "selectionchange", new() { Bubbles = false, Cancelable = false }); } else { DispatchInternal(element, "select", true, false); DispatchInternal(document, "selectionchange", false, false); } } private static int CodePointBoundary(string value, int position, bool left) { if (left) { if (position == 0) return 0; --position; if (position > 0 && char.IsLowSurrogate(value[position]) && char.IsHighSurrogate(value[position - 1])) --position; } else { if (position == value.Length) return position; ++position; if (position < value.Length && char.IsHighSurrogate(value[position - 1]) && char.IsLowSurrogate(value[position])) ++position; } return position; } private static int WordBoundary(string value, int position, bool left) { if (left) { while (position > 0 && char.IsWhiteSpace(value[position - 1])) --position; while (position > 0 && !char.IsWhiteSpace(value[position - 1])) --position; } else { while (position < value.Length && !char.IsWhiteSpace(value[position])) ++position; while (position < value.Length && char.IsWhiteSpace(value[position])) ++position; } return position; } private static int LineStart(string value, int position) => position <= 0 ? 0 : value.LastIndexOf('\n', Math.Min(position - 1, value.Length - 1)) + 1; private static int LineEnd(string value, int position) { var end = value.IndexOf('\n', position); return end < 0 ? value.Length : end; } private void InstallForm(ObjectInstance wrapper, IElement element) { Property(wrapper, "value", () => FormValue(element), value => SetFormValue(element, LimitedText(value))); Property(wrapper, "defaultValue", () => element is IHtmlTextAreaElement ? element.TextContent : element.GetAttribute("value") ?? string.Empty, value => { if (element is IHtmlTextAreaElement) MutateText(element, value); else SetAttribute(element, "value", value); }); Property(wrapper, "selectionStart", () => Selection(element).Start, value => SetSelection(element, NumberIndex(value), Selection(element).End, Selection(element).Direction)); Property(wrapper, "selectionEnd", () => Selection(element).End, value => SetSelection(element, Selection(element).Start, NumberIndex(value), Selection(element).Direction)); Property(wrapper, "selectionDirection", () => Selection(element).Direction, value => Selection(element).Direction = LimitedText(value)); Function(wrapper, "setSelectionRange", args => { SetSelection(element, NumberIndex(Value(args, 0)), NumberIndex(Value(args, 1)), args.Length > 2 ? Argument(args, 2) : "none"); return JsValue.Undefined; }); Function(wrapper, "select", _ => { SetSelection(element, 0, FormValue(element).Length, "none"); return JsValue.Undefined; }); Function(wrapper, "setRangeText", args => { var old = FormValue(element); var selection = Selection(element); var replacement = Argument(args, 0); var oldStart = selection.Start; var oldEnd = selection.End; var oldDirection = selection.Direction; var start = args.Length >= 3 ? NumberIndex(Value(args, 1)) : selection.Start; var end = args.Length >= 3 ? NumberIndex(Value(args, 2)) : selection.End; start = Math.Clamp(start, 0, old.Length); end = Math.Clamp(end, start, old.Length); SetFormValue(element, old[..start] + replacement + old[end..]); var mode = args.Length > 3 ? Argument(args, 3) : "preserve"; if (mode == "select") SetSelection(element, start, start + replacement.Length, "none"); else if (mode == "start") SetSelection(element, start, start, "none"); else if (mode == "end") SetSelection(element, start + replacement.Length, start + replacement.Length, "none"); else { int delta = replacement.Length - (end - start); SetSelection(element, oldStart > end ? oldStart + delta : Math.Min(oldStart, start), oldEnd > end ? oldEnd + delta : Math.Min(oldEnd, start + replacement.Length), oldDirection); } return JsValue.Undefined; }); } private static string FormValue(IElement element) => element switch { IHtmlInputElement input => input.Value, IHtmlTextAreaElement area => area.Value, _ => element.GetAttribute("value") ?? string.Empty }; private void SetFormValue(IElement element, string value) { if (value.Length > limits.MaximumHtmlCharacters) throw new BrowserLimitException("The control value exceeds its character limit."); var old = FormValue(element); if (element is IHtmlInputElement input) input.Value = value; else if (element is IHtmlTextAreaElement area) area.Value = value; else { SetAttribute(element, "value", value); return; } try { ValidateBudget(); } catch { if (element is IHtmlInputElement rollbackInput) rollbackInput.Value = old; else if (element is IHtmlTextAreaElement rollbackArea) rollbackArea.Value = old; throw; } var selection = Selection(element); selection.Start = selection.End = value.Length; host.Mutated?.Invoke(); } private FormSelection Selection(IElement element) { if (!selections.TryGetValue(element, out var state)) selections.Add(element, state = new() { Start = FormValue(element).Length, End = FormValue(element).Length }); return state; } private void SetSelection(IElement element, int start, int end, string direction) { var state = Selection(element); var length = FormValue(element).Length; state.End = Math.Clamp(end, 0, length); state.Start = Math.Clamp(start, 0, state.End); state.Direction = direction is "forward" or "backward" ? direction : "none"; } private static int NumberIndex(JsValue value) => value.IsNumber() && double.IsFinite(value.AsNumber()) ? (int)Math.Clamp(value.AsNumber(), 0, int.MaxValue) : 0; }