From acb06e7ca16f52011aef686cdf45d3839c128caf Mon Sep 17 00:00:00 2001
From: vocoder712 <100213316+vocoder712@users.noreply.github.com>
Date: Tue, 1 Sep 2026 08:52:11 +0800
Subject: [PATCH 1/2] =?UTF-8?q?feature:=20=E5=88=9D=E6=AD=A5=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E6=89=B9=E9=87=8F=E7=BC=96=E8=BE=91=E6=AD=8C=E8=AF=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.agent/DECISIONS.md | 6 ++
OpenUtauMobile/Assets/Lang/Strings.en.resx | 12 +++
OpenUtauMobile/Assets/Lang/Strings.ja.resx | 12 +++
OpenUtauMobile/Assets/Lang/Strings.ru.resx | 12 +++
OpenUtauMobile/Assets/Lang/Strings.uk.resx | 12 +++
.../Assets/Lang/Strings.zh-Hans.resx | 12 +++
.../Controls/BulkLyricEditPopup.axaml | 71 ++++++++++++++
.../Controls/BulkLyricEditPopup.axaml.cs | 33 +++++++
.../ViewModels/BulkLyricEditViewModel.cs | 97 +++++++++++++++++++
.../ViewModels/PianoRollViewModel.cs | 28 ++++++
10 files changed, 295 insertions(+)
create mode 100644 OpenUtauMobile/Controls/BulkLyricEditPopup.axaml
create mode 100644 OpenUtauMobile/Controls/BulkLyricEditPopup.axaml.cs
create mode 100644 OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs
diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md
index 4967b98b..87265c1a 100644
--- a/.agent/DECISIONS.md
+++ b/.agent/DECISIONS.md
@@ -12,6 +12,12 @@ 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-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..fa1a091c 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
+
+
+ Separate lyrics with spaces, commas, or line breaks
+
+
+ Apply to selected notes only
+
+
+ Apply
+
diff --git a/OpenUtauMobile/Assets/Lang/Strings.ja.resx b/OpenUtauMobile/Assets/Lang/Strings.ja.resx
index bda2c415..815081fe 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..0347ed6e 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..bed8ff0a 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..a165b604 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..9b5f1cf1
--- /dev/null
+++ b/OpenUtauMobile/ViewModels/BulkLyricEditViewModel.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reactive;
+using System.Text.RegularExpressions;
+using OpenUtau.Core;
+using OpenUtau.Core.Ustx;
+using ReactiveUI;
+using ReactiveUI.Fody.Helpers;
+
+namespace OpenUtauMobile.ViewModels;
+
+///
+/// 歌词批量编辑弹窗 ViewModel。
+///
+public sealed partial 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.ToList();
+ _selectedNotes = _partNotes
+ .Where(selectedNotes.Contains)
+ .ToList();
+
+ UNote? firstSelectedNote = _selectedNotes.FirstOrDefault();
+ _startNoteIndex = firstSelectedNote == null ? 0 : _partNotes.IndexOf(firstSelectedNote);
+ LyricsText = string.Join(" ", _partNotes.Skip(_startNoteIndex).Select(note => note.lyric));
+
+ CancelCommand = ReactiveCommand.Create(OnCancel);
+ ApplyCommand = ReactiveCommand.Create(OnApply);
+ }
+
+ private void OnCancel()
+ {
+ RaiseClose(null);
+ }
+
+ private void OnApply()
+ {
+ string[] lyrics = LyricSeparatorRegex()
+ .Split(LyricsText)
+ .Where(lyric => lyric.Length > 0)
+ .ToArray();
+ if (lyrics.Length == 0)
+ {
+ RaiseClose(null);
+ return;
+ }
+
+ IEnumerable candidates = ApplyToSelectedNotesOnly
+ ? _selectedNotes
+ : _partNotes.Skip(_startNoteIndex);
+ UNote[] notes = candidates.Take(lyrics.Length).ToArray();
+ if (notes.Length > 0)
+ {
+ string[] appliedLyrics = lyrics.Take(notes.Length).ToArray();
+ 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);
+ }
+
+ [GeneratedRegex("[ ,\\r\\n]+")]
+ private static partial Regex LyricSeparatorRegex();
+}
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