update view scroll for chat

This commit is contained in:
CuongNV
2026-04-13 11:25:55 +07:00
parent ff1e86f853
commit 1793b54947
8 changed files with 210 additions and 20 deletions
@@ -47,4 +47,4 @@ MonoBehaviour:
iconName:
icon: {fileID: 0}
prefix: '!#'
maxRawCharactersPerMessage: 80
maxRawCharactersPerMessage: 200
@@ -1,3 +1,4 @@
using System;
using System.Text;
using System.Text.RegularExpressions;
using CSNetwork;
@@ -10,7 +11,19 @@ namespace BrewMonster.Scripts.Chat
/// </summary>
public static class ChatWireTmpCodec
{
private static readonly Regex SpriteTagRegex = new Regex(@"<sprite\s[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Thẻ &lt;sprite …&gt; nội bộ (TMP cho phép &lt;sprite=&quot;asset&quot; …&gt; không có khoảng sau &quot;sprite&quot;).
/// English: Inner &lt;sprite …&gt; tag (TMP allows &lt;sprite=&quot;asset&quot; …&gt; with no space after &quot;sprite&quot;).
/// </summary>
private static readonly Regex InnerSpriteTagRegex = new Regex(@"<sprite[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
/// Một emoji hiển thị trong input = &lt;size&gt; + sprite + &lt;/size&gt; (EmotionTMPTagBuilder).
/// English: One in-game emoji in input = size wrapper + sprite + closing size tag.
/// </summary>
private static readonly Regex EmotionSizedBlockRegex =
new Regex(@"<size=\d+%>\s*<sprite[^>]*>\s*</size>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex OrphanSpriteFragmentRegex = new Regex(@"^\s*sprite\s[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>
@@ -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();
}
/// <summary>
/// Đoạn TMP (không gồm khối &lt;size&gt;…&lt;/size&gt; đã xử lý ở ngoài): chữ thường + &lt;sprite&gt; rời.
/// English: TMP segment: plain text plus bare sprite tags (outside size-wrapped emotion blocks).
/// </summary>
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 <sprite ...> 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
}
/// <summary>
/// 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 &lt;size&gt;… hoặc thẻ &lt;sprite&gt; rời), trả khoảng xóa [start, end).
/// English: If charIndex is inside one TMP emoji unit, returns delete span [start, end).
/// </summary>
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;
}
/// <summary>
/// Khớp tag &lt;sprite…&gt; với EmotionTMPTagBuilder — so sánh phần sprite bên trong &lt;size&gt;…&lt;/size&gt;.
/// English: Match inner &lt;sprite…&gt; to EmotionTMPTagBuilder output (full tag includes size wrapper).
/// </summary>
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;
@@ -135,6 +135,9 @@ namespace BrewMonster.Scripts.ChatUI
EventBus.Unsubscribe<ChatChannelFilterChangedEvent>(OnChannelFilterChanged);
EventBus.Unsubscribe<OpenChatPanelRequestedEvent>(OnOpenChatPanelRequested);
if (scrollRect != null)
scrollRect.onValueChanged.RemoveListener(OnScrollChanged);
if (_pool != null)
{
_pool.Clear();
@@ -309,8 +312,12 @@ namespace BrewMonster.Scripts.ChatUI
ScrollToBottom();
}
/// <summary>Cuộn log chat xuống dòng cuối (sau khi layout xong).</summary>
public void ScrollToBottom()
{
if (scrollRect == null) return;
if (content != null)
LayoutRebuilder.ForceRebuildLayoutImmediate(content);
Canvas.ForceUpdateCanvases();
scrollRect.verticalNormalizedPosition = 0f;
}
@@ -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<ScrollRect>();
_iconCache = new Dictionary<byte, Sprite>();
if (chatSystemSO != null && chatSystemSO.channelIcons != null)
{
@@ -160,7 +165,17 @@ namespace BrewMonster.Scripts.ChatUI
view.Bind(icon, data.message);
}
ScrollMiniChatToBottom();
}
/// <summary>Cuộn mini log xuống dòng cuối (khi có ScrollRect và nội dung cao hơn viewport).</summary>
void ScrollMiniChatToBottom()
{
if (miniChatScrollRect == null) return;
if (miniChatContent != null)
LayoutRebuilder.ForceRebuildLayoutImmediate(miniChatContent);
Canvas.ForceUpdateCanvases();
miniChatScrollRect.verticalNormalizedPosition = 0f;
}
/// <summary>
@@ -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
@@ -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:
@@ -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:
+93 -1
View File
@@ -43,6 +43,14 @@ namespace BrewMonster.Scripts.ChatUI
private bool _syncingWireToDisplay;
/// <summary>
/// Bản sao text input lần trước — dùng phát hiện xóa 1 ký tự trong thẻ &lt;sprite&gt;.
/// English: Last input text snapshot — used to detect single-char delete inside a sprite tag.
/// </summary>
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();
}
/// <summary>
/// Xóa Backspace/Delete từng ký tự trong thẻ TMP &lt;sprite&gt; → gỡ cả thẻ (một emoji).
/// English: Single-char delete inside a TMP sprite tag removes the whole tag (one in-game emoji).
/// </summary>
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)