using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using UnityEngine; using CommonMark; using CommonMark.Syntax; using UnityEngine.UI; using TMPro; using CustomUI.BeatSaber; namespace BSIPA_ModList.UI.ViewControllers { [RequireComponent(typeof(RectTransform), typeof(ScrollRect))] public class MarkdownView : MonoBehaviour { private class TagTypeComponent : MonoBehaviour { internal BlockTag Tag; internal HeadingData hData; } private string markdown = ""; private bool mdDirty = false; public string Markdown { get => markdown; set { markdown = value; mdDirty = true; } } public RectTransform rectTransform => GetComponent(); private ScrollRect view; private RectTransform content; private RectTransform viewport; private Scrollbar scrollbar; private CommonMarkSettings settings; public MarkdownView() { settings = CommonMarkSettings.Default.Clone(); settings.AdditionalFeatures = CommonMarkAdditionalFeatures.All; settings.RenderSoftLineBreaksAsLineBreaks = false; settings.UriResolver = ResolveUri; } public Func HasEmbeddedImage; private string ResolveUri(string arg) { var name = arg.Substring(3); if (!arg.StartsWith("!::") && !arg.StartsWith("w::")) { // !:: means embedded, w:: means web // this block is for when neither is specified Logger.md.Debug($"Resolving nonspecific URI {arg}"); // check if its embedded if (HasEmbeddedImage != null && HasEmbeddedImage(arg)) return "!::" + arg; else return "w::" + arg; } Logger.md.Debug($"Resolved specific URI {arg}"); return arg; } protected void Awake() { view = GetComponent(); view.verticalScrollbarVisibility = ScrollRect.ScrollbarVisibility.AutoHide; view.vertical = true; view.horizontal = false; view.scrollSensitivity = 0f; view.inertia = true; view.movementType = ScrollRect.MovementType.Clamped; scrollbar = new GameObject("Scrollbar", typeof(RectTransform)).AddComponent(); scrollbar.transform.SetParent(transform); scrollbar.direction = Scrollbar.Direction.TopToBottom; scrollbar.interactable = true; view.verticalScrollbar = scrollbar; var vpgo = new GameObject("Viewport"); viewport = vpgo.AddComponent(); viewport.SetParent(transform); viewport.localPosition = Vector2.zero; viewport.anchorMin = Vector2.zero; viewport.anchorMax = Vector2.one; viewport.anchoredPosition = new Vector2(.5f, .5f); viewport.sizeDelta = Vector2.zero; var vpmask = vpgo.AddComponent(); var vpim = vpgo.AddComponent(); // supposedly Mask needs an Image? vpmask.showMaskGraphic = false; vpim.color = Color.white; vpim.sprite = WhitePixel; vpim.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial; view.viewport = viewport; content = new GameObject("Content Wrapper").AddComponent(); content.SetParent(viewport); var contentLayout = content.gameObject.AddComponent(); var contentFitter = content.gameObject.AddComponent(); contentFitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; contentFitter.verticalFit = ContentSizeFitter.FitMode.Unconstrained; contentLayout.preferredWidth = contentLayout.minWidth = rectTransform.sizeDelta.x; // to be adjusted content.gameObject.AddComponent(); content.localPosition = Vector2.zero; content.anchorMin = new Vector2(.5f, .5f); content.anchorMax = new Vector2(.5f, .5f); content.anchoredPosition = Vector2.zero; //content.sizeDelta = Vector2.zero; view.content = content; } private static Sprite whitePixel; private static Sprite WhitePixel { get { if (whitePixel == null) whitePixel = Resources.FindObjectsOfTypeAll().First(s => s.name == "WhitePixel"); return whitePixel; } } #if DEBUG #if UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK private byte tbreakSettings = 0; #endif #endif public void Update() { #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK if (Input.GetKeyDown(KeyCode.K)) { tbreakSettings = (byte)((tbreakSettings + 1) % 16); UpdateMd(); Logger.md.Info(tbreakSettings.ToString()); } #endif if (mdDirty) UpdateMd(); else if (resetContentPosition) { resetContentPosition = false; var v = content.anchoredPosition; v.y = -(content.rect.height / 2); content.anchoredPosition = v; } } private bool resetContentPosition = false; private void UpdateMd() { mdDirty = false; Clear(); var doc = CommonMarkConverter.Parse(markdown, settings); Stack layout = new Stack(); layout.Push(content); TextMeshProUGUI currentText = null; foreach (var node in doc.AsEnumerable()) { Logger.md.Debug($"node {node}"); if (node.Block != null) { var block = node.Block; void Spacer(float size = 1.5f) { var go = new GameObject("Spacer", typeof(RectTransform)); var vlayout = go.GetComponent(); vlayout.SetParent(layout.Peek()); vlayout.anchorMin = new Vector2(.5f, .5f); vlayout.anchorMax = new Vector2(.5f, .5f); vlayout.localScale = Vector3.one; vlayout.localPosition = Vector3.zero; var l = go.AddComponent(); l.minHeight = l.preferredHeight = size; } HorizontalOrVerticalLayoutGroup BlockNode(string name, float spacing, bool isVertical, Action apply = null, float? spacer = null, bool isDoc = false) { if (node.IsOpening) { Logger.md.Debug($"Creating block container {name}"); currentText = null; var go = new GameObject(name, typeof(RectTransform)); var vlayout = go.GetComponent(); vlayout.SetParent(layout.Peek()); //vlayout.anchoredPosition = new Vector2(.5f, .5f); vlayout.anchorMin = new Vector2(.5f, .5f); vlayout.anchorMax = new Vector2(.5f, .5f); vlayout.localScale = Vector3.one; vlayout.localPosition = Vector3.zero; if (isDoc) { vlayout.sizeDelta = Vector2.zero; vlayout.anchorMin = Vector2.zero; vlayout.anchorMax = Vector2.one; } var tt = go.AddComponent(); tt.Tag = block.Tag; apply?.Invoke(tt); layout.Push(vlayout); HorizontalOrVerticalLayoutGroup l; if (isVertical) l = go.AddComponent(); else l = go.AddComponent(); l.childControlHeight = l.childControlWidth = true; l.childForceExpandHeight = l.childForceExpandWidth = false; l.childForceExpandWidth = isDoc; l.spacing = spacing; /*var cfit = go.AddComponent(); cfit.horizontalFit = ContentSizeFitter.FitMode.PreferredSize; cfit.verticalFit = ContentSizeFitter.FitMode.Unconstrained;*/ return l; } else if (node.IsClosing) { currentText = null; layout.Pop(); if (spacer.HasValue) Spacer(spacer.Value); } return null; } switch (block.Tag) { case BlockTag.Document: BlockNode("DocumentRoot", .5f, true, isDoc: true); break; case BlockTag.SetextHeading: var l = BlockNode("SeHeading", .1f, false, t => t.hData = block.Heading); if (l) l.childAlignment = TextAnchor.UpperCenter; // TODO: fix centering break; case BlockTag.AtxHeading: l = BlockNode("AtxHeading", .1f, false, t => t.hData = block.Heading); if (l && block.Heading.Level == 1) l.childAlignment = TextAnchor.UpperCenter; break; case BlockTag.Paragraph: l = BlockNode("Paragraph", .1f, false, spacer: 1.5f); break; case BlockTag.ThematicBreak: { // TODO: fix this, it doesn't want to actually show up properly const float BreakHeight = .5f; var go = new GameObject("ThematicBreak", typeof(RectTransform), typeof(HorizontalLayoutGroup)); var vlayout = go.GetComponent(); vlayout.SetParent(layout.Peek()); l = go.GetComponent(); #if DEBUG && UI_CONFIGURE_MARKDOWN_THEMATIC_BREAK l.childControlHeight = (tbreakSettings & 0b0001) != 0; // if set, not well behaved l.childControlWidth = (tbreakSettings & 0b0010) != 0; l.childForceExpandHeight = (tbreakSettings & 0b0100) != 0; // if set, not well behaved l.childForceExpandWidth = (tbreakSettings & 0b1000) != 0; #else l.childControlHeight = false; l.childControlWidth = false; l.childForceExpandHeight = false; l.childForceExpandWidth = false; #endif l.spacing = 0f; vlayout.localScale = Vector3.one; vlayout.anchoredPosition = Vector2.zero; vlayout.anchorMin = new Vector2(.5f, .5f); vlayout.anchorMax = new Vector2(.5f, .5f); vlayout.sizeDelta = new Vector2(layout.Peek().rect.width - BreakHeight, BreakHeight); vlayout.localPosition = Vector3.zero; currentText = null; go = new GameObject("ThematicBreak Bar", typeof(RectTransform), typeof(Image), typeof(LayoutElement)); var im = go.GetComponent(); im.color = Color.white; // i think i need to copy the sprite because i'm using the same one for the mask im.sprite = Sprite.Create(WhitePixel.texture, WhitePixel.rect, Vector2.zero); im.material = CustomUI.Utilities.UIUtilities.NoGlowMaterial; var rt = go.GetComponent(); rt.SetParent(vlayout); var le = go.GetComponent(); le.minWidth = le.preferredWidth = layout.Peek().rect.width - BreakHeight; le.minHeight = le.preferredHeight = BreakHeight; le.flexibleHeight = le.flexibleWidth = 1f; rt.localScale = Vector3.one; rt.localPosition = Vector3.zero; rt.anchoredPosition = Vector3.zero; rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.sizeDelta = new Vector2(layout.Peek().rect.width - BreakHeight, BreakHeight); Spacer(1f); } break; // TODO: add the rest of the tag types } } else if (node.Inline != null) { // inline element var inl = node.Inline; const float PSize = 3.5f; const float H1Size = 4.8f; const float HLevelDecrease = 0.5f; switch (inl.Tag) { case InlineTag.String: if (currentText == null) { Logger.md.Debug($"Adding new text element"); var tt = layout.Peek().gameObject.GetComponent(); currentText = BeatSaberUI.CreateText(layout.Peek(), "", Vector2.zero); //var le = currentText.gameObject.AddComponent(); switch (tt.Tag) { case BlockTag.List: case BlockTag.ListItem: case BlockTag.Paragraph: currentText.fontSize = PSize; currentText.enableWordWrapping = true; break; case BlockTag.AtxHeading: var size = H1Size; size -= HLevelDecrease * (tt.hData.Level - 1); currentText.fontSize = size; currentText.enableWordWrapping = true; break; case BlockTag.SetextHeading: currentText.fontSize = H1Size; currentText.enableWordWrapping = true; break; // TODO: add other relevant types } } Logger.md.Debug($"Appending '{inl.LiteralContent}' to current element"); currentText.text += inl.LiteralContent; break; } } } resetContentPosition = true; } private void Clear() { content.gameObject.SetActive(false); void Clear(Transform target) { foreach (Transform child in target) { Clear(child); Logger.md.Debug($"Destroying {child.name}"); child.SetParent(null); Destroy(child.gameObject); } } Clear(content); content.gameObject.SetActive(true); } } }