Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3f80eb9
refactor: introduce Beutl.Editor.Services interfaces for editing pipe…
claude May 21, 2026
be9f01a
refactor: implement SceneTimeRange and ElementResize services
claude May 21, 2026
65f34f2
refactor: implement ElementMove, ElementDuplicate, ElementClipboard s…
claude May 21, 2026
0664548
refactor: implement ElementLifecycle, ElementNudge, LayerMove, KeyFra…
claude May 21, 2026
78777b9
refactor!: route TimelineTabViewModel through editor services
claude May 21, 2026
f511c3e
refactor!: route ElementViewModel through editor services
claude May 21, 2026
7db9f22
refactor!: route Timeline views through drag-session services
claude May 21, 2026
2c6b90a
docs: update Beutl.Editor CLAUDE.md for new service layer
claude May 21, 2026
4447856
refactor!: collapse drag-session services into plain methods
claude May 21, 2026
7cee817
style: add utf-8-bom to remaining Editor.Services files
claude May 21, 2026
2c1c824
fix: address design / pr review findings on editor services
claude May 21, 2026
63cb0c1
fix: address copilot review on element clipboard service
claude May 21, 2026
98f30b2
fix: address codex review on clipboard / paste / drag rollback
claude May 21, 2026
044e892
chore: gitignore .claude/worktrees
claude May 21, 2026
95bb5ec
fix: only commit lifecycle group/ungroup when something changed
claude May 21, 2026
1744a73
fix: keep scene start fixed when dragging end marker
claude May 21, 2026
3202ca4
refactor!: relocate pure-data types from Editor/Services to Editor/Mo…
claude May 21, 2026
32e7b1b
refactor!: consolidate file/folder constants into EditorConstants
claude May 21, 2026
5e5e4d8
refactor: derive BeutlDataFormats from BeutlClipboardFormats
claude May 21, 2026
bae92c6
refactor!: split IElementLifecycleService into structure + attribute
claude May 21, 2026
7b67898
refactor!: own ZIndex writes inside ILayerMoveService.ApplyMove
claude May 21, 2026
f2f201b
refactor!: roll back IKeyFrameMoveService — testability ceremony only
claude May 21, 2026
04a941f
refactor: extract ILayerAttributeService
claude May 21, 2026
abf398c
refactor: extract ISceneSettingsService
claude May 21, 2026
5f84430
refactor: extract IKeyFrameClipboardService
claude May 21, 2026
813e3a8
refactor: extract INodeGraphMutationService
claude May 21, 2026
13ec39e
refactor: extract IElementObjectService
claude May 21, 2026
607a14a
refactor: route MenuBar element ops through existing services
claude May 21, 2026
f89404a
fix: repair build and correctness defects in editor service refactor
yuto-trd May 29, 2026
ad61d13
fix: address PR review on duplicate regen guard and clipboard disposal
yuto-trd May 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ telemetry-data.json
.claude/logs/
.claude/.DS_Store
.claude/.last-self-review
.claude/worktrees/

# `.mcp.json` is team-shared (see docs/ai-workflow/README.md); override any global gitignore
!.mcp.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json.Nodes;
using Beutl.Editor.Models;
using Beutl.Editor.Services;
using Microsoft.Extensions.DependencyInjection;
using Reactive.Bindings;
Expand Down
10 changes: 5 additions & 5 deletions src/Beutl.Editor.Components/BeutlDataFormats.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
using Avalonia.Input;
using Beutl.Editor.Services;
using Beutl.Services;

namespace Beutl.Editor.Components;

public static class BeutlDataFormats
{
private const string ElementFormat = "BeutlElementJson";
private const string ElementsFormat = "BeutlElementsJson";

public static readonly DataFormat<string> Element = DataFormat.CreateStringApplicationFormat(ElementFormat);
public static readonly DataFormat<string> Elements = DataFormat.CreateStringApplicationFormat(ElementsFormat);
// Format strings come from BeutlClipboardFormats so the Avalonia-free
// layer and this Avalonia-typed layer can never drift apart.
public static readonly DataFormat<string> Element = DataFormat.CreateStringApplicationFormat(BeutlClipboardFormats.Element);
public static readonly DataFormat<string> Elements = DataFormat.CreateStringApplicationFormat(BeutlClipboardFormats.Elements);
public static readonly DataFormat<string> KeyFrame = DataFormat.CreateStringApplicationFormat(nameof(Animation.KeyFrame));
public static readonly DataFormat<string> KeyFrameAnimation = DataFormat.CreateStringApplicationFormat(nameof(Animation.KeyFrameAnimation));
public static readonly DataFormat<string> EngineObject = DataFormat.CreateStringApplicationFormat(KnownLibraryItemFormats.EngineObject);
Expand Down
11 changes: 0 additions & 11 deletions src/Beutl.Editor.Components/Constants.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Specialized;
using System.Text.Json.Nodes;

using Beutl.Editor;
using Beutl.Editor.Services;
using Beutl.Engine;
using Beutl.ProjectSystem;
Expand Down Expand Up @@ -139,7 +140,7 @@ private static string ViewStateDirectory(Element element)
{
string directory = Path.GetDirectoryName(element.Uri!.LocalPath)!;

directory = Path.Combine(directory, Constants.BeutlFolder, Constants.ViewStateFolder);
directory = Path.Combine(directory, EditorConstants.BeutlFolder, EditorConstants.ViewStateFolder);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,8 @@ public EngineObjectPropertyViewModel(EngineObject model, ElementPropertyTabViewM
.ToReactiveProperty();
IsEnabled.Skip(1).Subscribe(v =>
{
HistoryManager? history = this.GetService<HistoryManager>();
if (history != null)
{
Model.IsEnabled = v;
history.Commit(CommandNames.ChangeObjectEnabled);
}
IElementObjectService? service = this.GetService<IElementObjectService>();
service?.SetEnabled(Model, v);
});

Init();
Expand Down Expand Up @@ -154,23 +150,13 @@ public void SetJsonString(string? str)
if (index < 0) return;

string message = MessageStrings.InvalidJson;
_ = str ?? throw new Exception(message);
JsonObject json = (JsonNode.Parse(str) as JsonObject) ?? throw new Exception(message);
if (str is null) throw new Exception(message);

Type? type = json.GetDiscriminator();
EngineObject? obj = null;
if (type?.IsAssignableTo(typeof(EngineObject)) ?? false)
ObjectPasteOutcome outcome = this.GetRequiredService<IElementObjectService>()
.PasteOver(element, index, str);
if (outcome != ObjectPasteOutcome.Pasted)
{
obj = Activator.CreateInstance(type) as EngineObject;
throw new Exception(message);
}

if (obj == null) throw new Exception(message);

CoreSerializer.PopulateFromJsonObject(obj, type!, json);

HistoryManager history = this.GetRequiredService<HistoryManager>();

element.Objects[index] = obj;
history.Commit(CommandNames.PasteObject);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Avalonia.Controls;
using Avalonia.Input;
using Beutl.Editor.Components.ElementPropertyTab.ViewModels;
using Beutl.Editor.Services;
using Beutl.Engine;
using Beutl.ProjectSystem;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -58,9 +59,8 @@ private void Drop(object? sender, DragEventArgs e)
&& DataContext is ElementPropertyTabViewModel vm
&& vm.Element.Value is Element element)
{
HistoryManager history = vm.GetRequiredService<HistoryManager>();
element.AddObject((EngineObject)Activator.CreateInstance(item)!);
history.Commit(CommandNames.AddObject);
vm.GetRequiredService<IElementObjectService>()
.Add(element, (EngineObject)Activator.CreateInstance(item)!);

e.Handled = true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,9 @@ public void Remove_Click(object? sender, RoutedEventArgs e)
{
if (DataContext is EngineObjectPropertyViewModel viewModel2)
{
HistoryManager history = viewModel2.GetRequiredService<HistoryManager>();
EngineObject obj = viewModel2.Model;
Element element = obj.FindRequiredHierarchicalParent<Element>();
element.RemoveObject(obj);
history.Commit(CommandNames.RemoveObject);
viewModel2.GetRequiredService<IElementObjectService>().Remove(element, obj);
}
}

Expand All @@ -66,24 +64,16 @@ private void Drop(object? sender, DragEventArgs e)
&& TypeFormat.ToType(typeName) is { } item2
&& DataContext is EngineObjectPropertyViewModel viewModel2)
{
HistoryManager history = viewModel2.GetRequiredService<HistoryManager>();
EngineObject obj = viewModel2.Model;
Element element = obj.FindRequiredHierarchicalParent<Element>();
Rect bounds = Bounds;
Point position = e.GetPosition(this);
double half = bounds.Height / 2;
int index = element.Objects.IndexOf(obj);

if (half < position.Y)
{
element.InsertObject(index + 1, (EngineObject)Activator.CreateInstance(item2)!);
}
else
{
element.InsertObject(index, (EngineObject)Activator.CreateInstance(item2)!);
}

history.Commit(CommandNames.AddObject);
int insertIndex = half < position.Y ? index + 1 : index;
viewModel2.GetRequiredService<IElementObjectService>()
.InsertAt(element, insertIndex, (EngineObject)Activator.CreateInstance(item2)!);

e.Handled = true;
}
Expand Down Expand Up @@ -135,9 +125,8 @@ protected override void OnMoveDraggedItem(ItemsControl? itemsControl, int oldInd
Element.Value: { } element
} viewModel)
{
HistoryManager history = viewModel.GetRequiredService<HistoryManager>();
element.Objects.Move(oldIndex, newIndex);
history.Commit(CommandNames.MoveObject);
viewModel.GetRequiredService<IElementObjectService>()
.Move(element, oldIndex, newIndex);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -372,112 +372,75 @@ private async Task PasteKeyFrameAtPositionAsync(TimeSpan pointerPosition)
private void PasteAnimation(string json)
{
_logger.LogInformation("Pasting JSON");
if (JsonNode.Parse(json) is not JsonObject newJson)
{
_logger.LogError("Invalid JSON");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJson);
return;
}

if (!newJson.TryGetDiscriminator(out Type? discriminator))
{
_logger.LogError("Invalid JSON: missing $type");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_MissingType);
return;
}
KeyFrameAnimation animation = (KeyFrameAnimation)Animation;
IKeyFrameClipboardService service = EditorContext.GetRequiredService<IKeyFrameClipboardService>();
KeyFrameAnimationPasteOutcome outcome = service.PasteAnimation(animation, json);

if (!discriminator.IsAssignableTo(typeof(IKeyFrameAnimation)))
switch (outcome)
{
_logger.LogError("Invalid JSON: $type is not a KeyFrameAnimation");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_TypeIsNotKeyFrameAnimation);
return;
}

try
{
KeyFrameAnimation animation = (KeyFrameAnimation)Animation;

if (discriminator.GenericTypeArguments[0] != animation.ValueType)
{
case KeyFrameAnimationPasteOutcome.Pasted:
break;
case KeyFrameAnimationPasteOutcome.InvalidJson:
_logger.LogError("Invalid JSON");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJson);
break;
case KeyFrameAnimationPasteOutcome.MissingType:
_logger.LogError("Invalid JSON: missing $type");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_MissingType);
break;
case KeyFrameAnimationPasteOutcome.TypeIsNotKeyFrameAnimation:
_logger.LogError("Invalid JSON: $type is not a KeyFrameAnimation");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_TypeIsNotKeyFrameAnimation);
break;
case KeyFrameAnimationPasteOutcome.GenericTypeMismatch:
_logger.LogError("The property type of the pasted animation does not match.");
NotificationService.ShowError(Strings.GraphEditor, string.Format(MessageStrings.AnimationPropertyTypeMismatch, animation.ValueType.Name, discriminator.GenericTypeArguments[0].Name));
return;
}

Guid id = animation.Id;
CoreSerializer.PopulateFromJsonObject(animation, newJson);
animation.Id = id;
foreach (IKeyFrame item in animation.KeyFrames)
{
item.Id = Guid.NewGuid();
}
HistoryManager.Commit(CommandNames.PasteAnimation);
}
catch (Exception ex)
{
_logger.LogError(ex, "An exception occurred while pasting JSON.");
NotificationService.ShowError(Strings.GraphEditor, ex.Message);
NotificationService.ShowError(
Strings.GraphEditor,
string.Format(MessageStrings.AnimationPropertyTypeMismatch, animation.ValueType.Name, "?"));
break;
case KeyFrameAnimationPasteOutcome.UnexpectedError:
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.FailedToPasteKeyframe);
break;
}
}

private void PasteKeyFrame(string json, TimeSpan pointerPosition)
{
_logger.LogInformation("Pasting JSON");
if (JsonNode.Parse(json) is not JsonObject newJson)
{
_logger.LogError("Invalid JSON");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJson);
return;
}
KeyFrameAnimation animation = (KeyFrameAnimation)Animation;
TimeSpan keyTime = ConvertKeyTime(pointerPosition);
IKeyFrameClipboardService service = EditorContext.GetRequiredService<IKeyFrameClipboardService>();
KeyFramePasteResult result = service.PasteKeyFrame(animation, json, keyTime);

if (!newJson.TryGetDiscriminator(out Type? discriminator))
switch (result.Outcome)
{
_logger.LogError("Invalid JSON: missing $type");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_MissingType);
return;
}

if (!discriminator.IsAssignableTo(typeof(KeyFrame)))
{
_logger.LogError("Invalid JSON: $type is not a KeyFrame");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_TypeIsNotKeyFrame);
return;
}

try
{
KeyFrameAnimation animation = (KeyFrameAnimation)Animation;

KeyFrame newKeyFrame = (KeyFrame)Activator.CreateInstance(discriminator)!;
CoreSerializer.PopulateFromJsonObject(newKeyFrame, newJson);

if (discriminator.GenericTypeArguments[0] != animation.ValueType)
{
InsertKeyFrame(newKeyFrame.Easing, pointerPosition);
NotificationService.ShowWarning(Strings.GraphEditor, MessageStrings.KeyframePropertyTypeMismatch_EasingApplied);
return;
}

var keyTime = ConvertKeyTime(pointerPosition);
if (animation.KeyFrames.FirstOrDefault(k => k.KeyTime == keyTime) is { } existingKeyFrame)
{
// イージングと値を変更
existingKeyFrame.Easing = newKeyFrame.Easing;
existingKeyFrame.Value = ((IKeyFrame)newKeyFrame).Value;
HistoryManager.Commit(CommandNames.PasteKeyFrame);
case KeyFramePasteOutcome.Inserted:
break;
case KeyFramePasteOutcome.ReplacedExisting:
NotificationService.ShowWarning(Strings.GraphEditor, MessageStrings.KeyframeExistsAtPastePosition);
}
else
{
newKeyFrame.KeyTime = keyTime;
animation.KeyFrames.Add((IKeyFrame)newKeyFrame, out _);
HistoryManager.Commit(CommandNames.PasteKeyFrame);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "An exception occurred while pasting JSON.");
NotificationService.ShowError(Strings.GraphEditor, ex.Message);
break;
case KeyFramePasteOutcome.GenericTypeMismatch when result.EasingForFallback is { } easing:
// Type does not match — fall back to creating a new keyframe at
// pointerPosition using the View's typed insert path, applying
// only the easing the clipboard carried.
InsertKeyFrame(easing, pointerPosition);
NotificationService.ShowWarning(Strings.GraphEditor, MessageStrings.KeyframePropertyTypeMismatch_EasingApplied);
break;
case KeyFramePasteOutcome.InvalidJson:
_logger.LogError("Invalid JSON");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJson);
break;
case KeyFramePasteOutcome.MissingType:
_logger.LogError("Invalid JSON: missing $type");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_MissingType);
break;
case KeyFramePasteOutcome.TypeIsNotKeyFrame:
_logger.LogError("Invalid JSON: $type is not a KeyFrame");
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.InvalidJSON_TypeIsNotKeyFrame);
break;
case KeyFramePasteOutcome.UnexpectedError:
NotificationService.ShowError(Strings.GraphEditor, MessageStrings.FailedToPasteKeyframe);
break;
}
}
}
Loading
Loading