Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions src/TextForge.Core/Documents/Document.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Module> modules, bool isSelected)
{
foreach (var mod in modules)
Expand Down
41 changes: 41 additions & 0 deletions src/TextForge.Core/Documents/DocumentOperations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using TextForge.Core.Modules;

namespace TextForge.Core;

public static class DocumentOperations
{
/// <summary>
/// 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.
/// </summary>
public static Module Duplicate(IList<Module> 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;
}
}
85 changes: 77 additions & 8 deletions src/TextForge.Core/Modules/Module.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents an independent content element within a document.
/// Modules can be nested hierarchically to form compound structures.
/// </summary>
public class Module : INotifyPropertyChanged
{
private string _content = string.Empty;
private bool _isSelected;
private bool _isExpanded;
private Module? _parent;

public event PropertyChangedEventHandler? PropertyChanged;

Expand All @@ -26,6 +23,24 @@ public class Module : INotifyPropertyChanged

public ModuleType Type { get; init; } = ModuleType.Text;

/// <summary>
/// Reference to the containing parent module. Null if this is a root-level module.
/// Ignored during serialization to avoid cyclic object graphs.
/// </summary>
[JsonIgnore]
public Module? Parent
{
get => _parent;
internal set
{
if (_parent != value)
{
_parent = value;
OnPropertyChanged();
}
}
}

public string Content
{
get => _content;
Expand All @@ -41,7 +56,7 @@ public string Content

public ModuleFeatures Features { get; set; } = ModuleFeatures.Default;

public ObservableCollection<Module> SubModules { get; set; } = [];
public ObservableCollection<Module> SubModules { get; } = [];

public bool IsSelected
{
Expand Down Expand Up @@ -69,7 +84,10 @@ public bool IsExpanded
}
}

public Module() { }
public Module()
{
WireSubModulesCollection();
}

public Module(
string content,
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/TextForge.Core/Modules/ModuleFeatures.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ public ModuleFeatures MergeWith(ModuleFeatures? fallback)
LineSpacing = LineSpacing ?? fallback.LineSpacing
};
}

}
7 changes: 7 additions & 0 deletions src/TextForge.Core/model/IModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace TextForge.Core.Models;

public interface IModule
{
Guid Id { get; }
IModule Clone();
}
100 changes: 58 additions & 42 deletions src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mod="using:TextForge.Core.Modules"
x:Class="TextForge.Desktop.Views.Components.ModuleEditorView">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mod="using:TextForge.Core.Modules"
x:Class="TextForge.Desktop.Views.Components.ModuleEditorView">

<UserControl.Styles>
<Style Selector="ListBoxItem">
Expand Down Expand Up @@ -39,75 +39,91 @@
<!-- Self-Referencing Recursive Template for Modules & Submodules at any depth -->
<DataTemplate x:Key="RecursiveModuleTemplate" x:DataType="mod:Module">
<Border Classes="module-card"
Tag="{Binding}"
Margin="0,1"
Padding="2">
Tag="{Binding}"
Margin="0,1"
Padding="2">
<Expander IsExpanded="{Binding IsExpanded, Mode=TwoWay, FallbackValue=False}"
HorizontalAlignment="Stretch"
Background="Transparent">
HorizontalAlignment="Stretch"
Background="Transparent">
<Expander.Header>
<Grid ColumnDefinitions="Auto,*,Auto"
VerticalAlignment="Center"
Background="Transparent">
VerticalAlignment="Center"
Background="Transparent">
<!-- Archetype Tag -->
<Border Grid.Column="0"
Background="#E5E7EB"
CornerRadius="4"
Padding="6,2"
Margin="0,0,8,0">
Background="#E5E7EB"
CornerRadius="4"
Padding="6,2"
Margin="0,0,8,0">
<TextBlock Text="{Binding Name}"
FontSize="11"
FontWeight="SemiBold"
Foreground="#374151" />
FontSize="11"
FontWeight="SemiBold"
Foreground="#374151" />
</Border>

<!-- Content Preview -->
<TextBlock Grid.Column="1"
Text="{Binding Content}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
FontSize="12"
Foreground="#1F2937" />
Text="{Binding Content}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"
FontSize="12"
Foreground="#1F2937" />

<!-- Action Buttons -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2" Margin="4,0,0,0">
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="2"
Margin="4,0,0,0">
<Button Content="▲" FontSize="10" Width="22" Height="22" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="Transparent" Foreground="#6B7280" ToolTip.Tip="Move Up"
Tag="{Binding}" Click="MoveUpButton_Click" />
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent" Foreground="#6B7280"
ToolTip.Tip="Move Up"
Tag="{Binding}" Click="MoveUpButton_Click" />
<Button Content="▼" FontSize="10" Width="22" Height="22" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="Transparent" Foreground="#6B7280" ToolTip.Tip="Move Down"
Tag="{Binding}" Click="MoveDownButton_Click" />
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent" Foreground="#6B7280"
ToolTip.Tip="Move Down"
Tag="{Binding}" Click="MoveDownButton_Click" />
<Button Content="✕" FontSize="11" Width="22" Height="22" Padding="0"
HorizontalContentAlignment="Center" VerticalContentAlignment="Center"
Background="Transparent" Foreground="#EF4444" ToolTip.Tip="Delete Module"
Tag="{Binding}" Click="DeleteModuleButton_Click" />
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent" Foreground="#EF4444"
ToolTip.Tip="Delete Module"
Tag="{Binding}" Click="DeleteModuleButton_Click" />
<Button Content="+" FontSize="14" FontWeight="Bold" Width="22"
Height="22" Padding="0"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
Background="Transparent" Foreground="#10B981"
ToolTip.Tip="Duplicate Module"
Tag="{Binding}" Click="DuplicateModuleButton_Click" />

</StackPanel>
</Grid>
</Expander.Header>

<StackPanel Spacing="6" Margin="4,6,4,4">
<TextBox Text="{Binding Content, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="45" />
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="45" />

<!-- Recursive ItemsControl: uses the exact same template for child submodules -->
<!-- Recursive ItemsControl: uses the exact same template for child
submodules -->
<ItemsControl ItemsSource="{Binding SubModules}"
ItemTemplate="{StaticResource RecursiveModuleTemplate}"
Margin="14,2,0,0" />
ItemTemplate="{StaticResource RecursiveModuleTemplate}"
Margin="14,2,0,0" />
</StackPanel>
</Expander>
</Border>
</DataTemplate>
</UserControl.Resources>

<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
HorizontalScrollBarVisibility="Disabled">
<ListBox Name="ModuleListBox"
Background="Transparent"
SelectionMode="Single"
ItemTemplate="{StaticResource RecursiveModuleTemplate}" />
Background="Transparent"
SelectionMode="Single"
ItemTemplate="{StaticResource RecursiveModuleTemplate}" />
</ScrollViewer>
</UserControl>
11 changes: 11 additions & 0 deletions src/TextForge.Desktop/Views/Components/ModuleEditorView.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using TextForge.Core;
using TextForge.Core.Documents;
using TextForge.Core.Modules;

Expand All @@ -12,6 +13,7 @@ public partial class ModuleEditorView : UserControl
public event EventHandler<Module>? ModuleMoveUpRequested;
public event EventHandler<Module>? ModuleMoveDownRequested;
public event EventHandler<Module>? ModuleDeleteRequested;
public event EventHandler<Module>? ModuleDuplicateRequested;

public ModuleEditorView()
{
Expand Down Expand Up @@ -41,6 +43,15 @@ private void DeleteModuleButton_Click(object? sender, RoutedEventArgs e)
ModuleDeleteRequested?.Invoke(this, module);
}
}

private void DuplicateModuleButton_Click(object? sender, RoutedEventArgs e)
{
if (sender is Button { Tag: Module module })
{
ModuleDuplicateRequested?.Invoke(this, module);
}
}

/// <summary>
/// Binds the editor to a document instance.
/// Done once per document load.
Expand Down
10 changes: 10 additions & 0 deletions src/TextForge.Desktop/Views/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ private void BindModuleList()
moduleEditor.ModuleMoveUpRequested += ModuleEditor_ModuleMoveUpRequested;
moduleEditor.ModuleMoveDownRequested += ModuleEditor_ModuleMoveDownRequested;
moduleEditor.ModuleDeleteRequested += ModuleEditor_ModuleDeleteRequested;
moduleEditor.ModuleDuplicateRequested += ModuleEditor_ModuleDuplicateRequested;


var moduleListBox = moduleEditor.FindControl<ListBox>("ModuleListBox");
if (moduleListBox is not null)
Expand Down Expand Up @@ -196,6 +198,14 @@ private void ModuleEditor_ModuleDeleteRequested(object? sender, Module module)
RefreshModuleEditorList();
}

private void ModuleEditor_ModuleDuplicateRequested(object? sender, Module module)
{
if (_currentDocument?.DuplicateModule(module) is not null)
{
RefreshModuleEditorList();
}
}

#endregion

#region Document & Preview Synchronization
Expand Down
Loading