mirror of
https://gitee.com/chinware/atomui.git
synced 2024-11-29 18:38:16 +08:00
feat: 完成timeline剩余样式和测试
This commit is contained in:
parent
dddab270a2
commit
d15892fa44
@ -23,7 +23,7 @@
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Color="red">
|
||||
<TextBlock>
|
||||
2024-10-01 Release of the 1.0 Preview Version
|
||||
2024-10-01 Release of the 0.0.1 Preview Version
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
</atom:Timeline>
|
||||
@ -60,27 +60,32 @@
|
||||
</desktop:ShowCaseItem>
|
||||
|
||||
<desktop:ShowCaseItem Description="When the timeline is incomplete and ongoing, put a ghost node at last. Set pending as truthy value to enable displaying pending item. You can customize the pending content by passing a React Element. Meanwhile, pendingDot={a React Element} is used to customize the dot of the pending item. reverse={true} is used for reversing nodes." Title="Last node and Reversing">
|
||||
<atom:Timeline Pending="Recording..." Reverse="True">
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 1
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-08-12">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 2
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-10-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 3
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
</atom:Timeline>
|
||||
<StackPanel>
|
||||
<atom:Timeline x:Name="ReverseTimeline" Pending="Recording..." Reverse="True">
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 1
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-08-12">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 2
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-10-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated. 3
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
</atom:Timeline>
|
||||
<DockPanel>
|
||||
<atom:Button ButtonType="Primary" x:Name="ReverseButton">Toggle Reverse</atom:Button>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</desktop:ShowCaseItem>
|
||||
|
||||
<desktop:ShowCaseItem Description="Alternate timeline." Title="Alternate">
|
||||
<atom:Timeline Mode="alternate">
|
||||
<atom:Timeline Mode="Alternate">
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
@ -96,7 +101,7 @@
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem DotIcon="{atom:IconProvider Kind=ClockCircleOutlined, NormalFilledColor=LightBlue}" Label="2024-01-01">
|
||||
<atom:TimelineItem DotIcon="{atom:IconProvider Kind=ClockCircleOutlined, NormalFilledColor=Red}" Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
@ -105,32 +110,39 @@
|
||||
</desktop:ShowCaseItem>
|
||||
|
||||
<desktop:ShowCaseItem Description="Use label show time alone." Title="Label">
|
||||
<atom:Timeline Mode="alternate">
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem>
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
</atom:Timeline>
|
||||
<StackPanel>
|
||||
<WrapPanel Orientation="Horizontal" Margin="0, 0, 0, 20">
|
||||
<atom:RadioButton x:Name="ModeLeft" IsChecked="True">Left</atom:RadioButton>
|
||||
<atom:RadioButton x:Name="ModeRight">Right</atom:RadioButton>
|
||||
<atom:RadioButton x:Name="ModeAlternate">Alternate</atom:RadioButton>
|
||||
</WrapPanel>
|
||||
<atom:Timeline Mode="Left" x:Name="LabelTimeline">
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem>
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
<atom:TimelineItem Label="2024-01-01">
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
</TextBlock>
|
||||
</atom:TimelineItem>
|
||||
</atom:Timeline>
|
||||
</StackPanel>
|
||||
</desktop:ShowCaseItem>
|
||||
|
||||
<desktop:ShowCaseItem Description="Right alternate timeline." Title="Right alternate">
|
||||
<atom:Timeline Mode="right">
|
||||
<atom:Timeline Mode="Right">
|
||||
<atom:TimelineItem>
|
||||
<TextBlock>
|
||||
2024-01-01 AtomUI Officially Initiated
|
||||
|
@ -1,11 +1,49 @@
|
||||
using AtomUI.Controls;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using RadioButton = Avalonia.Controls.RadioButton;
|
||||
|
||||
namespace AtomUI.Demo.Desktop.ShowCase;
|
||||
|
||||
public partial class TimelineShowCase : UserControl
|
||||
{
|
||||
|
||||
public TimelineShowCase()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
ModeLeft.Checked += ModeChecked;
|
||||
|
||||
ModeRight.Checked += ModeChecked;
|
||||
|
||||
ModeAlternate.Checked += ModeChecked;
|
||||
|
||||
ReverseButton.Click += ReverseButtonClick;
|
||||
|
||||
}
|
||||
|
||||
private void ReverseButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
ReverseTimeline.Reverse = !ReverseTimeline.Reverse;
|
||||
}
|
||||
|
||||
private void ModeChecked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (sender is RadioButton radioButton)
|
||||
{
|
||||
if (radioButton.Content?.ToString() == "Left")
|
||||
{
|
||||
LabelTimeline.Mode = TimeLineMode.Left;
|
||||
}
|
||||
else if (radioButton.Content?.ToString() == "Right")
|
||||
{
|
||||
LabelTimeline.Mode = TimeLineMode.Right;
|
||||
}
|
||||
else if (radioButton.Content?.ToString() == "Alternate")
|
||||
{
|
||||
LabelTimeline.Mode = TimeLineMode.Alternate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -586,6 +586,8 @@ namespace AtomUI.Theme.Styling
|
||||
public static readonly TokenResourceKey LeftMargin = new TokenResourceKey("Timeline.LeftMargin", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey LastItemContentMinHeight = new TokenResourceKey("Timeline.LastItemContentMinHeight", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey FontSize = new TokenResourceKey("Timeline.FontSize", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey ItemHeadSize = new TokenResourceKey("Timeline.ItemHeadSize", "AtomUI.Token");
|
||||
public static readonly TokenResourceKey CustomHeadSize = new TokenResourceKey("Timeline.CustomHeadSize", "AtomUI.Token");
|
||||
}
|
||||
|
||||
public static class TimePickerTokenResourceKey
|
||||
|
@ -11,12 +11,19 @@ using Avalonia.Layout;
|
||||
|
||||
namespace AtomUI.Controls;
|
||||
|
||||
public enum TimeLineMode
|
||||
{
|
||||
Left,
|
||||
Right,
|
||||
Alternate
|
||||
}
|
||||
|
||||
public class Timeline : ItemsControl
|
||||
{
|
||||
#region 公共属性定义
|
||||
|
||||
public static readonly StyledProperty<string> ModeProperty =
|
||||
AvaloniaProperty.Register<Timeline, string>(nameof(Mode), "left");
|
||||
public static readonly StyledProperty<TimeLineMode> ModeProperty =
|
||||
AvaloniaProperty.Register<Timeline, TimeLineMode>(nameof(Mode), TimeLineMode.Left);
|
||||
|
||||
public static readonly StyledProperty<string> PendingProperty =
|
||||
AvaloniaProperty.Register<Timeline, string>(nameof(Pending), "");
|
||||
@ -27,7 +34,7 @@ public class Timeline : ItemsControl
|
||||
public static readonly StyledProperty<PathIcon?> PendingIconProperty =
|
||||
AvaloniaProperty.Register<Alert, PathIcon?>(nameof(PendingIcon));
|
||||
|
||||
public string Mode
|
||||
public TimeLineMode Mode
|
||||
{
|
||||
get => GetValue(ModeProperty);
|
||||
set => SetValue(ModeProperty, value);
|
||||
@ -52,54 +59,11 @@ public class Timeline : ItemsControl
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Timeline()
|
||||
{
|
||||
if (Reverse)
|
||||
{
|
||||
OnReversePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
base.OnInitialized();
|
||||
|
||||
if (!String.IsNullOrEmpty(Pending))
|
||||
{
|
||||
var item = new TimelineItem();
|
||||
var textBlock = new TextBlock();
|
||||
|
||||
if (PendingIcon is null)
|
||||
{
|
||||
PendingIcon = new PathIcon
|
||||
{
|
||||
Kind = "LoadingOutlined",
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
LoadingAnimation = IconAnimation.Spin,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
}
|
||||
|
||||
item.DotIcon = PendingIcon;
|
||||
item.IsPending = true;
|
||||
item.Content = textBlock;
|
||||
BindUtils.RelayBind(this, PendingProperty, textBlock, TextBlock.TextProperty);
|
||||
|
||||
Items.Add(item);
|
||||
}
|
||||
|
||||
if (Reverse)
|
||||
{
|
||||
OnReversePropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private TimelineItem? _pendingItem;
|
||||
|
||||
static Timeline()
|
||||
{
|
||||
ReverseProperty.Changed.AddClassHandler<Timeline>((x, e) => x.OnReversePropertyChanged());
|
||||
}
|
||||
|
||||
protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey)
|
||||
@ -117,23 +81,9 @@ public class Timeline : ItemsControl
|
||||
base.PrepareContainerForItemOverride(element, item, index);
|
||||
if (element is TimelineItem timelineItem)
|
||||
{
|
||||
timelineItem.Index = index;
|
||||
timelineItem.Mode = Mode;
|
||||
timelineItem.IsLast = Items.Count - 1 == index;
|
||||
timelineItem.IsFirst = index == 0;
|
||||
BindUtils.RelayBind(this, ModeProperty, timelineItem, TimelineItem.ModeProperty);
|
||||
BindUtils.RelayBind(this, ReverseProperty, timelineItem, TimelineItem.ReverseProperty);
|
||||
foreach (var child in Items)
|
||||
{
|
||||
if (child is TimelineItem otherItem)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(otherItem.Label))
|
||||
{
|
||||
timelineItem.HasLabel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
BindUtils.RelayBind(this, ItemCountProperty, timelineItem, TimelineItem.CountProperty);
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,7 +91,10 @@ public class Timeline : ItemsControl
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
|
||||
TokenResourceBinder.CreateGlobalResourceBinding(this, BorderThicknessProperty,
|
||||
OnReversePropertyChanged();
|
||||
addPendingItem();
|
||||
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(this, BorderThicknessProperty,
|
||||
GlobalTokenResourceKey.BorderThickness,
|
||||
BindingPriority.Template,
|
||||
new RenderScaleAwareThicknessConfigure(this));
|
||||
@ -154,6 +107,73 @@ public class Timeline : ItemsControl
|
||||
{
|
||||
OnReversePropertyChanged();
|
||||
}
|
||||
|
||||
if (change.Property == ItemCountProperty && VisualRoot is not null)
|
||||
{
|
||||
OnItemCountPropertyChanged();
|
||||
}
|
||||
|
||||
if (change.Property == PendingProperty && VisualRoot is not null)
|
||||
{
|
||||
OnPendingPropertyChanged();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private void OnPendingPropertyChanged()
|
||||
{
|
||||
foreach (var item in Items)
|
||||
{
|
||||
if (item is TimelineItem timelineItem && timelineItem.IsPending)
|
||||
{
|
||||
Items.Remove(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
addPendingItem();
|
||||
}
|
||||
|
||||
private void addPendingItem()
|
||||
{
|
||||
if (!String.IsNullOrEmpty(Pending))
|
||||
{
|
||||
if (_pendingItem is null)
|
||||
{
|
||||
_pendingItem = new TimelineItem();
|
||||
var textBlock = new TextBlock();
|
||||
|
||||
if (PendingIcon is null)
|
||||
{
|
||||
PendingIcon = new PathIcon
|
||||
{
|
||||
Kind = "LoadingOutlined",
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
LoadingAnimation = IconAnimation.Spin,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
};
|
||||
}
|
||||
|
||||
_pendingItem.DotIcon = PendingIcon;
|
||||
_pendingItem.IsPending = true;
|
||||
_pendingItem.Content = textBlock;
|
||||
BindUtils.RelayBind(this, PendingProperty, textBlock, TextBlock.TextProperty);
|
||||
}
|
||||
|
||||
if (Reverse)
|
||||
{
|
||||
Items.Insert(0, _pendingItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
Items.Add(_pendingItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnItemCountPropertyChanged()
|
||||
{
|
||||
}
|
||||
|
||||
private void OnReversePropertyChanged()
|
||||
@ -164,6 +184,18 @@ public class Timeline : ItemsControl
|
||||
foreach (var item in items)
|
||||
{
|
||||
Items.Add(item);
|
||||
if (item is TimelineItem timelineItem)
|
||||
{
|
||||
timelineItem.Index = Items.IndexOf(item);
|
||||
}
|
||||
}
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is TimelineItem timelineItem)
|
||||
{
|
||||
timelineItem.Index = Items.IndexOf(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -3,55 +3,61 @@ using AtomUI.Utils;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace AtomUI.Controls;
|
||||
|
||||
public class TimelineItem : ContentControl
|
||||
{
|
||||
internal const string PendingNode = ":pending";
|
||||
internal const string ContentLeft = ":ContentLeft";
|
||||
internal const string LabelLeft = ":labelLeft";
|
||||
|
||||
internal const string PendingNodePC = ":pending";
|
||||
internal const string ContentLeftPC = ":ContentLeft";
|
||||
internal const string LabelLeftPC = ":labelLeft";
|
||||
|
||||
#region 公共属性定义
|
||||
|
||||
|
||||
public static readonly StyledProperty<string?> LabelProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, string?>(nameof(Label));
|
||||
|
||||
|
||||
public static readonly StyledProperty<PathIcon?> DotIconProperty =
|
||||
AvaloniaProperty.Register<Alert, PathIcon?>(nameof(DotIcon));
|
||||
|
||||
|
||||
public static readonly StyledProperty<string> ColorProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, string>(nameof(Color), "blue");
|
||||
|
||||
|
||||
public string? Label
|
||||
{
|
||||
get => GetValue(LabelProperty);
|
||||
set => SetValue(LabelProperty, value);
|
||||
}
|
||||
|
||||
|
||||
public PathIcon? DotIcon
|
||||
{
|
||||
get => GetValue(DotIconProperty);
|
||||
set => SetValue(DotIconProperty, value);
|
||||
}
|
||||
|
||||
|
||||
public string Color
|
||||
{
|
||||
get => GetValue(ColorProperty);
|
||||
set => SetValue(ColorProperty, value);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region 内部属性定义
|
||||
|
||||
internal static readonly StyledProperty<int> IndexProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, int>(nameof(Index), 0);
|
||||
|
||||
internal static readonly StyledProperty<int> CountProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, int>(nameof(Count), 0);
|
||||
|
||||
internal static readonly StyledProperty<string> ModeProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, string>(nameof(Mode), "left");
|
||||
internal static readonly StyledProperty<TimeLineMode> ModeProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, TimeLineMode>(nameof(Mode), TimeLineMode.Left);
|
||||
|
||||
internal static readonly StyledProperty<bool> HasLabelProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, bool>(nameof(HasLabel), false);
|
||||
@ -59,22 +65,32 @@ public class TimelineItem : ContentControl
|
||||
internal static readonly StyledProperty<bool> IsLastProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, bool>(nameof(IsLast), false);
|
||||
|
||||
internal static readonly StyledProperty<bool> IsFirstProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, bool>(nameof(IsFirst), false);
|
||||
|
||||
internal static readonly StyledProperty<bool> ReverseProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, bool>(nameof(Reverse), false);
|
||||
|
||||
internal static readonly StyledProperty<bool> IsPendingProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, bool>(nameof(IsPending), false);
|
||||
|
||||
|
||||
internal static readonly StyledProperty<HorizontalAlignment> ContentTextAlignProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, HorizontalAlignment>(nameof(ContentTextAlign),
|
||||
HorizontalAlignment.Left);
|
||||
|
||||
internal static readonly StyledProperty<HorizontalAlignment> LabelTextAlignProperty =
|
||||
AvaloniaProperty.Register<TimelineItem, HorizontalAlignment>(nameof(LabelTextAlign), HorizontalAlignment.Left);
|
||||
|
||||
internal int Index
|
||||
{
|
||||
get => GetValue(IndexProperty);
|
||||
set => SetValue(IndexProperty, value);
|
||||
}
|
||||
|
||||
internal int Count
|
||||
{
|
||||
get => GetValue(CountProperty);
|
||||
set => SetValue(CountProperty, value);
|
||||
}
|
||||
|
||||
internal string Mode
|
||||
internal TimeLineMode Mode
|
||||
{
|
||||
get => GetValue(ModeProperty);
|
||||
set => SetValue(ModeProperty, value);
|
||||
@ -86,12 +102,6 @@ public class TimelineItem : ContentControl
|
||||
set => SetValue(HasLabelProperty, value);
|
||||
}
|
||||
|
||||
internal bool IsFirst
|
||||
{
|
||||
get => GetValue(IsFirstProperty);
|
||||
set => SetValue(IsFirstProperty, value);
|
||||
}
|
||||
|
||||
internal bool IsLast
|
||||
{
|
||||
get => GetValue(IsLastProperty);
|
||||
@ -109,23 +119,40 @@ public class TimelineItem : ContentControl
|
||||
get => GetValue(IsPendingProperty);
|
||||
set => SetValue(IsPendingProperty, value);
|
||||
}
|
||||
#endregion
|
||||
|
||||
internal HorizontalAlignment ContentTextAlign
|
||||
{
|
||||
get => GetValue(ContentTextAlignProperty);
|
||||
set => SetValue(ContentTextAlignProperty, value);
|
||||
}
|
||||
|
||||
internal HorizontalAlignment LabelTextAlign
|
||||
{
|
||||
get => GetValue(LabelTextAlignProperty);
|
||||
set => SetValue(LabelTextAlignProperty, value);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
protected internal int LabelIndex = 0;
|
||||
protected internal int SplitIndex = 1;
|
||||
protected internal int ContentIndex = 2;
|
||||
protected internal HorizontalAlignment LabelTextAlign = HorizontalAlignment.Right;
|
||||
protected internal HorizontalAlignment ContentTextAlign = HorizontalAlignment.Left;
|
||||
|
||||
|
||||
private Grid? _gridContainer;
|
||||
private DockPanel? _splitPanel;
|
||||
|
||||
private TextBlock? _labelBlock;
|
||||
private ContentPresenter? _itemsContentPresenter;
|
||||
private Border? _splitHeadPart;
|
||||
private PathIcon? _dotPart;
|
||||
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
base.OnPropertyChanged(change);
|
||||
|
||||
CalculateIndex();
|
||||
CalculateOther();
|
||||
UpdatePseudoClasses();
|
||||
if (change.Property == ModeProperty || change.Property == CountProperty || change.Property == IndexProperty)
|
||||
{
|
||||
UpdateAll();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
@ -137,84 +164,198 @@ public class TimelineItem : ContentControl
|
||||
|
||||
private void HandleTemplateApplied(INameScope scope)
|
||||
{
|
||||
_splitPanel = scope.Find<DockPanel>(TimelineItemTheme.SplitPanelPart);
|
||||
UpdatePseudoClasses();
|
||||
_gridContainer = scope.Find<Grid>(TimelineItemTheme.GridPart);
|
||||
_splitPanel = scope.Find<DockPanel>(TimelineItemTheme.SplitPanelPart);
|
||||
_labelBlock = scope.Find<TextBlock>(TimelineItemTheme.LabelPart);
|
||||
_itemsContentPresenter = scope.Find<ContentPresenter>(TimelineItemTheme.ItemsContentPresenterPart);
|
||||
_splitHeadPart = scope.Find<Border>(TimelineItemTheme.SplitHeadPart);
|
||||
_dotPart = scope.Find<PathIcon>(TimelineItemTheme.DotPart);
|
||||
|
||||
UpdateAll();
|
||||
}
|
||||
|
||||
protected void UpdateAll()
|
||||
{
|
||||
CalculateIndex();
|
||||
SetupShowInfo();
|
||||
UpdatePseudoClasses();
|
||||
}
|
||||
|
||||
protected void UpdatePseudoClasses()
|
||||
{
|
||||
PseudoClasses.Set(PendingNode, IsPending);
|
||||
PseudoClasses.Set(ContentLeft, ContentIndex == 0);
|
||||
PseudoClasses.Set(LabelLeft, LabelIndex == 0);
|
||||
PseudoClasses.Set(PendingNodePC, IsPending);
|
||||
PseudoClasses.Set(ContentLeftPC, ContentIndex == 0);
|
||||
PseudoClasses.Set(LabelLeftPC, LabelIndex == 0);
|
||||
}
|
||||
|
||||
private void CalculateIndex()
|
||||
{
|
||||
LabelIndex = 0;
|
||||
if (VisualRoot is null || _gridContainer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Parent is Timeline timeline)
|
||||
{
|
||||
Index = timeline.Items.IndexOf(this);
|
||||
IsLast = Index == timeline.Items.Count - 1;
|
||||
HasLabel = false;
|
||||
|
||||
foreach (var child in timeline.Items)
|
||||
{
|
||||
if (child is TimelineItem item)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(item.Label))
|
||||
{
|
||||
HasLabel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SplitIndex = 1;
|
||||
ContentIndex = 2;
|
||||
LabelTextAlign = HorizontalAlignment.Right;
|
||||
ContentTextAlign = HorizontalAlignment.Left;
|
||||
if (Mode == "right" || (Mode == "alternate" && Index % 2 == 1))
|
||||
if (Mode == TimeLineMode.Right || (Mode == TimeLineMode.Alternate && Index % 2 == 1))
|
||||
{
|
||||
LabelIndex = 2;
|
||||
ContentIndex = 0;
|
||||
LabelTextAlign = HorizontalAlignment.Left;
|
||||
ContentTextAlign = HorizontalAlignment.Right;
|
||||
}
|
||||
|
||||
if (!HasLabel && Mode == "left")
|
||||
else
|
||||
{
|
||||
SplitIndex = 0;
|
||||
ContentIndex = 1;
|
||||
LabelIndex = 0;
|
||||
ContentIndex = 2;
|
||||
LabelTextAlign = HorizontalAlignment.Right;
|
||||
ContentTextAlign = HorizontalAlignment.Left;
|
||||
}
|
||||
|
||||
if (!HasLabel && Mode == "right")
|
||||
_gridContainer.ColumnDefinitions[0].Width = GridLength.Star;
|
||||
_gridContainer.ColumnDefinitions[2].Width = GridLength.Star;
|
||||
|
||||
if (!HasLabel)
|
||||
{
|
||||
SplitIndex = 1;
|
||||
ContentIndex = 0;
|
||||
ContentTextAlign = HorizontalAlignment.Right;
|
||||
if (Mode == TimeLineMode.Left)
|
||||
{
|
||||
LabelIndex = 0;
|
||||
ContentIndex = 2;
|
||||
ContentTextAlign = HorizontalAlignment.Left;
|
||||
}
|
||||
|
||||
if (Mode == TimeLineMode.Right)
|
||||
{
|
||||
LabelIndex = 2;
|
||||
ContentIndex = 0;
|
||||
ContentTextAlign = HorizontalAlignment.Right;
|
||||
}
|
||||
|
||||
_gridContainer.ColumnDefinitions[LabelIndex].Width = new GridLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void CalculateOther()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void SetupShowInfo()
|
||||
{
|
||||
if (_splitPanel is null)
|
||||
if (_splitPanel is null || _itemsContentPresenter is null || _labelBlock is null || _gridContainer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Grid.SetColumn(_labelBlock, LabelIndex);
|
||||
Grid.SetColumn(_itemsContentPresenter, ContentIndex);
|
||||
Grid.SetColumn(_splitPanel, SplitIndex);
|
||||
|
||||
var dot = _splitPanel.Children[0];
|
||||
var border = _splitPanel.Children[1] as Border;
|
||||
var rect = border?.Child as Rectangle;
|
||||
var isPendingItem = false;
|
||||
|
||||
if (IsLast && rect is not null)
|
||||
{
|
||||
rect.IsVisible = false;
|
||||
}
|
||||
MinHeight = 0;
|
||||
|
||||
if (Parent is Timeline timeline && !String.IsNullOrEmpty(timeline.Pending) && rect is not null && border is not null)
|
||||
if (Parent is Timeline timeline && !String.IsNullOrEmpty(timeline.Pending) && rect is not null &&
|
||||
border is not null)
|
||||
{
|
||||
rect.StrokeDashArray = null;
|
||||
|
||||
isPendingItem = Reverse && Index == 0 || !Reverse && timeline.ItemCount - 2 == Index;
|
||||
if (isPendingItem)
|
||||
{
|
||||
rect.StrokeDashArray = new AvaloniaList<double> { 0, 2 };
|
||||
}
|
||||
}
|
||||
|
||||
if (rect is not null)
|
||||
{
|
||||
rect.IsVisible = !IsLast;
|
||||
}
|
||||
|
||||
if (IsLast || isPendingItem)
|
||||
{
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(this, Layoutable.MinHeightProperty,
|
||||
TimelineTokenResourceKey.LastItemContentMinHeight);
|
||||
}
|
||||
|
||||
|
||||
if (Color.StartsWith("#"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var color = Avalonia.Media.Color.Parse(Color);
|
||||
var brush = new SolidColorBrush(color);
|
||||
if (_dotPart is not null && _dotPart.NormalFilledBrush is null)
|
||||
{
|
||||
_dotPart.NormalFilledBrush = brush;
|
||||
}
|
||||
|
||||
if (_splitHeadPart is not null)
|
||||
{
|
||||
_splitHeadPart.BorderBrush = brush;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (_dotPart is not null && _dotPart.NormalFilledBrush is null)
|
||||
{
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(_dotPart, PathIcon.NormalFilledBrushProperty,
|
||||
GlobalTokenResourceKey.ColorPrimary);
|
||||
}
|
||||
|
||||
if (_splitHeadPart is not null)
|
||||
{
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(_splitHeadPart, Border.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorPrimary);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var tokenText = GlobalTokenResourceKey.ColorSuccess;
|
||||
switch (Color)
|
||||
{
|
||||
case "blue":
|
||||
tokenText = GlobalTokenResourceKey.ColorPrimary;
|
||||
break;
|
||||
case "green":
|
||||
tokenText = GlobalTokenResourceKey.ColorSuccess;
|
||||
break;
|
||||
case "red":
|
||||
tokenText = GlobalTokenResourceKey.ColorError;
|
||||
break;
|
||||
case "gray":
|
||||
tokenText = GlobalTokenResourceKey.ColorTextDisabled;
|
||||
break;
|
||||
}
|
||||
if (_dotPart is not null && _dotPart.NormalFilledBrush is null)
|
||||
{
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(_dotPart, PathIcon.NormalFilledBrushProperty,
|
||||
tokenText);
|
||||
}
|
||||
|
||||
if (_splitHeadPart is not null)
|
||||
{
|
||||
TokenResourceBinder.CreateGlobalTokenBinding(_splitHeadPart, Border.BorderBrushProperty,
|
||||
tokenText);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -29,37 +29,39 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
{
|
||||
}
|
||||
|
||||
protected TimelineItemTheme(Type targetType) : base(targetType)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IControlTemplate BuildControlTemplate()
|
||||
{
|
||||
return new FuncControlTemplate<TimelineItem>((timelineItem, scope) =>
|
||||
{
|
||||
var columnDefinition = CalculateGridColumn(timelineItem);
|
||||
var grid = new Grid()
|
||||
{
|
||||
Name = GridPart,
|
||||
ColumnDefinitions = columnDefinition,
|
||||
Name = GridPart,
|
||||
ColumnDefinitions = new ColumnDefinitions()
|
||||
{
|
||||
new ColumnDefinition(GridLength.Star),
|
||||
new ColumnDefinition(new GridLength(10)),
|
||||
new ColumnDefinition(GridLength.Star)
|
||||
},
|
||||
RowDefinitions =
|
||||
{
|
||||
new RowDefinition(GridLength.Star),
|
||||
new RowDefinition(GridLength.Star),
|
||||
},
|
||||
};
|
||||
grid.RegisterInNameScope(scope);
|
||||
|
||||
var labelBlock = new TextBlock()
|
||||
{
|
||||
Name = LabelPart,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
HorizontalAlignment = timelineItem.LabelTextAlign,
|
||||
Name = LabelPart,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
};
|
||||
labelBlock.RegisterInNameScope(scope);
|
||||
|
||||
CreateTemplateParentBinding(labelBlock, TextBlock.TextProperty, TimelineItem.LabelProperty);
|
||||
CreateTemplateParentBinding(labelBlock, TextBlock.IsVisibleProperty, TimelineItem.HasLabelProperty);
|
||||
CreateTemplateParentBinding(labelBlock, Visual.IsVisibleProperty, TimelineItem.HasLabelProperty);
|
||||
CreateTemplateParentBinding(labelBlock, Layoutable.HorizontalAlignmentProperty,
|
||||
TimelineItem.LabelTextAlignProperty);
|
||||
|
||||
Grid.SetColumn(labelBlock, timelineItem.LabelIndex);
|
||||
grid.Children.Add(labelBlock);
|
||||
|
||||
var splitPanel = new DockPanel()
|
||||
@ -91,19 +93,19 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
border.Child = verticalDashedLine;
|
||||
|
||||
splitPanel.Children.Add(border);
|
||||
Grid.SetColumn(splitPanel, timelineItem.SplitIndex);
|
||||
grid.Children.Add(splitPanel);
|
||||
|
||||
var contentPresenter = new ContentPresenter()
|
||||
{
|
||||
Name = ItemsContentPresenterPart,
|
||||
HorizontalAlignment = timelineItem.ContentTextAlign,
|
||||
Name = ItemsContentPresenterPart,
|
||||
};
|
||||
contentPresenter.RegisterInNameScope(scope);
|
||||
|
||||
CreateTemplateParentBinding(contentPresenter, ContentPresenter.ContentProperty,
|
||||
ContentControl.ContentProperty);
|
||||
CreateTemplateParentBinding(contentPresenter, Layoutable.HorizontalAlignmentProperty,
|
||||
TimelineItem.ContentTextAlignProperty);
|
||||
|
||||
Grid.SetColumn(contentPresenter, timelineItem.ContentIndex);
|
||||
grid.Children.Add(contentPresenter);
|
||||
|
||||
return grid;
|
||||
@ -114,9 +116,9 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
{
|
||||
if (timelineItem.DotIcon is not null)
|
||||
{
|
||||
timelineItem.DotIcon.Width = 10;
|
||||
timelineItem.DotIcon.Height = 10;
|
||||
timelineItem.DotIcon.Name = DotPart;
|
||||
timelineItem.DotIcon.Width = 10;
|
||||
timelineItem.DotIcon.Height = 10;
|
||||
timelineItem.DotIcon.Name = DotPart;
|
||||
timelineItem.DotIcon.RegisterInNameScope(scope);
|
||||
DockPanel.SetDock(timelineItem.DotIcon, Dock.Top);
|
||||
panel.Children.Add(timelineItem.DotIcon);
|
||||
@ -131,97 +133,10 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
BorderThickness = new Thickness(3),
|
||||
Name = SplitHeadPart,
|
||||
};
|
||||
splitHead.RegisterInNameScope(scope);
|
||||
DockPanel.SetDock(splitHead, Dock.Top);
|
||||
panel.Children.Add(splitHead);
|
||||
}
|
||||
|
||||
var dotBorderStyle = new Style(selector => selector.Nesting().Template().Name(SplitHeadPart));
|
||||
var dotIconStyle = new Style(selector =>
|
||||
selector.Nesting().Not(x => x.PropertyEquals(TimelineItem.DotIconProperty, null)).Template().Name(DotPart));
|
||||
|
||||
if (timelineItem.Color.StartsWith("#"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var color = Color.Parse(timelineItem.Color);
|
||||
var brush = new SolidColorBrush(color);
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty, brush);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, brush);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorPrimary);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (timelineItem.Color)
|
||||
{
|
||||
case "blue":
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorPrimary);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorPrimary);
|
||||
break;
|
||||
case "green":
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorSuccess);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorSuccess);
|
||||
break;
|
||||
case "red":
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorError);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorError);
|
||||
break;
|
||||
case "gray":
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorTextDisabled);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorTextDisabled);
|
||||
break;
|
||||
default:
|
||||
dotBorderStyle.Add(ContentPresenter.BorderBrushProperty,
|
||||
GlobalTokenResourceKey.ColorPrimary);
|
||||
dotIconStyle.Add(PathIcon.NormalFilledBrushProperty, GlobalTokenResourceKey.ColorPrimary);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dotBorderStyle.Add(ContentPresenter.BackgroundProperty, TimelineTokenResourceKey.DotBg);
|
||||
Add(dotBorderStyle);
|
||||
if (timelineItem.DotIcon?.NormalFilledBrush is null)
|
||||
{
|
||||
Add(dotIconStyle);
|
||||
}
|
||||
}
|
||||
|
||||
private ColumnDefinitions CalculateGridColumn(TimelineItem timelineItem)
|
||||
{
|
||||
if (timelineItem.HasLabel || timelineItem.Mode == "alternate")
|
||||
{
|
||||
return new ColumnDefinitions()
|
||||
{
|
||||
new ColumnDefinition(GridLength.Star),
|
||||
new ColumnDefinition(new GridLength(10)),
|
||||
new ColumnDefinition(GridLength.Star)
|
||||
};
|
||||
}
|
||||
|
||||
if (timelineItem.LabelIndex == 0)
|
||||
{
|
||||
return new ColumnDefinitions()
|
||||
{
|
||||
new ColumnDefinition(new GridLength(10)),
|
||||
new ColumnDefinition(GridLength.Star)
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return new ColumnDefinitions()
|
||||
{
|
||||
new ColumnDefinition(GridLength.Star),
|
||||
new ColumnDefinition(new GridLength(10))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void BuildStyles()
|
||||
@ -230,14 +145,14 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
var splitLineborderStyle = new Style(selector => selector.Nesting().Template().Name(SplitLineBorderPart));
|
||||
splitLineborderStyle.Add(Layoutable.WidthProperty, TimelineTokenResourceKey.TailWidth);
|
||||
Add(splitLineborderStyle);
|
||||
|
||||
|
||||
var lineStyle = new Style(selector => selector.Nesting().Template().Child().OfType<Rectangle>());
|
||||
lineStyle.Add(Shape.StrokeProperty, TimelineTokenResourceKey.TailColor);
|
||||
lineStyle.Add(Layoutable.WidthProperty, TimelineTokenResourceKey.TailWidth);
|
||||
lineStyle.Add(Shape.StrokeThicknessProperty, TimelineTokenResourceKey.TailWidth);
|
||||
|
||||
Add(lineStyle);
|
||||
|
||||
|
||||
// 内容样式
|
||||
var contentPresenterStyle =
|
||||
new Style(selector => selector.Nesting().Template().Name(ItemsContentPresenterPart));
|
||||
@ -252,8 +167,8 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
new Style(selector => selector.Nesting().Template().Name(ItemsContentPresenterPart));
|
||||
contentPresenterRightStyle.Add(Layoutable.MarginProperty, TimelineTokenResourceKey.LeftMargin);
|
||||
|
||||
var contentLeftStyle = new Style(selector => selector.Nesting().Class(TimelineItem.ContentLeft));
|
||||
var contentRightStyle = new Style(selector => selector.Nesting().Not(x => x.Class(TimelineItem.ContentLeft)));
|
||||
var contentLeftStyle = new Style(selector => selector.Nesting().Class(TimelineItem.ContentLeftPC));
|
||||
var contentRightStyle = new Style(selector => selector.Nesting().Not(x => x.Class(TimelineItem.ContentLeftPC)));
|
||||
|
||||
contentLeftStyle.Add(contentPresenterLeftStyle);
|
||||
contentRightStyle.Add(contentPresenterRightStyle);
|
||||
@ -267,9 +182,9 @@ internal class TimelineItemTheme : BaseControlTheme
|
||||
labelStyle.Add(TextBlock.FontSizeProperty, TimelineTokenResourceKey.FontSize);
|
||||
|
||||
var labelLeftStyle = new Style(selector =>
|
||||
selector.Nesting().Class(TimelineItem.LabelLeft).Template().Name(LabelPart));
|
||||
selector.Nesting().Class(TimelineItem.LabelLeftPC).Template().Name(LabelPart));
|
||||
var labelRightStyle = new Style(selector =>
|
||||
selector.Nesting().Not(x => x.Class(TimelineItem.LabelLeft)).Template().Name(LabelPart));
|
||||
selector.Nesting().Not(x => x.Class(TimelineItem.LabelLeftPC)).Template().Name(LabelPart));
|
||||
labelLeftStyle.Add(TextBlock.PaddingProperty, TimelineTokenResourceKey.RightMargin);
|
||||
labelRightStyle.Add(TextBlock.PaddingProperty, TimelineTokenResourceKey.LeftMargin);
|
||||
|
||||
|
@ -4,7 +4,6 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.Presenters;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Styling;
|
||||
|
||||
namespace AtomUI.Controls;
|
||||
@ -16,8 +15,7 @@ internal class TimelineTheme : BaseControlTheme
|
||||
public const string ScrollViewerPart = "PART_ScrollViewer";
|
||||
public const string ItemsPresenterPart = "PART_ItemsPresenter";
|
||||
|
||||
public TimelineTheme() : this(typeof(Timeline)) { }
|
||||
protected TimelineTheme(Type targetType) : base(targetType) { }
|
||||
public TimelineTheme() : base(typeof(Timeline)) { }
|
||||
|
||||
protected override IControlTemplate BuildControlTemplate()
|
||||
{
|
||||
|
@ -1,4 +1,3 @@
|
||||
using AtomUI.Theme.Styling;
|
||||
using AtomUI.Theme.TokenSystem;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
@ -71,6 +70,15 @@ internal class TimelineToken : AbstractControlDesignToken
|
||||
/// </summary>
|
||||
public double FontSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// item head size
|
||||
/// </summary>
|
||||
public double ItemHeadSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// custom head size
|
||||
/// </summary>
|
||||
public double CustomHeadSize { get; set; }
|
||||
|
||||
internal override void CalculateFromAlias()
|
||||
{
|
||||
@ -89,8 +97,9 @@ internal class TimelineToken : AbstractControlDesignToken
|
||||
RightMargin = new Thickness(0, 0, _globalToken.MarginSM, 0);
|
||||
|
||||
LastItemContentMinHeight = _globalToken.HeightToken.ControlHeightLG * 1.2;
|
||||
FontSize = _globalToken.FontToken.FontSize;
|
||||
|
||||
FontSize = _globalToken.FontToken.FontSize;
|
||||
ItemHeadSize = 10;
|
||||
CustomHeadSize = _globalToken.FontToken.FontSize;
|
||||
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user