From 1793b54947e1d92d93b02125fab6a223bd6d3b09 Mon Sep 17 00:00:00 2001 From: CuongNV <> Date: Mon, 13 Apr 2026 11:25:55 +0700 Subject: [PATCH] update view scroll for chat --- .../ChatSystems/ChatSystemSO.asset | 2 +- .../Scripts/Chat/ChatWireTmpCodec.cs | 100 +++++++++++++++--- .../Scripts/Chat/UI/ChatSystemlUI.cs | 7 ++ .../Scripts/Chat/UI/MiniChatUI.cs | 15 +++ .../prefab_MiniChatContentsText.prefab | 2 +- .../MiniChat/prefab_MiniChatUI.prefab | 8 +- .../ChatSystem/prefab_ChatSystemUI.prefab | 2 +- Assets/Scripts/ChatInputHandler.cs | 94 +++++++++++++++- 8 files changed, 210 insertions(+), 20 deletions(-) diff --git a/Assets/PerfectWorld/ScriptableObjects/ChatSystems/ChatSystemSO.asset b/Assets/PerfectWorld/ScriptableObjects/ChatSystems/ChatSystemSO.asset index a463931134..4a8737ddca 100644 --- a/Assets/PerfectWorld/ScriptableObjects/ChatSystems/ChatSystemSO.asset +++ b/Assets/PerfectWorld/ScriptableObjects/ChatSystems/ChatSystemSO.asset @@ -47,4 +47,4 @@ MonoBehaviour: iconName: icon: {fileID: 0} prefix: '!#' - maxRawCharactersPerMessage: 80 + maxRawCharactersPerMessage: 200 diff --git a/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs index cce4fa46b7..f38107cd3b 100644 --- a/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs +++ b/Assets/PerfectWorld/Scripts/Chat/ChatWireTmpCodec.cs @@ -1,3 +1,4 @@ +using System; using System.Text; using System.Text.RegularExpressions; using CSNetwork; @@ -10,7 +11,19 @@ namespace BrewMonster.Scripts.Chat /// public static class ChatWireTmpCodec { - private static readonly Regex SpriteTagRegex = new Regex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + /// + /// Thẻ <sprite …> nội bộ (TMP cho phép <sprite="asset" …> không có khoảng sau "sprite"). + /// English: Inner <sprite …> tag (TMP allows <sprite="asset" …> with no space after "sprite"). + /// + private static readonly Regex InnerSpriteTagRegex = new Regex(@"]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// + /// Một emoji hiển thị trong input = <size> + sprite + </size> (EmotionTMPTagBuilder). + /// English: One in-game emoji in input = size wrapper + sprite + closing size tag. + /// + private static readonly Regex EmotionSizedBlockRegex = + new Regex(@"\s*]*>\s*", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex OrphanSpriteFragmentRegex = new Regex(@"^\s*sprite\s[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled); /// @@ -44,9 +57,36 @@ namespace BrewMonster.Scripts.Chat var sb = new StringBuilder(tmpBody.Length); int last = 0; - foreach (Match m in SpriteTagRegex.Matches(tmpBody)) + foreach (Match block in EmotionSizedBlockRegex.Matches(tmpBody)) { - AppendSanitizedPlainText(sb, tmpBody, last, m.Index - last); + AppendTmpSegmentToWire(sb, tmpBody, last, block.Index - last, map); + last = block.Index + block.Length; + + Match inner = InnerSpriteTagRegex.Match(block.Value); + if (inner.Success && TryMatchSpriteTagToEmotion(map, inner.Value, out int es, out int ei)) + sb.Append(BuildMarshaledEmotionWire(es, ei)); + else + sb.Append(block.Value); + } + + AppendTmpSegmentToWire(sb, tmpBody, last, tmpBody.Length - last, map); + return sb.ToString(); + } + + /// + /// Đoạn TMP (không gồm khối <size>…</size> đã xử lý ở ngoài): chữ thường + <sprite> rời. + /// English: TMP segment: plain text plus bare sprite tags (outside size-wrapped emotion blocks). + /// + private static void AppendTmpSegmentToWire(StringBuilder sb, string tmpBody, int start, int length, IEmotionSpriteMap map) + { + if (length <= 0) + return; + + string gap = tmpBody.Substring(start, length); + int last = 0; + foreach (Match m in InnerSpriteTagRegex.Matches(gap)) + { + AppendSanitizedPlainText(sb, gap, last, m.Index - last); string tag = m.Value; if (TryMatchSpriteTagToEmotion(map, tag, out int es, out int ei)) sb.Append(BuildMarshaledEmotionWire(es, ei)); @@ -54,16 +94,12 @@ namespace BrewMonster.Scripts.Chat sb.Append(tag); last = m.Index + m.Length; - // Defensive: TMP_InputField can transiently expose an orphan fragment like - // `sprite anim="..."` right after a valid tag when input updates race. - // Skip it so we do not leak malformed rich-text into wire text. - var orphan = OrphanSpriteFragmentRegex.Match(tmpBody, last); + var orphan = OrphanSpriteFragmentRegex.Match(gap, last); if (orphan.Success) last += orphan.Length; } - AppendSanitizedPlainText(sb, tmpBody, last, tmpBody.Length - last); - return sb.ToString(); + AppendSanitizedPlainText(sb, gap, last, gap.Length - last); } private static void AppendSanitizedPlainText(StringBuilder sb, string source, int start, int length) @@ -81,8 +117,44 @@ namespace BrewMonster.Scripts.Chat } /// - /// Khớp tag với EmotionTMPTagBuilder — duyệt (set,index) đủ nhỏ. - /// Match tag to EmotionTMPTagBuilder output — brute-force over (set,index) within reasonable bounds. + /// Nếu charIndex nằm trong một emoji TMP (khối <size>… hoặc thẻ <sprite> rời), trả khoảng xóa [start, end). + /// English: If charIndex is inside one TMP emoji unit, returns delete span [start, end). + /// + public static bool TryGetSpriteTagRangeContainingCharacterIndex(string text, int charIndex, out int tagStart, out int tagEndExclusive) + { + tagStart = 0; + tagEndExclusive = 0; + if (string.IsNullOrEmpty(text) || charIndex < 0 || charIndex >= text.Length) + return false; + + foreach (Match m in EmotionSizedBlockRegex.Matches(text)) + { + int end = m.Index + m.Length; + if (charIndex >= m.Index && charIndex < end) + { + tagStart = m.Index; + tagEndExclusive = end; + return true; + } + } + + foreach (Match m in InnerSpriteTagRegex.Matches(text)) + { + int end = m.Index + m.Length; + if (charIndex >= m.Index && charIndex < end) + { + tagStart = m.Index; + tagEndExclusive = end; + return true; + } + } + + return false; + } + + /// + /// Khớp tag <sprite…> với EmotionTMPTagBuilder — so sánh phần sprite bên trong <size>…</size>. + /// English: Match inner <sprite…> to EmotionTMPTagBuilder output (full tag includes size wrapper). /// public static bool TryMatchSpriteTagToEmotion(IEmotionSpriteMap map, string spriteTag, out int emotionSet, out int emotionIndex) { @@ -98,7 +170,11 @@ namespace BrewMonster.Scripts.Chat { if (!EmotionTMPTagBuilder.TryBuildEmotionTag(map, s, e, out string built)) continue; - if (built == normalized) + + Match builtSprite = InnerSpriteTagRegex.Match(built); + if (!builtSprite.Success) + continue; + if (string.Equals(builtSprite.Value, normalized, StringComparison.OrdinalIgnoreCase)) { emotionSet = s; emotionIndex = e; diff --git a/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs b/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs index d2ac43f23e..17d416447b 100644 --- a/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs +++ b/Assets/PerfectWorld/Scripts/Chat/UI/ChatSystemlUI.cs @@ -135,6 +135,9 @@ namespace BrewMonster.Scripts.ChatUI EventBus.Unsubscribe(OnChannelFilterChanged); EventBus.Unsubscribe(OnOpenChatPanelRequested); + if (scrollRect != null) + scrollRect.onValueChanged.RemoveListener(OnScrollChanged); + if (_pool != null) { _pool.Clear(); @@ -309,8 +312,12 @@ namespace BrewMonster.Scripts.ChatUI ScrollToBottom(); } + /// Cuộn log chat xuống dòng cuối (sau khi layout xong). public void ScrollToBottom() { + if (scrollRect == null) return; + if (content != null) + LayoutRebuilder.ForceRebuildLayoutImmediate(content); Canvas.ForceUpdateCanvases(); scrollRect.verticalNormalizedPosition = 0f; } diff --git a/Assets/PerfectWorld/Scripts/Chat/UI/MiniChatUI.cs b/Assets/PerfectWorld/Scripts/Chat/UI/MiniChatUI.cs index 08f14e98ed..271fd142a9 100644 --- a/Assets/PerfectWorld/Scripts/Chat/UI/MiniChatUI.cs +++ b/Assets/PerfectWorld/Scripts/Chat/UI/MiniChatUI.cs @@ -16,6 +16,8 @@ namespace BrewMonster.Scripts.ChatUI public Button onOpenChatPanelButton; [Tooltip("Parent cho các dòng tin xem trước (nên có VerticalLayoutGroup).")] public RectTransform miniChatContent; + [Tooltip("ScrollRect bọc mini chat (null = không cuộn). Nếu để trống, Awake sẽ thử GetComponentInParent từ miniChatContent.")] + [SerializeField] ScrollRect miniChatScrollRect; [Tooltip("Null = dùng messagePrefab (fallback).")] public ChatMessageView miniMessagePrefab; [Tooltip("Prefab dòng tin khi miniMessagePrefab null (giống messagePrefab panel chính).")] @@ -36,6 +38,9 @@ namespace BrewMonster.Scripts.ChatUI void Awake() { + if (miniChatScrollRect == null && miniChatContent != null) + miniChatScrollRect = miniChatContent.GetComponentInParent(); + _iconCache = new Dictionary(); if (chatSystemSO != null && chatSystemSO.channelIcons != null) { @@ -160,7 +165,17 @@ namespace BrewMonster.Scripts.ChatUI view.Bind(icon, data.message); } + ScrollMiniChatToBottom(); + } + + /// Cuộn mini log xuống dòng cuối (khi có ScrollRect và nội dung cao hơn viewport). + void ScrollMiniChatToBottom() + { + if (miniChatScrollRect == null) return; + if (miniChatContent != null) + LayoutRebuilder.ForceRebuildLayoutImmediate(miniChatContent); Canvas.ForceUpdateCanvases(); + miniChatScrollRect.verticalNormalizedPosition = 0f; } /// diff --git a/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatContentsText.prefab b/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatContentsText.prefab index f94e31205f..0eab65e8cc 100644 --- a/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatContentsText.prefab +++ b/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatContentsText.prefab @@ -187,7 +187,7 @@ MonoBehaviour: m_fontSizeMax: 72 m_fontStyle: 0 m_HorizontalAlignment: 1 - m_VerticalAlignment: 256 + m_VerticalAlignment: 4096 m_textAlignment: 65535 m_characterSpacing: 0 m_wordSpacing: 0 diff --git a/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatUI.prefab b/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatUI.prefab index 3d21d9065f..95b1a0f36b 100644 --- a/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatUI.prefab +++ b/Assets/Prefabs/ChatSystem/MiniChat/prefab_MiniChatUI.prefab @@ -490,8 +490,8 @@ RectTransform: m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} m_AnchorMin: {x: 0, y: 1} m_AnchorMax: {x: 1, y: 1} - m_AnchoredPosition: {x: 10, y: 0} - m_SizeDelta: {x: -30, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} m_Pivot: {x: 0, y: 1} --- !u!114 &8221922843074448897 MonoBehaviour: @@ -524,7 +524,7 @@ MonoBehaviour: m_Right: 0 m_Top: 0 m_Bottom: 0 - m_ChildAlignment: 0 + m_ChildAlignment: 6 m_Spacing: 0 m_ChildForceExpandWidth: 0 m_ChildForceExpandHeight: 0 @@ -583,7 +583,7 @@ RectTransform: m_AnchorMin: {x: 0, y: 0} m_AnchorMax: {x: 1, y: 1} m_AnchoredPosition: {x: 0, y: 0} - m_SizeDelta: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: -10} m_Pivot: {x: 0.5, y: 0.5} --- !u!222 &6058492214783487750 CanvasRenderer: diff --git a/Assets/Prefabs/ChatSystem/prefab_ChatSystemUI.prefab b/Assets/Prefabs/ChatSystem/prefab_ChatSystemUI.prefab index 87c603392d..8b03b5466c 100644 --- a/Assets/Prefabs/ChatSystem/prefab_ChatSystemUI.prefab +++ b/Assets/Prefabs/ChatSystem/prefab_ChatSystemUI.prefab @@ -716,7 +716,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2070588821282803961} - m_Enabled: 0 + m_Enabled: 1 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3} m_Name: diff --git a/Assets/Scripts/ChatInputHandler.cs b/Assets/Scripts/ChatInputHandler.cs index ada78a2724..320de5e407 100644 --- a/Assets/Scripts/ChatInputHandler.cs +++ b/Assets/Scripts/ChatInputHandler.cs @@ -43,6 +43,14 @@ namespace BrewMonster.Scripts.ChatUI private bool _syncingWireToDisplay; + /// + /// Bản sao text input lần trước — dùng phát hiện xóa 1 ký tự trong thẻ <sprite>. + /// English: Last input text snapshot — used to detect single-char delete inside a sprite tag. + /// + private string _lastInputFieldText = ""; + + private bool _applyingSpriteAwareDelete; + [Serializable] public struct ChannelButtonMapping { @@ -135,6 +143,8 @@ namespace BrewMonster.Scripts.ChatUI if (inputField != null) inputField.onValueChanged.AddListener(OnInputValueChanged); + _lastInputFieldText = inputField != null ? (inputField.text ?? "") : ""; + WireChatDropdown(); RebuildChatDropdownOptions(); @@ -859,13 +869,95 @@ namespace BrewMonster.Scripts.ChatUI _chatWireBody = ChatWireTmpCodec.TmpBodyToWire(tmpBody, _spriteMap); } - private void OnInputValueChanged(string _) + private void OnInputValueChanged(string newText) { + newText ??= ""; + if (_syncingWireToDisplay) + { + _lastInputFieldText = newText; return; + } + + if (_applyingSpriteAwareDelete) + { + _lastInputFieldText = newText; + SyncChatWireBodyFromInput(); + return; + } + + string prev = _lastInputFieldText ?? ""; + if (TryApplySpriteAwareSingleDelete(prev, newText, out string fixedText, out int caretRestore)) + { + _applyingSpriteAwareDelete = true; + try + { + inputField.text = fixedText; + } + finally + { + _applyingSpriteAwareDelete = false; + } + + int clampedCaret = Mathf.Clamp(caretRestore, 0, fixedText.Length); + inputField.caretPosition = clampedCaret; + inputField.ForceLabelUpdate(); + _lastInputFieldText = fixedText; + SyncChatWireBodyFromInput(); + return; + } + + _lastInputFieldText = newText; SyncChatWireBodyFromInput(); } + /// + /// Xóa Backspace/Delete từng ký tự trong thẻ TMP <sprite> → gỡ cả thẻ (một emoji). + /// English: Single-char delete inside a TMP sprite tag removes the whole tag (one in-game emoji). + /// + private static bool TryGetSingleCharDeleteRemovedIndex(string prev, string curr, out int removedIndex) + { + removedIndex = -1; + if (prev == null) + prev = ""; + if (curr == null) + curr = ""; + if (prev.Length != curr.Length + 1) + return false; + + int i = 0; + for (; i < curr.Length; i++) + { + if (prev[i] != curr[i]) + { + removedIndex = i; + return string.CompareOrdinal(prev, i + 1, curr, i, curr.Length - i) == 0; + } + } + + removedIndex = prev.Length - 1; + return true; + } + + private bool TryApplySpriteAwareSingleDelete(string prev, string curr, out string fixedText, out int caretRestore) + { + fixedText = null; + caretRestore = 0; + if (inputField == null || !TryGetSingleCharDeleteRemovedIndex(prev, curr, out int removedIndex)) + return false; + + string prefix = BuildVisualPrefixForCurrentChannel(); + if (removedIndex < prefix.Length) + return false; + + if (!ChatWireTmpCodec.TryGetSpriteTagRangeContainingCharacterIndex(prev, removedIndex, out int tagStart, out int tagEnd)) + return false; + + fixedText = prev.Substring(0, tagStart) + prev.Substring(tagEnd); + caretRestore = tagStart; + return true; + } + private void RefreshInputDisplayFromWire() { if (inputField == null)