using bessw.Unity.WebView; using bessw.Unity.WebView.ChromeDevTools; using bessw.Unity.WebView.ChromeDevTools.Protocol.DOM; using MoreLinq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using REST_JSON_API; using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using UnityEngine; public class WebViewController : ScrollView { private WebViewComponent webViewComponent; private DomNodeWrapper[] dropzones; private void Awake() { webViewComponent = GetComponent<WebViewComponent>(); WebViewComponent.serializerSettings.Converters.Add(new FactObjectUIConverter()); webViewComponent.targetUrl = Path.Join(CommunicationEvents.Get_DataPath(), "../ScrollView_Server/scrollView.html"); webViewComponent.OnWebViewComponentReady += OnWebViewComponentReady; // TODO: handle webViewComponent.onDomDocumentUpdated } private void OnWebViewComponentReady() { webViewComponent.onDomDocumentUpdated += DocumentUpdatedHandler; SwitchScrollUI.activeScrollData.OnScrollChanged.AddListener(SetScrollContent); SwitchScrollUI.activeScrollData.OnScrollDynamicInfoUpdated.AddListener(SetScrollContent); SwitchScrollUI.activeScrollData.HintAvailableEvent.AddListener(OnHintAvailable); webViewComponent.tab.AddJSBinding("applyScroll", ApplyScrollHandler); webViewComponent.tab.AddJSBinding("getHint", GetHintHandler); // webViewComponent.tab.AddJSBinding("updateAssignments", UpdateAssignmentsHandler); // The browser can just ask the MMTServer directly if (SwitchScrollUI.activeScrollData.Scroll is not null) { SetScrollContent(SwitchScrollUI.activeScrollData.RenderedScroll); } RegisterBrowserEventhandlers(); } private void OnDisable() { webViewComponent.onDomDocumentUpdated -= DocumentUpdatedHandler; SwitchScrollUI.activeScrollData.OnScrollChanged.RemoveListener(SetScrollContent); SwitchScrollUI.activeScrollData.OnScrollDynamicInfoUpdated.RemoveListener(SetScrollContent); webViewComponent.tab.RemoveJSBinding("applyScroll"); webViewComponent.tab.RemoveJSBinding("getHint"); DeRegisterBrowserEventhandlers(); } private void DocumentUpdatedHandler(documentUpdatedEvent _) { // all old DomNodeWrapper objects are invalid, because the whole document has been updated dropzones = null; RegisterBrowserEventhandlers(); } private async void RegisterBrowserEventhandlers() { // register js event handlers in the browser _ = webViewComponent.tab.Evaluate("addDropZoneEventListeners()"); // register c# event handlers // get the dropzones from the scroll and add the event handler to track when something is dropped DomNodeWrapper document = await webViewComponent.tab.Document; dropzones = await document.querySelectorAllAsync("[dropzone='copy']"); dropzones.ForEach(dropzone => dropzone.OnAttributeModified += DropzoneAttributeModifiedHandler); } private void DeRegisterBrowserEventhandlers() { dropzones?.ForEach(dropzone => dropzone.OnAttributeModified -= DropzoneAttributeModifiedHandler); } /// <summary> /// <para> /// Hand the ScrollView the (partially applied) scroll, and request it to rerender the description. /// </para> /// </summary> /// <param name="scroll">The (partially applied) scroll to be rendered</param> private void SetScrollContent(Scroll scroll) { // If the description is plaintext, create a generic MathML version if (!Regex.IsMatch(scroll.description, ".*<scroll-description.*")) { scroll.description = CreateGenericScrollDescription(scroll); } ForwardDataToScrollView("scrollDynamic", scroll); _ = webViewComponent.tab.Evaluate("RenderScroll()"); Debug.Log("Called for scrollView to rerender"); // Update the interactive components dropzones = null; RegisterBrowserEventhandlers(); } /// <summary> /// Create a MathML description for a scroll without one. /// Obviously cannot be very fancy. /// </summary> /// <param name="scroll">The rendered scroll to create a description for</param> /// <returns>A fitting scroll-description element as a string</returns> private string CreateGenericScrollDescription(Scroll scroll) { // scroll is a legacy plain text scroll, generate html with slots for the required facts var factSlots = SwitchScrollUI.activeScrollData.Assignments .Where(pair => pair.Value.IsVisible) .Select(pair => @$"<span class='legacySlot' dropzone='copy' data-slot-id='{pair.Key}' {(pair.Value.IsSet ? $"data-fact-id='{pair.Value.fact.Id}'" : "")}> {(pair.Value.IsSet ? pair.Value.fact.GetLabel(StageStatic.stage.factState) : scroll.requiredFacts.Find(fact => fact.@ref.uri == pair.Key).label)} </span>"); return $"<scroll-description><p>{scroll.description}</p><div id='legacySlots'>{String.Join("", factSlots)}</div></scroll-description>"; } private async void DropzoneAttributeModifiedHandler(attributeModifiedEvent attributeModifiedEvent) { //Debug.Log($"dropzoneAttributeModifiedHandler: '{attributeModifiedEvent.name}'"); // call the onFactAssignment event if the data-fact-id attribute was modified if (attributeModifiedEvent.name == "data-fact-id") { // get the slot id var node = await webViewComponent.tab.GetNode(attributeModifiedEvent.nodeId); if (!(node.Node.attributes.TryGetValue("data-slot-id", out string slot) || (await node.getAttributesAsync()).TryGetValue("data-slot-id", out slot))) { Debug.LogError($"dropzoneAttributeModifiedHandler: data-slot-id attribute not found on dropzone"); throw new Exception("data-slot-id attribute not found on dropzone"); } // if fact has been unassigned from the scroll if (attributeModifiedEvent.value == "null") { // remove fact from slot SwitchScrollUI.activeScrollData.AssignFact(slot, null); return; } // get the fact from the fact id if (!FactRecorder.AllFacts.TryGetValue(attributeModifiedEvent.value, out Fact fact)) { Debug.LogError($"dropzoneAttributeModifiedHandler: fact with id '{attributeModifiedEvent.value}' not found"); throw new Exception($"fact with id '{attributeModifiedEvent.value}' not found"); } // assign fact to slot SwitchScrollUI.activeScrollData.AssignFact(slot, fact); } } private async void OnHintAvailable(IReadOnlyList<string> url) { dropzones = await (await webViewComponent.tab.Document).querySelectorAllAsync("[dropzone='copy']"); foreach (var dropzone in dropzones) { if (url.Contains(dropzone.Node.attributes["data-slot-id"])) { _ = dropzone.setAttributeValue("data-hint-available", "true"); } else if (dropzone.Node.attributes.ContainsKey("data-hint-available")) { _ = dropzone.removeAttributeAsync("data-hint-available"); } } } private void ApplyScrollHandler(string button) { SwitchScrollUI.activeScrollData.ButtonClicked(new ApplyScrollButton()); } private void GetHintHandler(string url) { SwitchScrollUI.activeScrollData.ButtonClicked(new HintScrollButton(url)); } /// <summary> /// Send data to the ScrollView. /// <para>JSON serialize the <paramref name="dataValue"/> and add it to the dataset of the Unity-Data-Interface Node. JS can then retrieve it by calling /// <code>const unityData = JSON.parse(document.querySelector("#Unity-Data-Interface").dataset.<paramref name="dataID"/>);</code> /// </para> /// </summary> /// <param name="dataID">The name of the dataset-member <paramref name="dataValue"/> will be assigned to</param> /// <param name="dataValue">The data to transmit</param> public async void ForwardDataToScrollView(string dataID, object dataValue) { string payload = JsonConvert.SerializeObject(dataValue); //payload = WebUtility.HtmlEncode(payload); // Using the DomNodeWrapper methods requires 3 requests, and since those go to an altogether // different prozess, I would like to avoid that. await webViewComponent.tab.Evaluate( $"document.querySelector('#Unity-Data-Interface').dataset.{dataID} = '{payload}'" ); } public string[] GetFactAssignments() { return dropzones.Select(dropzone => dropzone.Node.attributes.GetValueOrDefault("data-fact-id", null)).ToArray(); } #region ObsoleteFunctions : Functions that are no longer used. Since similar functionality may be of use sometime later, they are here for reference. /// <summary> /// <para> /// Hand the ScrollView the labels (and potentially assignments) of all facts, so it can render the scroll. /// </para><para> /// More precisely: Put a stringified and Json serialized Dictionary /// <see cref="OMS.uri"/> slotID -> (<see cref="string"/> label, <see cref="Fact.Id"/> factId) <br/> /// into the "data-assignments" attribute of the "assignments" node /// </para> /// </summary> /// <param name="youHappyNowJS">Is ignored. JS bindings need to take a string argument, so here it is.</param> [Obsolete("This data needs to be updated iff the assignment is changed in Unity, JS doesn't need a way to to request it independently.")] private void UpdateAssignmentsHandler(string youHappyNowJS = "") { Scroll scroll = SwitchScrollUI.activeScrollData.RenderedScroll; Dictionary<string, (string, string)> assignments = new(); foreach (MMTFact slot in scroll.requiredFacts) { string slotId = slot.@ref.uri; ActiveScroll.SlotAssignment assignment = SwitchScrollUI.activeScrollData.Assignments[slotId]; if (assignment.IsSet) { Fact fact = assignment.fact; assignments.Add(slotId, (fact.GetLabel(StageStatic.stage.factState), fact.Id)); } else { assignments.Add(slotId, (slot.label, "")); } } foreach (MMTFact fact in scroll.acquiredFacts) { assignments.Add(fact.@ref.uri, (fact.label, "")); } ForwardDataToScrollView("assignments", JsonConvert.SerializeObject(assignments)); } [Obsolete("Is done by JS. How to render the scroll is the scrolls buisness.")] private string createCustomScrollDescription(Scroll scroll) { string description = scroll.description; // replace slot templates //the scroll-slot syntax has changed by now, so the match would have to be adapted. description = Regex.Replace(description, "<scroll-slot([^>]*)>([^<]*)</scroll-slot>", match => { var extraAttributes = match.Groups[1].Value; var slotId = match.Groups[2].Value; var assignment = SwitchScrollUI.activeScrollData.Assignments[slotId]; /** label of the assigned fact, or the slot label if nothing assigned */ var label = assignment.IsSet ? assignment.fact.GetLabel(StageStatic.stage.factState) : scroll.requiredFacts.Find(fact => fact.@ref.uri == slotId).label; /** id of the assigned fact. If nothing is assigned don't add the attribute */ var fact_id = assignment.IsSet ? $"data-fact-id='{assignment.fact.Id}'" : ""; return $"<mi dropzone='copy' data-slot-id='{slotId}' {fact_id} {extraAttributes}>{label}</mi>"; }); // replace solution templates description = Regex.Replace(description, "<scroll-solution([^>]*)>([^<]*)</scroll-solution>", match => { var extraAttributes = match.Groups[1].Value; var solutionId = match.Groups[2].Value; var label = scroll.acquiredFacts.Find(fact => fact.@ref.uri == solutionId).label; return $"<mi data-solution-id='{solutionId}' {extraAttributes}>{label}</mi>"; }); return description; } [Obsolete()] private async void GetDropzoneStateAsync() { // alternative way to get the dropzone state DomNodeWrapper document = await DomNodeWrapper.getDocumentAsync(webViewComponent.tab); Debug.LogWarning($"dropzone 1: '{document}'"); var dropzones = await document.querySelectorAllAsync("[dropzone='copy']"); Debug.LogWarning($"dropzone 2: '{dropzones.Index().Aggregate("", (currentString, dropzone) => $"{currentString}, {dropzone.Key}: {dropzone.Value.Node.attributes.GetValueOrDefault("data-fact-id")}")}'"); string[] factIDs = new string[dropzones.Length]; // get attributes for each dropzone //for (int i = 0; i < dropzones.Length; i++) //{ // factIDs[i] = ( await dropzones[i].getAttributesAsync() ).GetValueOrDefault("data-fact-id", null); //} //Debug.LogWarning($"dropzone 3: '{string.Join(", ", factIDs)}'"); } /// <summary> /// First try to get the dropzone state using coroutines. /// Doesn't work because coroutines can't access method local temporary variables. /// </summary> /// <returns></returns> [Obsolete("Use getDropzoneStateAsync instead")] private IEnumerator GetDropzoneState() { Debug.LogWarning($"dropzone pre"); DomNodeWrapper doc = null; yield return DomNodeWrapper.getDocument(webViewComponent.tab, (document) => { Debug.LogWarning($"dropzone 1: '{document}'"); doc = document; StartCoroutine(document.querySelectorAll("[dropzone='copy']", (dropzones) => { foreach (var dropzone in dropzones) { Debug.LogWarning($"dropzone 2: Node is Null?: '{dropzone.Node == null}'"); StartCoroutine(dropzone.getAttributes((attributes) => { Debug.LogWarning($"dropzone 3 getAttributes: '{string.Join(", ", attributes.Values)}'"); })); } })); }); Debug.LogWarning($"dropzone post: '{doc}'"); } #endregion ObsoleteFunctions } public class FactObjectUIConverter : JsonConverter<FactObjectUI> { public override void WriteJson(JsonWriter writer, FactObjectUI value, JsonSerializer serializer) { //serializer.Serialize(writer, value.Fact); JObject o = JObject.FromObject(value.Fact, WebViewComponent.serializer); o.AddFirst(new JProperty("id", value.Fact.Id)); o.WriteTo(writer); } public override FactObjectUI ReadJson(JsonReader reader, Type objectType, FactObjectUI existingValue, bool hasExistingValue, JsonSerializer serializer) { var factObject = new FactObjectUI(); factObject.Fact = serializer.Deserialize<Fact>(reader); return factObject; } }