Выделение и вставка

This commit is contained in:
2026-03-29 17:53:11 +03:00
parent 3a61e0a07d
commit e7546f9d12
9 changed files with 354 additions and 242 deletions

View File

@@ -13,6 +13,11 @@ using Minint.Core.Services.Impl;
namespace Minint.ViewModels;
/// <summary>
/// Palette-independent clipboard fragment: stores resolved RGBA pixels.
/// </summary>
public sealed record ClipboardFragment(int Width, int Height, RgbaColor[] Pixels);
public partial class EditorViewModel : ViewModelBase
{
private readonly ICompositor _compositor = new Compositor();
@@ -66,6 +71,27 @@ public partial class EditorViewModel : ViewModelBase
public RgbaColor SelectedColor => new(_previewColor.R, _previewColor.G, _previewColor.B, _previewColor.A);
// Selection state (Select tool rubber-band)
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasSelection))]
private (int X, int Y, int W, int H)? _selectionRect;
public bool HasSelection => SelectionRect is not null;
// Clipboard
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasClipboard))]
private ClipboardFragment? _clipboard;
public bool HasClipboard => Clipboard is not null;
// Paste mode
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPasting))]
private (int X, int Y)? _pastePosition;
public bool IsPasting => PastePosition is not null;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Title))]
private string? _filePath;
@@ -113,10 +139,6 @@ public partial class EditorViewModel : ViewModelBase
SyncLayersAndCanvas(doc);
}
/// <summary>
/// Re-syncs the Documents observable collection after an external modification
/// to Container.Documents (e.g. pattern generation adding a document).
/// </summary>
public void SyncAfterExternalChange() => SyncDocumentsList();
private void SyncDocumentsList()
@@ -191,7 +213,7 @@ public partial class EditorViewModel : ViewModelBase
private void RemoveDocument()
{
if (Container is null || ActiveDocument is null) return;
if (Container.Documents.Count <= 1) return; // keep at least one
if (Container.Documents.Count <= 1) return;
var doc = ActiveDocument;
int idx = Container.Documents.IndexOf(doc);
@@ -203,10 +225,7 @@ public partial class EditorViewModel : ViewModelBase
}
[RelayCommand]
private void RenameDocument()
{
// Triggered via UI text edit — the Name property is directly editable via TextBox
}
private void RenameDocument() { }
[RelayCommand]
private void MoveDocumentUp()
@@ -254,7 +273,7 @@ public partial class EditorViewModel : ViewModelBase
private void RemoveLayer()
{
if (ActiveDocument is null || ActiveLayer is null) return;
if (ActiveDocument.Layers.Count <= 1) return; // keep at least one
if (ActiveDocument.Layers.Count <= 1) return;
UnsubscribeLayerVisibility();
var layer = ActiveLayer;
@@ -329,6 +348,8 @@ public partial class EditorViewModel : ViewModelBase
if (px < 0 || px >= w || py < 0 || py >= h)
return;
if (ActiveTool == ToolType.Select) return; // handled separately
switch (ActiveTool)
{
case ToolType.Brush:
@@ -353,7 +374,7 @@ public partial class EditorViewModel : ViewModelBase
public void OnToolDrag(int px, int py)
{
if (ActiveTool == ToolType.Fill) return;
if (ActiveTool is ToolType.Fill or ToolType.Select) return;
OnToolDown(px, py);
}
@@ -361,7 +382,7 @@ public partial class EditorViewModel : ViewModelBase
{
if (PreviewCenter is null || Container is null)
return null;
if (ActiveTool == ToolType.Fill)
if (ActiveTool is ToolType.Fill or ToolType.Select)
return null;
var (cx, cy) = PreviewCenter.Value;
@@ -369,36 +390,161 @@ public partial class EditorViewModel : ViewModelBase
}
[RelayCommand]
private void SelectBrush() => ActiveTool = ToolType.Brush;
private void SelectBrush() { CancelPasteMode(); ActiveTool = ToolType.Brush; }
[RelayCommand]
private void SelectEraser() => ActiveTool = ToolType.Eraser;
private void SelectEraser() { CancelPasteMode(); ActiveTool = ToolType.Eraser; }
[RelayCommand]
private void SelectFill() => ActiveTool = ToolType.Fill;
private void SelectFill() { CancelPasteMode(); ActiveTool = ToolType.Fill; }
[RelayCommand]
private void SelectSelectTool() { CancelPasteMode(); ActiveTool = ToolType.Select; }
#endregion
#region Fragment copy (A4)
#region Selection + Copy/Paste (A4)
/// <summary>
/// Copies a rectangular fragment from <paramref name="srcDoc"/> active layer
/// to <paramref name="dstDoc"/> active layer.
/// </summary>
public void CopyFragment(
MinintDocument srcDoc, int srcLayerIndex,
int srcX, int srcY, int regionW, int regionH,
MinintDocument dstDoc, int dstLayerIndex,
int dstX, int dstY)
/// <summary>Called by PixelCanvas when selection drag starts.</summary>
public void BeginSelection(int px, int py)
{
if (Container is null) return;
_fragmentService.CopyFragment(
srcDoc, srcLayerIndex, srcX, srcY, regionW, regionH,
dstDoc, dstLayerIndex, dstX, dstY,
Container.Width, Container.Height);
SelectionRect = (px, py, 0, 0);
}
/// <summary>Called by PixelCanvas as the user drags.</summary>
public void UpdateSelection(int px, int py)
{
if (SelectionRect is null) return;
var s = SelectionRect.Value;
int x = Math.Min(s.X, px);
int y = Math.Min(s.Y, py);
int w = Math.Abs(px - s.X) + 1;
int h = Math.Abs(py - s.Y) + 1;
// Store normalized rect but keep original anchor in _selAnchor
_selectionRectNormalized = (x, y, w, h);
}
/// <summary>Called by PixelCanvas when mouse is released.</summary>
public void FinishSelection(int px, int py)
{
if (SelectionRect is null) return;
var s = SelectionRect.Value;
int x0 = Math.Min(s.X, px);
int y0 = Math.Min(s.Y, py);
int rw = Math.Abs(px - s.X) + 1;
int rh = Math.Abs(py - s.Y) + 1;
if (Container is not null)
{
x0 = Math.Max(0, x0);
y0 = Math.Max(0, y0);
rw = Math.Min(rw, Container.Width - x0);
rh = Math.Min(rh, Container.Height - y0);
}
if (rw <= 0 || rh <= 0)
{
SelectionRect = null;
_selectionRectNormalized = null;
return;
}
SelectionRect = (x0, y0, rw, rh);
_selectionRectNormalized = SelectionRect;
}
private (int X, int Y, int W, int H)? _selectionRectNormalized;
/// <summary>The normalized (positive W/H, clamped) selection rectangle for rendering.</summary>
public (int X, int Y, int W, int H)? SelectionRectNormalized => _selectionRectNormalized;
[RelayCommand]
private void CopySelection()
{
if (SelectionRect is null || ActiveDocument is null || ActiveLayer is null || Container is null)
return;
var (sx, sy, sw, sh) = SelectionRect.Value;
int cw = Container.Width;
var palette = ActiveDocument.Palette;
var srcPixels = ActiveLayer.Pixels;
var buf = new RgbaColor[sw * sh];
for (int dy = 0; dy < sh; dy++)
{
int srcRow = sy + dy;
for (int dx = 0; dx < sw; dx++)
{
int srcCol = sx + dx;
int idx = srcPixels[srcRow * cw + srcCol];
buf[dy * sw + dx] = idx < palette.Count ? palette[idx] : RgbaColor.Transparent;
}
}
Clipboard = new ClipboardFragment(sw, sh, buf);
}
[RelayCommand]
private void PasteClipboard()
{
if (Clipboard is null) return;
PastePosition = (0, 0);
}
public void MovePaste(int px, int py)
{
if (!IsPasting) return;
PastePosition = (px, py);
}
[RelayCommand]
public void CommitPaste()
{
if (!IsPasting || Clipboard is null || ActiveDocument is null || ActiveLayer is null || Container is null)
return;
var (px, py) = PastePosition!.Value;
int cw = Container.Width, ch = Container.Height;
var frag = Clipboard;
var dstPixels = ActiveLayer.Pixels;
for (int fy = 0; fy < frag.Height; fy++)
{
int dy = py + fy;
if (dy < 0 || dy >= ch) continue;
for (int fx = 0; fx < frag.Width; fx++)
{
int dx = px + fx;
if (dx < 0 || dx >= cw) continue;
var color = frag.Pixels[fy * frag.Width + fx];
if (color.A == 0) continue; // skip transparent
int colorIdx = ActiveDocument.EnsureColorCached(color);
dstPixels[dy * cw + dx] = colorIdx;
}
}
PastePosition = null;
SelectionRect = null;
_selectionRectNormalized = null;
RefreshCanvas();
}
[RelayCommand]
public void CancelPaste()
{
PastePosition = null;
}
public void ClearSelection()
{
SelectionRect = null;
_selectionRectNormalized = null;
}
private void CancelPasteMode()
{
PastePosition = null;
SelectionRect = null;
_selectionRectNormalized = null;
}
#endregion
#region Canvas rendering