diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index 4967b98b..9834fda2 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,6 +12,18 @@ Record meaningful technical decisions here. Use one entry per decision.
## Entries
+- Date: 2026-09-01
+- Decision: Add a dedicated batch-lyric popup to the Hand, Note, and MultiSelect piano-roll context menus. Initialize it from the earliest selected note through the end of the active voice part, and apply one undoable multi-note lyric command either sequentially or to the selected-note snapshot.
+- Rationale: A focused text editor supports fast paste-and-replace workflows while preserving the current note selection and Core's existing undo/validation behavior. Keeping parsing and scope selection in the Mobile ViewModel avoids changes to upstream-derived Core.
+- Alternatives considered: Extend the single-note lyric navigator; route the workflow through the generic batch-edit catalog; mutate note lyrics directly.
+- Impacted areas: Mobile piano-roll context actions, batch-lyric popup/ViewModel, and localization. OpenUtau.Core is unchanged.
+
+- Date: 2026-09-01
+- Decision: Reuse Core's upstream `SplitLyrics.Split` and `SplitLyrics.Join` for the batch-lyric editor instead of maintaining a Mobile-only delimiter expression.
+- Rationale: The upstream algorithm handles Unicode text elements, automatic CJK/Hiragana/Katakana/Hangul segmentation, contracted Japanese kana, empty lyrics, whitespace-containing lyrics, and quoted groups with matching round-trip serialization.
+- Alternatives considered: Expand the Mobile regular expression; fork and maintain a second parser in the Mobile project.
+- Impacted areas: Mobile batch-lyric initialization, parsing, and input guidance. Core remains unchanged because the synchronized utility already exists.
+
- Date: 2026-08-31
- Decision: Expose clipboard text writes through an injectable `IClipboardService` in `ServiceHub`, backed by Avalonia's current `TopLevel` clipboard. Keep error-detail selection and copy orchestration in the Mobile UI/ViewModel layer.
- Rationale: The service follows the existing cross-platform capability pattern, keeps ViewModels independent of window/platform APIs, and uses Avalonia's native clipboard bridge across desktop, mobile, and browser hosts without duplicating platform code.
diff --git a/OpenUtauMobile/Assets/Lang/Strings.en.resx b/OpenUtauMobile/Assets/Lang/Strings.en.resx
index 2ece0fa2..913ba85e 100644
--- a/OpenUtauMobile/Assets/Lang/Strings.en.resx
+++ b/OpenUtauMobile/Assets/Lang/Strings.en.resx
@@ -1756,4 +1756,16 @@
Failed to copy error information
+
+ Batch edit lyrics
+
+
+ Enter lyrics; use quotes to keep spaces or character groups together
+
+
+ Apply to selected notes only
+
+
+ Apply
+
diff --git a/OpenUtauMobile/Assets/Lang/Strings.ja.resx b/OpenUtauMobile/Assets/Lang/Strings.ja.resx
index bda2c415..3aaaa136 100644
--- a/OpenUtauMobile/Assets/Lang/Strings.ja.resx
+++ b/OpenUtauMobile/Assets/Lang/Strings.ja.resx
@@ -1749,4 +1749,16 @@
エラー情報をコピーできませんでした
+
+ 歌詞を一括編集
+
+
+ 歌詞を入力します。空白や文字列をまとめるには引用符を使用します
+
+
+ 選択したノートにのみ適用
+
+
+ 適用
+
diff --git a/OpenUtauMobile/Assets/Lang/Strings.ru.resx b/OpenUtauMobile/Assets/Lang/Strings.ru.resx
index b0210414..962913d1 100644
--- a/OpenUtauMobile/Assets/Lang/Strings.ru.resx
+++ b/OpenUtauMobile/Assets/Lang/Strings.ru.resx
@@ -1711,4 +1711,16 @@
Не удалось скопировать информацию об ошибке
+
+ Пакетное изменение текста
+
+
+ Введите текст; кавычки сохраняют пробелы и группы символов
+
+
+ Применить только к выбранным нотам
+
+
+ Применить
+
diff --git a/OpenUtauMobile/Assets/Lang/Strings.uk.resx b/OpenUtauMobile/Assets/Lang/Strings.uk.resx
index ceacd2de..04e488ec 100644
--- a/OpenUtauMobile/Assets/Lang/Strings.uk.resx
+++ b/OpenUtauMobile/Assets/Lang/Strings.uk.resx
@@ -1711,4 +1711,16 @@
Не вдалося скопіювати інформацію про помилку
+
+ Пакетне редагування тексту
+
+
+ Введіть текст; лапки зберігають пробіли та групи символів
+
+
+ Застосувати лише до вибраних нот
+
+
+ Застосувати
+
diff --git a/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx b/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx
index 5f0987b4..604b2729 100644
--- a/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx
+++ b/OpenUtauMobile/Assets/Lang/Strings.zh-Hans.resx
@@ -1756,4 +1756,16 @@
复制报错信息失败
+
+ 批量编辑歌词
+
+
+ 输入歌词;使用双引号保留含空格或需组合的内容
+
+
+ 仅应用到选中的音符
+
+
+ 应用
+
diff --git a/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml b/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml
new file mode 100644
index 00000000..16e0a3f2
--- /dev/null
+++ b/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml.cs b/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml.cs
new file mode 100644
index 00000000..828ee1b6
--- /dev/null
+++ b/OpenUtauMobile/Controls/BulkLyricEditPopup.axaml.cs
@@ -0,0 +1,33 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Threading;
+
+namespace OpenUtauMobile.Controls;
+
+public partial class BulkLyricEditPopup : PopupDialogControl
+{
+ protected override PopupDialogWidthPreset WidthPreset => PopupDialogWidthPreset.Wide;
+
+ public BulkLyricEditPopup()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
+ {
+ base.OnAttachedToVisualTree(e);
+ Dispatcher.UIThread.Post(FocusAndSelectLyrics);
+ }
+
+ private void FocusAndSelectLyrics()
+ {
+ TextBox? input = this.FindControl("LyricsInput");
+ if (input == null)
+ {
+ return;
+ }
+
+ input.Focus();
+ input.SelectAll();
+ }
+}
diff --git a/OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs b/OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs
new file mode 100644
index 00000000..ed7ce9fb
--- /dev/null
+++ b/OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs
@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive;
+using OpenUtau.Core;
+using OpenUtau.Core.Ustx;
+using OpenUtau.Core.Util;
+using ReactiveUI;
+using ReactiveUI.Fody.Helpers;
+
+namespace OpenUtauMobile.ViewModels;
+
+///
+/// 歌词批量编辑弹窗 ViewModel。
+///
+public sealed class BulkLyricEditViewModel : PopupViewModelBase, IDisposable
+{
+ private readonly UVoicePart _part;
+ private readonly List _partNotes;
+ private readonly List _selectedNotes;
+ private readonly int _startNoteIndex;
+
+ [Reactive]
+ public string LyricsText { get; set; }
+
+ [Reactive]
+ public bool ApplyToSelectedNotesOnly { get; set; }
+
+ public bool HasSelectedNotes => _selectedNotes.Count > 0;
+
+ public ReactiveCommand CancelCommand { get; }
+
+ public ReactiveCommand ApplyCommand { get; }
+
+ public BulkLyricEditViewModel(UVoicePart part, IReadOnlyCollection selectedNotes)
+ {
+ _part = part;
+ _partNotes = [.. part.notes];
+ _selectedNotes =
+ [
+ .. _partNotes
+ .Where(selectedNotes.Contains)
+ ];
+
+ UNote? firstSelectedNote = _selectedNotes.FirstOrDefault();
+ _startNoteIndex = firstSelectedNote == null ? 0 : _partNotes.IndexOf(firstSelectedNote);
+ LyricsText = SplitLyrics.Join(_partNotes.Skip(_startNoteIndex).Select(note => note.lyric));
+
+ CancelCommand = ReactiveCommand.Create(OnCancel);
+ ApplyCommand = ReactiveCommand.Create(OnApply);
+ }
+
+ private void OnCancel()
+ {
+ RaiseClose(null);
+ }
+
+ private void OnApply()
+ {
+ List lyrics = SplitLyrics.Split(LyricsText);
+ if (lyrics.Count == 0)
+ {
+ RaiseClose(null);
+ return;
+ }
+
+ IEnumerable candidates = ApplyToSelectedNotesOnly
+ ? _selectedNotes
+ : _partNotes.Skip(_startNoteIndex);
+ UNote[] notes = [.. candidates.Take(lyrics.Count)];
+ if (notes.Length > 0)
+ {
+ string[] appliedLyrics = [.. lyrics.Take(notes.Length)];
+ DocManager.Inst.StartUndoGroup();
+ DocManager.Inst.ExecuteCmd(new ChangeNoteLyricCommand(_part, notes, appliedLyrics));
+ DocManager.Inst.EndUndoGroup();
+ }
+
+ RaiseClose(null);
+ }
+
+ public override void RequestBack()
+ {
+ RaiseClose(null);
+ }
+
+ public void Dispose()
+ {
+ CancelCommand.Dispose();
+ ApplyCommand.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+}
diff --git a/OpenUtauMobile/ViewModels/PianoRollViewModel.cs b/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
index 895c1555..732e1b81 100644
--- a/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
+++ b/OpenUtauMobile/ViewModels/PianoRollViewModel.cs
@@ -2191,11 +2191,16 @@ private void RebuildPianoRollContextActions()
switch (EditMode)
{
case PianoRollEditMode.Hand:
+ if (EditingVoicePart != null)
+ {
+ items.Add(CreateBulkLyricEditAction());
+ }
break;
case PianoRollEditMode.Note:
if (EditingVoicePart != null)
{
+ items.Add(CreateBulkLyricEditAction());
// 批量编辑
items.Add(CreateBatchEditAction());
}
@@ -2253,6 +2258,7 @@ private void RebuildPianoRollContextActions()
});
if (EditingVoicePart != null)
{
+ items.Add(CreateBulkLyricEditAction());
items.Add(CreateBatchEditAction());
}
if (hasNoteSelection)
@@ -2372,6 +2378,28 @@ private void RebuildPianoRollContextActions()
PianoRollContextActions = items;
}
+ private ContextActionItem CreateBulkLyricEditAction()
+ {
+ return new ContextActionItem
+ {
+ Icon = PackIconPhosphorIconsKind.TextAa,
+ Tip = L.S("BulkLyricEdit.Title"),
+ Command = ReactiveCommand.CreateFromTask(ShowBulkLyricEditPopupAsync),
+ };
+ }
+
+ private async Task ShowBulkLyricEditPopupAsync()
+ {
+ UVoicePart? part = EditingVoicePart;
+ if (part == null || part.notes.Count == 0)
+ {
+ return;
+ }
+
+ BulkLyricEditViewModel viewModel = new(part, SelectedNotes.ToList());
+ await PopupService.Show