From 5450e5fc5c7d0c313d8c847ce8c806fabc18c41c Mon Sep 17 00:00:00 2001 From: ahmdkaml Date: Fri, 28 Aug 2026 05:39:28 +0300 Subject: [PATCH] feat(core,desktop): implement module duplication and parent auto-sync --- src/TextForge.Core/Documents/Document.cs | 10 ++ .../Documents/DocumentOperations.cs | 41 +++++++ src/TextForge.Core/Modules/Module.cs | 85 +++++++++++++-- src/TextForge.Core/Modules/ModuleFeatures.cs | 1 + src/TextForge.Core/model/IModule.cs | 7 ++ .../Views/Components/ModuleEditorView.axaml | 100 ++++++++++-------- .../Components/ModuleEditorView.axaml.cs | 11 ++ .../Views/MainWindow.axaml.cs | 10 ++ 8 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 src/TextForge.Core/Documents/DocumentOperations.cs create mode 100644 src/TextForge.Core/model/IModule.cs diff --git a/src/TextForge.Core/Documents/Document.cs b/src/TextForge.Core/Documents/Document.cs index 1363477..80805cf 100644 --- a/src/TextForge.Core/Documents/Document.cs +++ b/src/TextForge.Core/Documents/Document.cs @@ -106,6 +106,16 @@ public void SelectModule(Module? module) NotifyChanged(); } + public Module? DuplicateModule(Module module) + { + var clone = DocumentOperations.Duplicate(Modules, module); + if (clone is not null) + { + NotifyChanged(); // Same notification Move and Remove use internally + } + return clone; + } + private static void SetSelectionRecursive(IEnumerable modules, bool isSelected) { foreach (var mod in modules) diff --git a/src/TextForge.Core/Documents/DocumentOperations.cs b/src/TextForge.Core/Documents/DocumentOperations.cs new file mode 100644 index 0000000..8767133 --- /dev/null +++ b/src/TextForge.Core/Documents/DocumentOperations.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using TextForge.Core.Modules; + +namespace TextForge.Core; + +public static class DocumentOperations +{ + /// + /// Duplicates the target module and inserts it directly after the target in its parent collection + /// or in the root collection if it is a root module. + /// + public static Module Duplicate(IList rootModules, Module target) + { + var clone = target.Clone(); + + if (target.Parent is not null) + { + var parentCollection = target.Parent.SubModules; + int index = parentCollection.IndexOf(target); + if (index >= 0) + { + parentCollection.Insert(index + 1, clone); + return clone; + } + } + + // Target is a root-level module + int rootIndex = rootModules.IndexOf(target); + if (rootIndex >= 0) + { + rootModules.Insert(rootIndex + 1, clone); + } + else + { + rootModules.Add(clone); + } + + return clone; + } +} diff --git a/src/TextForge.Core/Modules/Module.cs b/src/TextForge.Core/Modules/Module.cs index 81ac634..1529cfd 100644 --- a/src/TextForge.Core/Modules/Module.cs +++ b/src/TextForge.Core/Modules/Module.cs @@ -1,20 +1,17 @@ -using System; -using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Text.Json.Serialization; namespace TextForge.Core.Modules; -/// -/// Represents an independent content element within a document. -/// Modules can be nested hierarchically to form compound structures. -/// public class Module : INotifyPropertyChanged { private string _content = string.Empty; private bool _isSelected; private bool _isExpanded; + private Module? _parent; public event PropertyChangedEventHandler? PropertyChanged; @@ -26,6 +23,24 @@ public class Module : INotifyPropertyChanged public ModuleType Type { get; init; } = ModuleType.Text; + /// + /// Reference to the containing parent module. Null if this is a root-level module. + /// Ignored during serialization to avoid cyclic object graphs. + /// + [JsonIgnore] + public Module? Parent + { + get => _parent; + internal set + { + if (_parent != value) + { + _parent = value; + OnPropertyChanged(); + } + } + } + public string Content { get => _content; @@ -41,7 +56,7 @@ public string Content public ModuleFeatures Features { get; set; } = ModuleFeatures.Default; - public ObservableCollection SubModules { get; set; } = []; + public ObservableCollection SubModules { get; } = []; public bool IsSelected { @@ -69,7 +84,10 @@ public bool IsExpanded } } - public Module() { } + public Module() + { + WireSubModulesCollection(); + } public Module( string content, @@ -83,6 +101,57 @@ public Module( StyleKey = styleKey; Features = features ?? ModuleFeatures.Default; Name = name; + WireSubModulesCollection(); + } + + private void WireSubModulesCollection() + { + SubModules.CollectionChanged += OnSubModulesChanged; + } + + private void OnSubModulesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.NewItems != null) + { + foreach (Module child in e.NewItems) + { + child.Parent = this; + } + } + + if (e.OldItems != null) + { + foreach (Module child in e.OldItems) + { + if (child.Parent == this) + { + child.Parent = null; + } + } + } + } + + public Module Clone() + { + var clone = new Module + { + Id = Guid.NewGuid(), + Name = Name, + Type = Type, + StyleKey = StyleKey, + Content = Content, + Features = Features with { }, + IsExpanded = IsExpanded, + IsSelected = false + }; + + foreach (var subModule in SubModules) + { + // Adding automatically assigns clone as the Parent + clone.SubModules.Add(subModule.Clone()); + } + + return clone; } protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) diff --git a/src/TextForge.Core/Modules/ModuleFeatures.cs b/src/TextForge.Core/Modules/ModuleFeatures.cs index a696e92..e285bbd 100644 --- a/src/TextForge.Core/Modules/ModuleFeatures.cs +++ b/src/TextForge.Core/Modules/ModuleFeatures.cs @@ -39,4 +39,5 @@ public ModuleFeatures MergeWith(ModuleFeatures? fallback) LineSpacing = LineSpacing ?? fallback.LineSpacing }; } + } diff --git a/src/TextForge.Core/model/IModule.cs b/src/TextForge.Core/model/IModule.cs new file mode 100644 index 0000000..0748a67 --- /dev/null +++ b/src/TextForge.Core/model/IModule.cs @@ -0,0 +1,7 @@ +namespace TextForge.Core.Models; + +public interface IModule +{ + Guid Id { get; } + IModule Clone(); +} diff --git a/src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml b/src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml index 700349d..098c357 100644 --- a/src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml +++ b/src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml @@ -1,7 +1,7 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:mod="using:TextForge.Core.Modules" + x:Class="TextForge.Desktop.Views.Components.ModuleEditorView">