diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9554478 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# CS0067: The event 'HomeCommand.CanExecuteChanged' is never used +dotnet_diagnostic.CS0067.severity = none diff --git a/Markdowser.sln b/Markdowser.sln index ac25629..862d22c 100644 --- a/Markdowser.sln +++ b/Markdowser.sln @@ -3,7 +3,12 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.9.34616.47 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Markdowser", "Markdowser\Markdowser.csproj", "{98C02B25-7D7D-4F83-892D-5503FFF3D108}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Markdowser", "Markdowser\Markdowser.csproj", "{98C02B25-7D7D-4F83-892D-5503FFF3D108}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{99E8E3C0-9FF5-4665-98DC-BD0C00FA8569}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Markdowser/App.axaml.cs b/Markdowser/App.axaml.cs index 5782b35..1249b3b 100644 --- a/Markdowser/App.axaml.cs +++ b/Markdowser/App.axaml.cs @@ -3,7 +3,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Styling; -using Markdowser.Utilities; +using Markdowser.Models; using Markdowser.ViewModels; using Markdowser.Views; diff --git a/Markdowser/Assets/Markdowser-Dark-Transparent.ico b/Markdowser/Assets/Markdowser-Dark-Transparent.ico new file mode 100644 index 0000000..3119219 Binary files /dev/null and b/Markdowser/Assets/Markdowser-Dark-Transparent.ico differ diff --git a/Markdowser/Assets/Markdowser-Light-Transparent.ico b/Markdowser/Assets/Markdowser-Light-Transparent.ico new file mode 100644 index 0000000..e84390a Binary files /dev/null and b/Markdowser/Assets/Markdowser-Light-Transparent.ico differ diff --git a/Markdowser/Assets/avalonia-logo.ico b/Markdowser/Assets/avalonia-logo.ico deleted file mode 100644 index da8d49f..0000000 Binary files a/Markdowser/Assets/avalonia-logo.ico and /dev/null differ diff --git a/Markdowser/Commands/ChangeThemeCommand.cs b/Markdowser/Commands/ChangeThemeCommand.cs index 96213da..074c100 100644 --- a/Markdowser/Commands/ChangeThemeCommand.cs +++ b/Markdowser/Commands/ChangeThemeCommand.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Styling; +using Markdowser.Models; using Markdowser.Utilities; using System; @@ -10,8 +11,12 @@ namespace Markdowser.Commands; internal class ChangeThemeCommand : ICommand { +#pragma warning disable CS0067 // The event 'ChangeThemeCommand.CanExecuteChanged' is never used + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 // The event 'ChangeThemeCommand.CanExecuteChanged' is never used + public bool CanExecute(object? parameter) { return true; diff --git a/Markdowser/Commands/HomeCommand.cs b/Markdowser/Commands/HomeCommand.cs index e0cdeff..f58d239 100644 --- a/Markdowser/Commands/HomeCommand.cs +++ b/Markdowser/Commands/HomeCommand.cs @@ -1,4 +1,5 @@ -using Markdowser.Utilities; +using Markdowser.Models; +using Markdowser.Utilities; using System; using System.Windows.Input; @@ -7,8 +8,12 @@ namespace Markdowser.Commands; internal class HomeCommand : ICommand { +#pragma warning disable CS0067 // The event 'HomeCommand.CanExecuteChanged' is never used + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 // The event 'HomeCommand.CanExecuteChanged' is never used + public bool CanExecute(object? parameter) { return true; diff --git a/Markdowser/Commands/HyperlinkCommand.cs b/Markdowser/Commands/HyperlinkCommand.cs index b7df8ff..6c97b5e 100644 --- a/Markdowser/Commands/HyperlinkCommand.cs +++ b/Markdowser/Commands/HyperlinkCommand.cs @@ -8,8 +8,12 @@ namespace Markdowser.Commands; public class HyperlinkCommand : ICommand { +#pragma warning disable CS0067 // The event 'HyperlinkCommand.CanExecuteChanged' is never used + public event EventHandler? CanExecuteChanged; +#pragma warning restore CS0067 // The event 'HyperlinkCommand.CanExecuteChanged' is never used + public bool CanExecute(object? parameter) { return true; diff --git a/Markdowser/GlobalSuppressions.cs b/Markdowser/GlobalSuppressions.cs index 48e5f4b..e7bc418 100644 --- a/Markdowser/GlobalSuppressions.cs +++ b/Markdowser/GlobalSuppressions.cs @@ -6,4 +6,5 @@ using System.Diagnostics.CodeAnalysis; [assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "")] -[assembly: SuppressMessage("Minor Code Smell", "CS0067:The event 'ChangeThemeCommand.CanExecuteChanged' is never used", Justification = "", Scope = "member", Target = "~N:Markdowser.Commands")] \ No newline at end of file +[assembly: SuppressMessage("Minor Code Smell", "CS0067:The event 'ChangeThemeCommand.CanExecuteChanged' is never used", Justification = "", Scope = "member", Target = "~N:Markdowser.Commands")] +[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "")] \ No newline at end of file diff --git a/Markdowser/Markdowser.csproj b/Markdowser/Markdowser.csproj index 8209d0b..2ff3f6d 100644 --- a/Markdowser/Markdowser.csproj +++ b/Markdowser/Markdowser.csproj @@ -13,8 +13,10 @@ + + @@ -35,8 +37,4 @@ - - - - diff --git a/Markdowser/Utilities/Settings.cs b/Markdowser/Models/Settings.cs similarity index 89% rename from Markdowser/Utilities/Settings.cs rename to Markdowser/Models/Settings.cs index b98af4b..091854b 100644 --- a/Markdowser/Utilities/Settings.cs +++ b/Markdowser/Models/Settings.cs @@ -1,9 +1,11 @@ -using System.IO; +using Markdowser.Utilities; + +using System.IO; using System.Text.Json; -namespace Markdowser.Utilities; +namespace Markdowser.Models; -internal class Settings +public class Settings { public static Settings Current { get; set; } = LoadSettings(); diff --git a/Markdowser/Utilities/ColumnDefinition.cs b/Markdowser/Utilities/ColumnDefinition.cs new file mode 100644 index 0000000..20caaa3 --- /dev/null +++ b/Markdowser/Utilities/ColumnDefinition.cs @@ -0,0 +1,35 @@ +using Avalonia; + +namespace Markdowser.Utilities; + +internal static class ColumnDefinition +{ + public static readonly AttachedProperty IsVisibleProperty = AvaloniaProperty.RegisterAttached("IsVisible", typeof(ColumnDefinition), true, coerce: (element, visibility) => + { + Avalonia.Controls.GridLength? lastWidth = element.GetValue(LastWidthProperty!); + if (visibility && lastWidth is { }) + { + _ = element.SetValue(Avalonia.Controls.ColumnDefinition.WidthProperty, lastWidth); + } + else if (!visibility) + { + _ = element.SetValue(LastWidthProperty, element.GetValue(Avalonia.Controls.ColumnDefinition.WidthProperty)); + _ = element.SetValue(Avalonia.Controls.ColumnDefinition.WidthProperty, ZeroWidth); + } + return visibility; + }); + + private static readonly Avalonia.Controls.GridLength ZeroWidth = new Avalonia.Controls.GridLength(0, Avalonia.Controls.GridUnitType.Pixel); + + private static readonly AttachedProperty LastWidthProperty = AvaloniaProperty.RegisterAttached("LastWidth", typeof(ColumnDefinition), default); + + public static bool GetIsVisible(Avalonia.Controls.ColumnDefinition columnDefinition) + { + return columnDefinition.GetValue(IsVisibleProperty); + } + + public static void SetIsVisible(Avalonia.Controls.ColumnDefinition columnDefinition, bool visibility) + { + _ = columnDefinition.SetValue(IsVisibleProperty, visibility); + } +} \ No newline at end of file diff --git a/Markdowser/Utilities/GlobalState.cs b/Markdowser/Utilities/GlobalState.cs index f78d35f..856fb75 100644 --- a/Markdowser/Utilities/GlobalState.cs +++ b/Markdowser/Utilities/GlobalState.cs @@ -1,5 +1,8 @@ -using System; +using Avalonia.Controls; + +using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; namespace Markdowser.Utilities; @@ -33,6 +36,8 @@ internal static class GlobalState } } + public static ObservableCollection Tabs { get; internal set; } = [new TabItem() { Header = "New Tab" }]; + internal static void SetUrl(object sender, string url) { GlobalState.url = url; diff --git a/Markdowser/Utilities/HttpPathResolver.cs b/Markdowser/Utilities/HttpPathResolver.cs index dd3d489..f83a29c 100644 --- a/Markdowser/Utilities/HttpPathResolver.cs +++ b/Markdowser/Utilities/HttpPathResolver.cs @@ -1,7 +1,7 @@ using Avalonia.Platform; using Markdown.Avalonia.Utils; - +using Markdowser.Models; using System; using System.Collections.Generic; using System.Diagnostics; diff --git a/Markdowser/ViewModels/MainWindowViewModel.cs b/Markdowser/ViewModels/MainWindowViewModel.cs index 6909ae4..2f5323f 100644 --- a/Markdowser/ViewModels/MainWindowViewModel.cs +++ b/Markdowser/ViewModels/MainWindowViewModel.cs @@ -1,8 +1,15 @@ -using Markdowser.Utilities; +using Avalonia.Controls; +using Avalonia.Threading; + +using HtmlAgilityPack; + +using Markdowser.Models; +using Markdowser.Utilities; using ReactiveUI; using System; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -21,13 +28,33 @@ public partial class MainWindowViewModel : ViewModelBase private readonly HttpClient httpClient = new(new HttpClientHandler() { AllowAutoRedirect = true }); private readonly ReverseMarkdown.Converter markdownConverter = new(); + private readonly StringBuilder html = new(); private StringBuilder? content; - + private int currentTabIndex; + private bool showSidePanel; private bool isBusy; - private int progress; + public ObservableCollection Tabs => GlobalState.Tabs; public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}"; + public int CurrentTabIndex + { + get => currentTabIndex; + set + { + if (CurrentTab is not null) + { + CurrentTab.Tag = Url; + } + + _ = this.RaiseAndSetIfChanged(ref currentTabIndex, value); + + Url = CurrentTab?.Tag?.ToString() ?? string.Empty; + + FetchUrl(); + } + } + public StringBuilder Content { get => content ?? DefaultContent; @@ -40,6 +67,12 @@ public partial class MainWindowViewModel : ViewModelBase set => GlobalState.SetUrl(this, value); } + public bool ShowSidePanel + { + get => showSidePanel; + set => this.RaiseAndSetIfChanged(ref showSidePanel, value); + } + public bool IsBusy { get => isBusy; @@ -56,12 +89,13 @@ public partial class MainWindowViewModel : ViewModelBase } } + public SettingsViewModel SettingsViewModel => new SettingsViewModel(); + public RawHtmlViewModel RawHtmlViewModel => new RawHtmlViewModel(html); + public RawMarkdownViewModel RawMarkdownViewModel => new RawMarkdownViewModel(() => Content); + public bool CloseTabEnabled => Tabs.Count > 1; public bool BackEnabled => GlobalState.BackHistory.Count > 0; - public bool ForwardEnabled => GlobalState.ForwardHistory.Count > 0; - public bool ProgressIndeterminate => Progress == 0; - public ICommand Browse => ReactiveCommand.Create(FetchUrl); public ICommand Back => ReactiveCommand.Create(() => @@ -90,6 +124,27 @@ public partial class MainWindowViewModel : ViewModelBase } }); + public ICommand ToggleSidePanel => ReactiveCommand.Create(() => ShowSidePanel = !ShowSidePanel); + + public ICommand CloseTab => ReactiveCommand.Create(() => + { + if (Tabs.Count > 1 && Tabs.Remove(CurrentTab)) + { + CurrentTabIndex = Math.Max(0, CurrentTabIndex - 1); + this.RaisePropertyChanged(nameof(CloseTabEnabled)); + } + }); + + public ICommand NewTab => ReactiveCommand.Create(() => + { + TabItem tab = new() { Header = "New Tab", Name = Guid.NewGuid().ToString() }; + Tabs.Add(tab); + CurrentTabIndex = Tabs.Count - 1; + this.RaisePropertyChanged(nameof(CloseTabEnabled)); + }); + + private TabItem CurrentTab => Tabs[CurrentTabIndex]; + private StringBuilder DefaultContent => new StringBuilder() .AppendLine($"![Logo](avares://Markdowser/Assets/Markdowser-{(Settings.Current.DarkMode ? "Dark" : "Light")}-Transparent.png)") .AppendLine() @@ -139,8 +194,10 @@ public partial class MainWindowViewModel : ViewModelBase if (string.IsNullOrWhiteSpace(Url)) { content = null; - this.RaisePropertyChanged(nameof(Content)); + _ = html.Clear(); + ContentChanged(); Debug.WriteLine("URL is empty."); + CurrentTab.Header = "New Tab"; return; } @@ -152,6 +209,7 @@ public partial class MainWindowViewModel : ViewModelBase content ??= new StringBuilder(); _ = content.Clear(); + _ = html.Clear(); IsBusy = true; Progress = 0; @@ -160,8 +218,6 @@ public partial class MainWindowViewModel : ViewModelBase { Debug.WriteLine("Fetching URL..."); - StringBuilder html = new(); - try { HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url); @@ -173,7 +229,7 @@ public partial class MainWindowViewModel : ViewModelBase IsBusy = false; _ = content.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}"); _ = content.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}"); - this.RaisePropertyChanged(nameof(Content)); + ContentChanged(); return; } @@ -187,6 +243,15 @@ public partial class MainWindowViewModel : ViewModelBase _ = html.AppendLine(await streamReader.ReadLineAsync()); Progress = (int)((double)contentStream.Position / length * 100); } + + HtmlDocument htmlDoc = new HtmlDocument(); + htmlDoc.LoadHtml(html.ToString()); + string title = htmlDoc.DocumentNode.SelectSingleNode("html/head/title")?.InnerText ?? Url; + + Dispatcher.UIThread.Post(() => + { + CurrentTab.Header = title?.Trim() ?? Url; + }); } catch (HttpRequestException e) { @@ -199,16 +264,18 @@ public partial class MainWindowViewModel : ViewModelBase _ = content.AppendLine($"# {e.HttpRequestError}"); _ = content.AppendLine($"Failed to fetch URL: {e.Message}"); - this.RaisePropertyChanged(nameof(Content)); + ContentChanged(); } catch (Exception e) { IsBusy = false; _ = content.AppendLine($"# {e.GetType().Name}"); _ = content.AppendLine($"Failed to fetch URL: {e.Message}"); - this.RaisePropertyChanged(nameof(Content)); + ContentChanged(); } + Progress = 0; + Debug.WriteLine("Converting HTML to markdown..."); string markdown = markdownConverter.Convert(html.ToString()); @@ -240,14 +307,18 @@ public partial class MainWindowViewModel : ViewModelBase } _ = content.AppendLine(trimmedLine); - - //this.RaisePropertyChanged(nameof(Content)); } - Debug.WriteLine("Done processing."); - this.RaisePropertyChanged(nameof(Content)); + ContentChanged(); IsBusy = false; }); } + + private void ContentChanged() + { + this.RaisePropertyChanged(nameof(Content)); + this.RaisePropertyChanged(nameof(RawHtmlViewModel)); + this.RaisePropertyChanged(nameof(RawMarkdownViewModel)); + } } \ No newline at end of file diff --git a/Markdowser/ViewModels/RawHtmlViewModel.cs b/Markdowser/ViewModels/RawHtmlViewModel.cs new file mode 100644 index 0000000..1d4832d --- /dev/null +++ b/Markdowser/ViewModels/RawHtmlViewModel.cs @@ -0,0 +1,8 @@ +using System.Text; + +namespace Markdowser.ViewModels; + +public class RawHtmlViewModel(StringBuilder html) : ViewModelBase +{ + public StringBuilder Html => html; +} \ No newline at end of file diff --git a/Markdowser/ViewModels/RawMarkdownViewModel.cs b/Markdowser/ViewModels/RawMarkdownViewModel.cs new file mode 100644 index 0000000..60f7ad3 --- /dev/null +++ b/Markdowser/ViewModels/RawMarkdownViewModel.cs @@ -0,0 +1,9 @@ +using System; +using System.Text; + +namespace Markdowser.ViewModels; + +public class RawMarkdownViewModel(Func markdown) : ViewModelBase +{ + public StringBuilder Markdown => markdown(); +} \ No newline at end of file diff --git a/Markdowser/ViewModels/SettingsViewModel.cs b/Markdowser/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..3aa3d71 --- /dev/null +++ b/Markdowser/ViewModels/SettingsViewModel.cs @@ -0,0 +1,18 @@ +using Markdowser.Models; + +namespace Markdowser.ViewModels; + +public class SettingsViewModel : ViewModelBase +{ + public string? HomeUrl + { + get => Settings.Current.HomeUrl; + set => Settings.Current.HomeUrl = value; + } + + public bool DarkMode + { + get => Settings.Current.DarkMode; + set => Settings.Current.DarkMode = value; + } +} \ No newline at end of file diff --git a/Markdowser/Views/MainWindow.axaml b/Markdowser/Views/MainWindow.axaml index 1373aec..6f2662e 100644 --- a/Markdowser/Views/MainWindow.axaml +++ b/Markdowser/Views/MainWindow.axaml @@ -11,7 +11,7 @@ mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="Markdowser.Views.MainWindow" x:DataType="vm:MainWindowViewModel" - Icon="/Assets/avalonia-logo.ico" + Icon="/Assets/Markdowser-Light-Transparent.ico" Title="{Binding Title}"> @@ -33,15 +33,21 @@ - - - - - - + + + +