update view scroll for chat
This commit is contained in:
@@ -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ẻ <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").
|
||||
/// </summary>
|
||||
private static readonly Regex InnerSpriteTagRegex = new Regex(@"<sprite[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </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 <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).
|
||||
/// </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 <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).
|
||||
/// </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 <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).
|
||||
/// </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:
|
||||
|
||||
@@ -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ẻ <sprite>.
|
||||
/// 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 <sprite> → 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)
|
||||
|
||||
Reference in New Issue
Block a user