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;
}