diff --git a/JianpuEditor.Tests/Glue/DocumentTabTests.cs b/JianpuEditor.Tests/Glue/DocumentTabTests.cs index 06f7aa0..83b5e6d 100644 --- a/JianpuEditor.Tests/Glue/DocumentTabTests.cs +++ b/JianpuEditor.Tests/Glue/DocumentTabTests.cs @@ -94,6 +94,7 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services.BuildServiceProvider(); } diff --git a/JianpuEditor.Tests/MainFormMultiTabTests.cs b/JianpuEditor.Tests/MainFormMultiTabTests.cs index 598fe4e..2c0bb0e 100644 --- a/JianpuEditor.Tests/MainFormMultiTabTests.cs +++ b/JianpuEditor.Tests/MainFormMultiTabTests.cs @@ -306,6 +306,7 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddTransient(); return services.BuildServiceProvider(); diff --git a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs index 9ae228b..5bbe585 100644 --- a/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs +++ b/JianpuEditor.Tests/ViewModels/ViewModelTestHelper.cs @@ -55,7 +55,8 @@ public static NoteEditorViewModel CreateNoteEditor( ScoreSelectionViewModel selection, IAppMessenger messenger, IEditCommandHistory history = null, - MeasureNavigationViewModel navigation = null) + MeasureNavigationViewModel navigation = null, + INoteClipboardService clipboard = null) { navigation = navigation ?? CreateMeasureNavigation(document, selection, messenger, history); return new NoteEditorViewModel( @@ -63,7 +64,8 @@ public static NoteEditorViewModel CreateNoteEditor( selection, navigation, messenger, - history ?? CreateHistory(messenger)); + history ?? CreateHistory(messenger), + clipboard ?? new NoteClipboardService()); } public static ChordEditorViewModel CreateChordEditor( @@ -138,7 +140,7 @@ public static MainViewModel CreateMainViewModel() var document = new ScoreDocumentViewModel(new ScoreFileServiceAdapter(), messenger, history); var selection = new ScoreSelectionViewModel(document); var measureNavigation = new MeasureNavigationViewModel(document, selection, messenger, history); - var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history); + var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history, new NoteClipboardService()); var tieEditor = new TieEditorViewModel(document, measureNavigation, messenger, history); var measureContent = new MeasureContentViewModel(document, measureNavigation, messenger, history); var chordEditor = new ChordEditorViewModel( @@ -188,7 +190,7 @@ public static MainViewModel CreateMainViewModel( var document = new ScoreDocumentViewModel(new ScoreFileServiceAdapter(), messenger, history); var selection = new ScoreSelectionViewModel(document); var measureNavigation = new MeasureNavigationViewModel(document, selection, messenger, history); - var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history); + var noteEditor = new NoteEditorViewModel(document, selection, measureNavigation, messenger, history, new NoteClipboardService()); var tieEditor = new TieEditorViewModel(document, measureNavigation, messenger, history); var measureContent = new MeasureContentViewModel(document, measureNavigation, messenger, history); var chordEditor = new ChordEditorViewModel( diff --git a/JianpuEditor/AppBootstrapper.cs b/JianpuEditor/AppBootstrapper.cs index ae6a1bd..8c6454e 100644 --- a/JianpuEditor/AppBootstrapper.cs +++ b/JianpuEditor/AppBootstrapper.cs @@ -58,6 +58,8 @@ public static ServiceProvider ConfigureServices() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Shared across tabs deliberately (like IMidiOutput): cut/copy in one tab, paste in another. + services.AddSingleton(); services.AddTransient(); return services.BuildServiceProvider(); diff --git a/JianpuEditor/Controls/ScoreCanvas.cs b/JianpuEditor/Controls/ScoreCanvas.cs index a7dda7b..9ff8d8a 100644 --- a/JianpuEditor/Controls/ScoreCanvas.cs +++ b/JianpuEditor/Controls/ScoreCanvas.cs @@ -50,6 +50,24 @@ public bool HasChordSelected public IReadOnlyList SelectedNotes { get; set; } = Array.Empty(); } + /// Raised while the canvas's right-click menu is being built, after the canvas has + /// already synced the selection to whatever's under the cursor (see + /// ). A handler adds its own items + /// to based on ; the canvas contributes only its own + /// intrinsic items (currently just "Move playback marker here"). + public sealed class ScoreContextMenuEventArgs : EventArgs + { + public ScoreContextMenuEventArgs(ContextMenuStrip menu, ScoreHitType hitType) + { + Menu = menu; + HitType = hitType; + } + + public ContextMenuStrip Menu { get; } + + public ScoreHitType HitType { get; } + } + public sealed class ScoreCanvas : Panel { private readonly JianpuRenderer _renderer = new JianpuRenderer(); @@ -117,7 +135,6 @@ public ScoreCanvas() AppTheme.ThemeChanged += OnThemeChanged; _contentContextMenu = new ContextMenuStrip(); - _contentContextMenu.Items.Add("Move playback marker here", null, OnMovePlaybackMarkerHereClicked); _contentContextMenu.Opening += OnContentContextMenuOpening; _contentPanel.ContextMenuStrip = _contentContextMenu; } @@ -205,6 +222,8 @@ public void ApplyTheme() public event Action PlaybackSeeked; + public event EventHandler ContextMenuOpening; + public JianpuScore Score { get { return _score; } @@ -814,21 +833,110 @@ private void CommitPlaybackHeadDrag(Point logicalLocation) } /// - /// Right-click alternative to dragging the playback marker. Dragging can only *start* by - /// grabbing the marker's current (narrow) on-screen position, so on a long score the - /// marker effectively feels pinned wherever it last was (typically the very first bar, - /// since that's where every load/new/reset leaves it) until the user finds that exact - /// spot to grab. Right-clicking anywhere seeks there directly, no drag required. + /// Builds the right-click menu fresh on every open: first syncs the selection to + /// whatever's under the cursor (so the rest of the app, listening to SelectionChanged, + /// already reflects the right-click target by the time + /// fires), then contributes the canvas's own "Move playback marker here" item -- see its + /// own remarks below -- and lets subscribers add edit-command items via + /// . Cancelled if nothing ends up added (e.g. an empty + /// canvas with no playback segments yet). /// private void OnContentContextMenuOpening(object sender, CancelEventArgs e) { - if (_playbackSegments.Count == 0) + _contextMenuLogicalLocation = _zoom.ToLogical(_contentPanel.PointToClient(System.Windows.Forms.Cursor.Position)); + var hit = _score == null + ? new ScoreHitResult() + : _renderer.HitTest(_score, GetDrawWidth(), _contextMenuLogicalLocation); + + if (hit.HitType != ScoreHitType.None) + { + ApplyHitSelectionForContextMenu(hit); + } + + _contentContextMenu.Items.Clear(); + + // Right-click alternative to dragging the playback marker. Dragging can only *start* + // by grabbing the marker's current (narrow) on-screen position, so on a long score + // the marker effectively feels pinned wherever it last was (typically the very first + // bar, since that's where every load/new/reset leaves it) until the user finds that + // exact spot to grab. Right-clicking anywhere seeks there directly, no drag required. + if (_playbackSegments.Count > 0) + { + _contentContextMenu.Items.Add("Move playback marker here", null, OnMovePlaybackMarkerHereClicked); + } + + ContextMenuOpening?.Invoke(this, new ScoreContextMenuEventArgs(_contentContextMenu, hit.HitType)); + + if (_contentContextMenu.Items.Count == 0) { e.Cancel = true; - return; } + } - _contextMenuLogicalLocation = _zoom.ToLogical(_contentPanel.PointToClient(System.Windows.Forms.Cursor.Position)); + /// Selection-only counterpart to 's hit-type switch: + /// syncs the selection to whatever's under a right-click, but never triggers a click + /// handler's side effects (deleting a chord marker, starting inline text edit, adding a + /// chord slot) -- those belong to a menu item the user explicitly chooses, not to merely + /// opening the menu. Leaves an existing multi-note selection alone when the right-click + /// landed inside it, so "Cut"/"Copy" on the context menu act on the whole selection + /// rather than collapsing it to just the note under the cursor. + private void ApplyHitSelectionForContextMenu(ScoreHitResult hit) + { + switch (hit.HitType) + { + case ScoreHitType.Tie: + if (_selectedTieIndex != hit.TieIndex) + { + SelectTie(hit.TieIndex); + } + + break; + case ScoreHitType.Note: + if (!_selectedNotes.Any(existing => existing.MeasureIndex == hit.MeasureIndex && existing.NoteIndex == hit.NoteIndex) + && !(_selectedNoteIndex == hit.NoteIndex && _selectedMeasureIndex == hit.MeasureIndex)) + { + HandleNoteSelectionClick(hit.MeasureIndex, hit.NoteIndex); + } + + break; + case ScoreHitType.Gap: + SelectSingleMeasure(hit.MeasureIndex, false); + ClearNoteSelection(); + _selectedInsertIndex = hit.InsertIndex; + _selectedTieIndex = -1; + ClearChordSelection(); + RaiseSelectionChanged(); + InvalidateSelection(); + break; + case ScoreHitType.ChordMarker: + case ScoreHitType.ChordDelete: + case ScoreHitType.ChordDragHandle: + if (hit.ChordMarkerIndex >= 0) + { + SelectChordMarker(hit.MeasureIndex, hit.ChordMarkerIndex, startInlineEdit: false); + } + else + { + SelectSingleMeasure(hit.MeasureIndex, false); + } + + break; + case ScoreHitType.ChordAddSlot: + case ScoreHitType.ChordRow: + SelectSingleMeasure(hit.MeasureIndex, false); + ClearChordSelection(); + RaiseSelectionChanged(); + break; + case ScoreHitType.LyricText: + SelectSingleMeasure(hit.MeasureIndex, false); + RaiseSelectionChanged(); + break; + case ScoreHitType.ScoreHeader: + break; + default: + HandleMeasureSelectionClick(hit.MeasureIndex); + break; + } } private void OnMovePlaybackMarkerHereClicked(object sender, EventArgs e) diff --git a/JianpuEditor/Core/Abstractions/INoteClipboardService.cs b/JianpuEditor/Core/Abstractions/INoteClipboardService.cs new file mode 100644 index 0000000..ee32efa --- /dev/null +++ b/JianpuEditor/Core/Abstractions/INoteClipboardService.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using JianpuEditor.Models; + +namespace JianpuEditor.Core.Abstractions +{ + /// + /// In-app clipboard for melody notes, shared across every open tab (a genuine app-wide + /// resource, like ) so cut/copy in one tab can be pasted into + /// another. Not backed by the OS clipboard. + /// + public interface INoteClipboardService + { + bool HasNotes { get; } + + /// Stores a deep copy of ; later mutations to the + /// source notes (or to whatever previously returned) do not + /// affect what's stored. + void SetNotes(IReadOnlyList notes); + + /// Returns a fresh deep copy each call, safe to insert directly into a score. + IReadOnlyList GetNotes(); + } +} diff --git a/JianpuEditor/MainForm.cs b/JianpuEditor/MainForm.cs index 638c16b..f1987ac 100644 --- a/JianpuEditor/MainForm.cs +++ b/JianpuEditor/MainForm.cs @@ -127,6 +127,7 @@ private void AttachTab(DocumentTab tab) tab.Canvas.ChordMarkersChanged += OnCanvasChordMarkersChanged; tab.Canvas.ScoreMutationStarting += OnCanvasScoreMutationStarting; tab.Canvas.PlaybackSeeked += OnCanvasPlaybackSeeked; + tab.Canvas.ContextMenuOpening += OnCanvasContextMenuOpening; tab.Canvas.ZoomChanged += () => { if (ReferenceEquals(ActiveTab, tab)) @@ -676,6 +677,10 @@ private void PopulateMenuStrip(MenuStrip menu) _redoMenuItem = CreateMenuItem("Redo", Keys.Control | Keys.Y, (s, e) => ExecuteRedo()); _redoMenuItem.Enabled = false; editMenu.DropDownItems.Add(_redoMenuItem); + editMenu.DropDownItems.Add(CreateMenuItem("Cut", Keys.Control | Keys.X, (s, e) => ExecuteCut())); + editMenu.DropDownItems.Add(CreateMenuItem("Copy", Keys.Control | Keys.C, (s, e) => ExecuteCopy())); + editMenu.DropDownItems.Add(CreateMenuItem("Paste", Keys.Control | Keys.V, (s, e) => ExecutePaste())); + editMenu.DropDownItems.Add(CreateMenuItem("Delete", Keys.Delete, (s, e) => ExecuteDelete())); editMenu.DropDownItems.Add(CreateMenuItem("Add Measure", Keys.None, (s, e) => ExecuteAddMeasure())); editMenu.DropDownItems.Add(CreateMenuItem( "Add Measure (with placeholders)", @@ -1054,6 +1059,139 @@ private void ExecuteDelete() ExecuteScoreEdit(() => _viewModel.ScoreEditor.Delete()); } + /// Copy, then delete -- but only if there was actually something to copy, so an + /// accidental Cut with nothing selected doesn't fall through to Delete's own "nothing + /// selected" behavior (deleting the last note in the measure). + private void ExecuteCut() + { + if (_viewModel.NoteEditor.CopySelectedNotes()) + { + ExecuteDelete(); + } + } + + private void ExecuteCopy() + { + _viewModel.NoteEditor.CopySelectedNotes(); + } + + private void ExecutePaste() + { + ExecuteNoteEdit(() => _viewModel.NoteEditor.PasteNotes()); + } + + /// Builds the right-click menu's edit-command items, based on what the canvas + /// just told us is under the cursor (it has already synced the selection to match, before + /// raising this event -- see ). + /// Every item here reuses an existing command already wired to the ribbon/menu elsewhere, + /// except Cut/Copy/Paste. + private void OnCanvasContextMenuOpening(object sender, ScoreContextMenuEventArgs e) + { + var menu = e.Menu; + if (menu.Items.Count > 0) + { + menu.Items.Add(new ToolStripSeparator()); + } + + switch (e.HitType) + { + case ScoreHitType.Note: + AddNoteContextMenuItems(menu); + break; + case ScoreHitType.Gap: + AddGapContextMenuItems(menu); + break; + case ScoreHitType.Tie: + menu.Items.Add("Remove Tie", null, (s, args) => ExecuteDelete()); + break; + case ScoreHitType.ChordMarker: + case ScoreHitType.ChordDelete: + case ScoreHitType.ChordDragHandle: + case ScoreHitType.ChordAddSlot: + case ScoreHitType.ChordRow: + AddChordContextMenuItems(menu); + break; + case ScoreHitType.LyricText: + menu.Items.Add("Align Lyrics", null, (s, args) => ExecuteScoreEdit(() => _viewModel.MeasureContent.AlignLyricsToNotes())); + break; + default: + AddMeasureContextMenuItems(menu); + break; + } + } + + private void AddNoteContextMenuItems(ContextMenuStrip menu) + { + menu.Items.Add("Cut", null, (s, e) => ExecuteCut()); + menu.Items.Add("Copy", null, (s, e) => ExecuteCopy()); + menu.Items.Add("Delete", null, (s, e) => ExecuteDelete()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Shorten", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.DecreaseDuration())); + menu.Items.Add("Extend", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.IncreaseDuration())); + menu.Items.Add("Toggle Dotted", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.ToggleDotted())); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Octave Up", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SetOctave(1))); + menu.Items.Add("Octave Down", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SetOctave(-1))); + menu.Items.Add("Transpose Up", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.TransposePitch(1))); + menu.Items.Add("Transpose Down", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.TransposePitch(-1))); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Split", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.SplitSelectedNotes())); + menu.Items.Add("Merge", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.MergeSelectedNotes())); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Tie Here", null, (s, e) => _viewModel.TieEditor.ToggleTieModeCommand.Execute(null)); + + var ornamentsMenu = new ToolStripMenuItem("Ornaments"); + ornamentsMenu.DropDownItems.Add("Grace Note", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.GraceNote))); + ornamentsMenu.DropDownItems.Add("Trill", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Trill))); + ornamentsMenu.DropDownItems.Add("Turn", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Turn))); + ornamentsMenu.DropDownItems.Add("Fermata", null, (s, e) => ExecuteScoreEdit(() => _viewModel.OrnamentEditor.AddOrnament(OrnamentType.Fermata))); + menu.Items.Add(ornamentsMenu); + + AddPasteItemIfAvailable(menu); + } + + private void AddGapContextMenuItems(ContextMenuStrip menu) + { + var insertMenu = new ToolStripMenuItem("Insert Note"); + for (var pitch = 1; pitch <= 7; pitch++) + { + var capturedPitch = pitch; + insertMenu.DropDownItems.Add(pitch.ToString(), null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.AddNote(capturedPitch))); + } + + menu.Items.Add(insertMenu); + menu.Items.Add("Insert Rest", null, (s, e) => ExecuteNoteEdit(() => _viewModel.NoteEditor.AddRest())); + AddPasteItemIfAvailable(menu); + } + + private void AddChordContextMenuItems(ContextMenuStrip menu) + { + if (_viewModel.Selection.HasChordSelected) + { + menu.Items.Add("Delete Chord Marker", null, (s, e) => ExecuteDelete()); + } + + menu.Items.Add("Add Chord Marker Here", null, (s, e) => ExecuteScoreEdit(() => _viewModel.ChordEditor.AddChordMarker())); + } + + private void AddMeasureContextMenuItems(ContextMenuStrip menu) + { + menu.Items.Add("Add Measure", null, (s, e) => ExecuteAddMeasure()); + menu.Items.Add("Duplicate Measure(s)", null, (s, e) => ExecuteDuplicateMeasures()); + AddPasteItemIfAvailable(menu); + } + + private void AddPasteItemIfAvailable(ContextMenuStrip menu) + { + if (!_viewModel.NoteEditor.HasClipboardContent) + { + return; + } + + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Paste", null, (s, e) => ExecutePaste()); + } + private void ExecuteUndo() { if (_isExecutingHistoryChange || !_commandHistory.CanUndo) @@ -1177,6 +1315,27 @@ private void OnFormKeyDown(object sender, KeyEventArgs e) { ExecuteDelete(); e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.X) + { + ExecuteCut(); + e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.C) + { + ExecuteCopy(); + e.Handled = true; + return; + } + + if (e.Control && e.KeyCode == Keys.V) + { + ExecutePaste(); + e.Handled = true; } } diff --git a/JianpuEditor/Services/NoteClipboardService.cs b/JianpuEditor/Services/NoteClipboardService.cs new file mode 100644 index 0000000..7ed9671 --- /dev/null +++ b/JianpuEditor/Services/NoteClipboardService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using JianpuEditor.Core.Abstractions; +using JianpuEditor.Models; + +namespace JianpuEditor.Services +{ + internal sealed class NoteClipboardService : INoteClipboardService + { + private List _notes = new List(); + + public bool HasNotes + { + get { return _notes.Count > 0; } + } + + public void SetNotes(IReadOnlyList notes) + { + _notes = Clone(notes); + } + + public IReadOnlyList GetNotes() + { + return Clone(_notes); + } + + private static List Clone(IReadOnlyList notes) + { + var clone = new List(); + if (notes == null) + { + return clone; + } + + for (var i = 0; i < notes.Count; i++) + { + var source = notes[i]; + clone.Add(new JianpuNote + { + Type = source.Type, + Pitch = source.Pitch, + Accidental = source.Accidental, + Octave = source.Octave, + Underlines = source.Underlines, + Dashes = source.Dashes, + Dotted = source.Dotted + }); + } + + return clone; + } + } +} diff --git a/JianpuEditor/ViewModels/NoteEditorViewModel.cs b/JianpuEditor/ViewModels/NoteEditorViewModel.cs index 285136d..0323ee3 100644 --- a/JianpuEditor/ViewModels/NoteEditorViewModel.cs +++ b/JianpuEditor/ViewModels/NoteEditorViewModel.cs @@ -19,6 +19,7 @@ public sealed class NoteEditorViewModel : ObservableObject private readonly MeasureNavigationViewModel _navigation; private readonly IAppMessenger _messenger; private readonly IEditCommandHistory _history; + private readonly INoteClipboardService _clipboard; private JianpuNote _pendingNote = CreateDefaultNote(); public NoteEditorViewModel( @@ -26,13 +27,15 @@ public NoteEditorViewModel( ScoreSelectionViewModel selection, MeasureNavigationViewModel navigation, IAppMessenger messenger, - IEditCommandHistory history) + IEditCommandHistory history, + INoteClipboardService clipboard) { _document = document ?? throw new ArgumentNullException(nameof(document)); _selection = selection ?? throw new ArgumentNullException(nameof(selection)); _navigation = navigation ?? throw new ArgumentNullException(nameof(navigation)); _messenger = messenger ?? throw new ArgumentNullException(nameof(messenger)); _history = history ?? throw new ArgumentNullException(nameof(history)); + _clipboard = clipboard ?? throw new ArgumentNullException(nameof(clipboard)); AddNoteCommand = new RelayCommand(pitch => AddNote(pitch)); AddRestCommand = new RelayCommand(() => AddRest()); @@ -264,6 +267,69 @@ public ScoreEditResult MergeSelectedNotes() "Merge notes")); } + public bool HasClipboardContent + { + get { return _clipboard.HasNotes; } + } + + /// Copies the selected note(s) to the shared clipboard. Returns false (no-op, + /// nothing copied) when no note is selected, so callers like Cut know not to follow up + /// with a delete. + public bool CopySelectedNotes() + { + var refs = GetOrderedSelectedNoteRefs(); + if (refs.Count == 0) + { + _messenger.Send(new StatusChangedMessage("Select a note to copy first")); + return false; + } + + _document.EnsureMeasures(); + var notes = new List(); + foreach (var noteRef in refs) + { + if (noteRef.MeasureIndex < 0 || noteRef.MeasureIndex >= _document.Score.Measures.Count) + { + continue; + } + + var measureNotes = _document.Score.Measures[noteRef.MeasureIndex].MelodyNotes; + if (noteRef.NoteIndex < 0 || noteRef.NoteIndex >= measureNotes.Count) + { + continue; + } + + notes.Add(measureNotes[noteRef.NoteIndex]); + } + + if (notes.Count == 0) + { + return false; + } + + _clipboard.SetNotes(notes); + _messenger.Send(new StatusChangedMessage(notes.Count > 1 ? "Copied " + notes.Count + " notes" : "Copied note")); + return true; + } + + public ScoreEditResult PasteNotes() + { + var clipboardNotes = _clipboard.GetNotes(); + if (clipboardNotes.Count == 0) + { + _messenger.Send(new StatusChangedMessage("Nothing to paste")); + return ScoreEditResult.Unchanged; + } + + _document.EnsureMeasures(); + var message = clipboardNotes.Count > 1 ? "Pasted " + clipboardNotes.Count + " notes" : "Pasted note"; + return ExecuteCommand(new MeasuresMelodySnapshotCommand( + _document.Score, + _messenger, + () => ApplyPasteNotes(clipboardNotes), + message)); + } + public ScoreEditResult TransposePitch(int delta) { var selectedNotes = GetSelectedNotes(); @@ -571,6 +637,57 @@ private static ScoreEditResult BuildEditResult( }; } + private ScoreEditResult ApplyPasteNotes(IReadOnlyList clipboardNotes) + { + var (measureIndex, insertIndex) = ResolvePasteInsertPoint(); + if (measureIndex < 0 || measureIndex >= _document.Score.Measures.Count) + { + return ScoreEditResult.Unchanged; + } + + var measure = _document.Score.Measures[measureIndex]; + for (var i = 0; i < clipboardNotes.Count; i++) + { + MelodyChordService.InsertSlot(measure, insertIndex + i, clipboardNotes[i]); + } + + return new ScoreEditResult + { + Changed = true, + SelectNoteMeasureIndex = measureIndex, + SelectNoteIndex = insertIndex + clipboardNotes.Count - 1 + }; + } + + /// Pastes into the selected gap, right after the last selected note (in + /// ascending measure/note order), or at the end of the current measure if nothing more + /// specific is selected -- mirrors 's own fallback. + private (int measureIndex, int insertIndex) ResolvePasteInsertPoint() + { + if (_selection.HasGapSelected) + { + var gapMeasureIndex = Math.Max(0, _selection.MeasureIndex); + return (gapMeasureIndex, _selection.InsertIndex); + } + + var refs = GetOrderedSelectedNoteRefs(); + if (refs.Count > 0) + { + var last = refs[refs.Count - 1]; + return (last.MeasureIndex, last.NoteIndex + 1); + } + + var measureIndex = GetCurrentMeasureIndex(); + return (measureIndex, _document.Score.Measures[measureIndex].MelodyNotes.Count); + } + + private List GetOrderedSelectedNoteRefs() + { + var refs = GetSelectedNoteRefs(); + refs.Sort((a, b) => ScoreNoteRef.Compare(a, b)); + return refs; + } + private List GetSelectedNoteRefs() { var result = new List(); diff --git a/README.md b/README.md index 86d37f2..aaeaaf0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,13 @@ A Jianpu (numbered musical notation) editing tool built on C# WinForms, supporti - **Shift + Left click**: Batch add/remove the range from the anchor to the current note (can span measures) - When multi-selecting across measures, the toolbar's "From / To" measure range automatically syncs the highlight - With multiple selection, toolbar ornament/delete operations etc. apply in batch +- **Cut / Copy / Paste** + - **Ctrl+X** / **Ctrl+C** / **Ctrl+V** (also on the Edit menu and the right-click menu) cut, copy, or paste the selected note(s) + - Paste inserts after the last selected note, or at the selected gap, or at the end of the current measure; works across tabs and across open scores + - An in-app clipboard (not the OS clipboard), separate from the tie/chord/lyric/ornament data attached to the copied notes -- those are not carried over +- **Right-click context menu** + - Context-aware: shows different commands depending on what's under the cursor -- a note (Cut/Copy/Delete, Shorten/Extend, octave/transpose, Split/Merge, Tie, Ornaments), an empty gap (Insert Note/Rest, Paste), a tie (Remove Tie), a chord marker (Add/Delete), lyric text (Align Lyrics), or empty space (Add Measure, Duplicate Measure(s), Paste, and the existing "Move playback marker here") + - Right-clicking a note or chord marker that isn't already selected selects it first, so the menu always acts on what you clicked - **Duration modification** - **Increase (+) / Decrease (-) duration**: cycles through six levels: 1/16 → 1/8 → 1/4 → extend 1 beat → extend 2 beats → extend 3 beats - High octave dot / low octave dot / dot (dotted note)