Move code files to src directory and add readme

This commit is contained in:
Stone_Red
2024-04-03 17:25:09 +02:00
parent a74d1285ad
commit 6aeeb585f6
37 changed files with 21 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
[*.cs]
# CS0067: The event 'HomeCommand.CanExecuteChanged' is never used
dotnet_diagnostic.CS0067.severity = none
+30
View File
@@ -0,0 +1,30 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34616.47
MinimumVisualStudioVersion = 10.0.40219.1
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
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{98C02B25-7D7D-4F83-892D-5503FFF3D108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{98C02B25-7D7D-4F83-892D-5503FFF3D108}.Debug|Any CPU.Build.0 = Debug|Any CPU
{98C02B25-7D7D-4F83-892D-5503FFF3D108}.Release|Any CPU.ActiveCfg = Release|Any CPU
{98C02B25-7D7D-4F83-892D-5503FFF3D108}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {93739FBA-8641-4D80-A0C5-64201ABD1B13}
EndGlobalSection
EndGlobal
+15
View File
@@ -0,0 +1,15 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Markdowser.App"
xmlns:local="using:Markdowser"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.DataTemplates>
<local:ViewLocator />
</Application.DataTemplates>
<Application.Styles>
<StyleInclude Source="avares://Semi.Avalonia/Themes/Index.axaml" />
</Application.Styles>
</Application>
+41
View File
@@ -0,0 +1,41 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using Markdowser.Models;
using Markdowser.ViewModels;
using Markdowser.Views;
namespace Markdowser;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
desktop.MainWindow.Closing += (sender, e) =>
{
Settings.SaveSettings();
};
}
base.OnFrameworkInitializationCompleted();
if (Current is not null)
{
Current.RequestedThemeVariant = Settings.Current.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,36 @@
using Avalonia;
using Avalonia.Styling;
using Markdowser.Models;
using Markdowser.Utilities;
using System;
using System.Windows.Input;
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;
}
public void Execute(object? parameter)
{
Application? app = Application.Current;
if (app is not null)
{
ThemeVariant theme = app.ActualThemeVariant;
app.RequestedThemeVariant = theme == ThemeVariant.Dark ? ThemeVariant.Light : ThemeVariant.Dark;
Settings.Current.DarkMode = app.RequestedThemeVariant == ThemeVariant.Dark;
GlobalState.ReloadContent(this);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
using Markdowser.Models;
using Markdowser.Utilities;
using System;
using System.Windows.Input;
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;
}
public void Execute(object? parameter)
{
GlobalState.Url = Settings.Current.HomeUrl ?? string.Empty;
}
}
@@ -0,0 +1,42 @@
using Markdowser.Utilities;
using System;
using System.Diagnostics;
using System.Windows.Input;
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;
}
public void Execute(object? parameter)
{
if (parameter is string url)
{
Debug.WriteLine($"Hyperlink clicked: {url}");
if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
{
GlobalState.Url = url;
}
else if (Uri.IsWellFormedUriString(url, UriKind.Relative) || url.StartsWith('/'))
{
GlobalState.Url = new Uri(new Uri(GlobalState.Url), url).ToString();
}
else
{
Debug.WriteLine($"Invalid URL: {url}");
}
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// This file is used by Code Analysis to maintain SuppressMessage
// attributes that are applied to this project.
// Project-level suppressions either have no target or are given
// a specific target and scoped to a namespace, type, member, etc.
using System.Diagnostics.CodeAnalysis;
[assembly: SuppressMessage("Minor Code Smell", "S1075:URIs should not be hardcoded", Justification = "<Pending>")]
[assembly: SuppressMessage("Minor Code Smell", "CS0067:The event 'ChangeThemeCommand.CanExecuteChanged' is never used", Justification = "<Pending>", Scope = "member", Target = "~N:Markdowser.Commands")]
[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "<Pending>")]
+39
View File
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<None Remove="Assets\Markdowser-Dark-Transparent.ico" />
<None Remove="Assets\Markdowser-Dark-Transparent.png" />
<None Remove="Assets\Markdowser-Dark.png" />
<None Remove="Assets\Markdowser-Light-Transparent.ico" />
<None Remove="Assets\Markdowser-Light-Transparent.png" />
<None Remove="Assets\Markdowser-Light.png" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.10" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.10" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.10" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.10" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.10" />
<PackageReference Include="Markdown.Avalonia" Version="11.0.2" />
<PackageReference Include="Projektanker.Icons.Avalonia" Version="9.1.2" />
<PackageReference Include="Projektanker.Icons.Avalonia.FontAwesome" Version="9.1.2" />
<PackageReference Include="Projektanker.Icons.Avalonia.MaterialDesign" Version="9.1.2" />
<PackageReference Include="ReverseMarkdown" Version="4.3.0" />
<PackageReference Include="Semi.Avalonia" Version="11.0.7.1" />
</ItemGroup>
</Project>
+37
View File
@@ -0,0 +1,37 @@
using Markdowser.Utilities;
using System.IO;
using System.Text.Json;
namespace Markdowser.Models;
public class Settings
{
public static Settings Current { get; set; } = LoadSettings();
public bool DarkMode { get; set; }
public string? HomeUrl { get; set; }
public static void SaveSettings()
{
if (!Directory.Exists(Configuration.ApplicationDataPath))
{
_ = Directory.CreateDirectory(Configuration.ApplicationDataPath);
}
File.WriteAllText(Configuration.SettingsFilePath, JsonSerializer.Serialize(Current));
}
private static Settings LoadSettings()
{
if (File.Exists(Configuration.SettingsFilePath))
{
return JsonSerializer.Deserialize<Settings>(File.ReadAllText(Configuration.SettingsFilePath)) ?? new Settings();
}
else
{
return new Settings();
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.ReactiveUI;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
using Projektanker.Icons.Avalonia.MaterialDesign;
using System;
namespace Markdowser;
internal static class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
_ = BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
{
_ = IconProvider.Current
.Register<FontAwesomeIconProvider>()
.Register<MaterialDesignIconProvider>();
return AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}
}
@@ -0,0 +1,35 @@
using Avalonia;
namespace Markdowser.Utilities;
internal static class ColumnDefinition
{
public static readonly AttachedProperty<bool> IsVisibleProperty = AvaloniaProperty.RegisterAttached<Avalonia.Controls.ColumnDefinition, bool>("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<Avalonia.Controls.GridLength?> LastWidthProperty = AvaloniaProperty.RegisterAttached<Avalonia.Controls.ColumnDefinition, Avalonia.Controls.GridLength?>("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);
}
}
+12
View File
@@ -0,0 +1,12 @@
using System;
using System.IO;
namespace Markdowser.Utilities;
internal static class Configuration
{
public static string DownloadPath => Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
public static string ApplicationDataPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), nameof(Markdowser));
public static string SettingsFilePath => Path.Combine(ApplicationDataPath, "settings.json");
}
+51
View File
@@ -0,0 +1,51 @@
using Avalonia.Controls;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace Markdowser.Utilities;
[SuppressMessage("Major Code Smell", "S4220:Events should have proper arguments", Justification = "<Pending>")]
internal static class GlobalState
{
public static event EventHandler<string>? UrlChanged;
public static event EventHandler? ContentReload;
private static string url = string.Empty;
public static Stack<string> BackHistory { get; } = new();
public static Stack<string> ForwardHistory { get; } = new();
public static string Url
{
get => url;
set
{
if (url != value)
{
BackHistory.Push(url);
ForwardHistory.Clear();
}
url = value;
UrlChanged?.Invoke(null, url);
}
}
public static ObservableCollection<TabItem> Tabs { get; internal set; } = [new TabItem() { Header = "New Tab" }];
internal static void SetUrl(object sender, string url)
{
GlobalState.url = url;
UrlChanged?.Invoke(sender, url);
}
internal static void ReloadContent(object sender)
{
ContentReload?.Invoke(sender, EventArgs.Empty);
}
}
@@ -0,0 +1,58 @@
using Avalonia.Platform;
using Markdown.Avalonia.Utils;
using Markdowser.Models;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
namespace Markdowser.Utilities;
public class HttpPathResolver : IPathResolver
{
private readonly HttpClient httpClient = new();
public string? AssetPathRoot { get; set; }
public IEnumerable<string>? CallerAssemblyNames { get; set; }
public async Task<Stream?>? ResolveImageResource(string relativeOrAbsolutePath)
{
if (string.IsNullOrWhiteSpace(relativeOrAbsolutePath))
{
return GetLogo();
}
if (relativeOrAbsolutePath.StartsWith("avares://"))
{
return AssetLoader.Open(new Uri(relativeOrAbsolutePath))!;
}
if (!Uri.IsWellFormedUriString(relativeOrAbsolutePath, UriKind.Absolute))
{
relativeOrAbsolutePath = new Uri(new Uri(GlobalState.Url), relativeOrAbsolutePath).ToString();
}
Debug.WriteLine($"Resolving image: {relativeOrAbsolutePath}");
HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(relativeOrAbsolutePath)!;
if (!httpResponseMessage.IsSuccessStatusCode)
{
return GetLogo();
}
return await httpResponseMessage.Content.ReadAsStreamAsync();
}
private static Stream GetLogo()
{
if (Settings.Current.DarkMode)
{
return AssetLoader.Open(new Uri("avares://Markdowser/Assets/Markdowser-Dark-Transparent.png"))!;
}
return AssetLoader.Open(new Uri("avares://Markdowser/Assets/Markdowser-Light-Transparent.png"))!;
}
}
+34
View File
@@ -0,0 +1,34 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Markdowser.ViewModels;
using System;
namespace Markdowser;
public class ViewLocator : IDataTemplate
{
public Control? Build(object? data)
{
if (data is null)
return null;
var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
var control = (Control)Activator.CreateInstance(type)!;
control.DataContext = data;
return control;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
@@ -0,0 +1,325 @@
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;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Windows.Input;
namespace Markdowser.ViewModels;
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 TabItem currentTab = null!;
private bool showSidePanel;
private bool isBusy;
private int progress;
public ObservableCollection<TabItem> Tabs => GlobalState.Tabs;
public string Title => $"{nameof(Markdowser)} - {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}";
public TabItem CurrentTab
{
get => currentTab;
set
{
if (currentTab is not null)
{
currentTab.Tag = Url;
}
_ = this.RaiseAndSetIfChanged(ref currentTab!, value);
Url = currentTab?.Tag?.ToString() ?? string.Empty;
FetchUrl();
}
}
public StringBuilder Content
{
get => content ?? DefaultContent;
set => this.RaiseAndSetIfChanged(ref content, value);
}
public string Url
{
get => GlobalState.Url;
set => GlobalState.SetUrl(this, value);
}
public bool ShowSidePanel
{
get => showSidePanel;
set => this.RaiseAndSetIfChanged(ref showSidePanel, value);
}
public bool IsBusy
{
get => isBusy;
set => this.RaiseAndSetIfChanged(ref isBusy, value);
}
public int Progress
{
get => progress;
set
{
_ = this.RaiseAndSetIfChanged(ref progress, value);
this.RaisePropertyChanged(nameof(ProgressIndeterminate));
}
}
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(() =>
{
if (GlobalState.BackHistory.Count > 0)
{
GlobalState.ForwardHistory.Push(GlobalState.Url);
Url = GlobalState.BackHistory.Pop();
FetchUrl();
this.RaisePropertyChanged(nameof(BackEnabled));
this.RaisePropertyChanged(nameof(ForwardEnabled));
}
});
public ICommand Forward => ReactiveCommand.Create(() =>
{
if (GlobalState.ForwardHistory.Count > 0)
{
GlobalState.BackHistory.Push(GlobalState.Url);
Url = GlobalState.ForwardHistory.Pop();
FetchUrl();
this.RaisePropertyChanged(nameof(ForwardEnabled));
this.RaisePropertyChanged(nameof(BackEnabled));
}
});
public ICommand ToggleSidePanel => ReactiveCommand.Create(() => ShowSidePanel = !ShowSidePanel);
public ICommand CloseTab => ReactiveCommand.Create(() =>
{
if (Tabs.Count > 1)
{
int currentIndex = Tabs.IndexOf(CurrentTab);
_ = Tabs.Remove(CurrentTab);
CurrentTab = currentIndex > 0 ? Tabs[currentIndex - 1] : Tabs[0];
this.RaisePropertyChanged(nameof(CloseTabEnabled));
}
});
public ICommand NewTab => ReactiveCommand.Create(() =>
{
TabItem tab = new() { Header = "New Tab", Name = Guid.NewGuid().ToString() };
Tabs.Add(tab);
CurrentTab = tab;
this.RaisePropertyChanged(nameof(CloseTabEnabled));
});
private StringBuilder DefaultContent => new StringBuilder()
.AppendLine($"![Logo](avares://Markdowser/Assets/Markdowser-{(Settings.Current.DarkMode ? "Dark" : "Light")}-Transparent.png)")
.AppendLine()
.AppendLine($"{nameof(Markdowser)} {Assembly.GetExecutingAssembly().GetName().Version?.ToString()}\n")
.AppendLine("A markdown web browser.\n")
.AppendLine("[GitHub](https://github.me.stone-red.net/Markdowser)");
public MainWindowViewModel()
{
httpClient.DefaultRequestHeaders.UserAgent.ParseAdd($"{nameof(Markdowser)}/{Assembly.GetExecutingAssembly().GetName().Version?.ToString()}");
httpClient.Timeout = TimeSpan.FromSeconds(10);
markdownConverter.Config.UnknownTags = ReverseMarkdown.Config.UnknownTagsOption.Bypass;
markdownConverter.Config.SuppressDivNewlines = false;
markdownConverter.Config.SmartHrefHandling = false;
GlobalState.UrlChanged += (sender, url) =>
{
this.RaisePropertyChanged(nameof(ForwardEnabled));
this.RaisePropertyChanged(nameof(BackEnabled));
this.RaisePropertyChanged(nameof(Url));
if (sender == this)
{
return;
}
FetchUrl();
};
GlobalState.ContentReload += (sender, _) =>
{
this.RaisePropertyChanged(nameof(Content));
};
}
private static bool IsValidHttpUri(string uriString, [NotNullWhen(true)] out Uri? uri)
{
return Uri.TryCreate(uriString, UriKind.Absolute, out uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps);
}
[GeneratedRegex("!\\[(.*?)\\]\\((.*?)\\)")]
private static partial Regex MarkdownImage();
private void FetchUrl()
{
if (string.IsNullOrWhiteSpace(Url))
{
content = null;
_ = html.Clear();
ContentChanged();
Debug.WriteLine("URL is empty.");
CurrentTab.Header = "New Tab";
return;
}
if (!IsValidHttpUri(Url, out _))
{
// Search with duckduckgo
Url = $"https://duckduckgo.com/html/?kd=-1&k1=-1&q={Uri.EscapeDataString(Url)}";
}
content ??= new StringBuilder();
_ = content.Clear();
_ = html.Clear();
IsBusy = true;
Progress = 0;
_ = Task.Run(async () =>
{
Debug.WriteLine("Fetching URL...");
try
{
HttpResponseMessage httpResponseMessage = await httpClient.GetAsync(Url);
Url = httpResponseMessage.RequestMessage?.RequestUri?.ToString() ?? Url;
if (!httpResponseMessage.IsSuccessStatusCode)
{
IsBusy = false;
_ = content.AppendLine($"# {(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}");
_ = content.AppendLine($"Failed to fetch URL: {httpResponseMessage.ReasonPhrase}");
ContentChanged();
return;
}
long length = httpResponseMessage.Content.Headers.ContentLength ?? 0;
using Stream contentStream = await httpResponseMessage.Content.ReadAsStreamAsync();
using StreamReader streamReader = new(contentStream);
while (!streamReader.EndOfStream)
{
_ = 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)
{
IsBusy = false;
if (e.StatusCode is not null)
{
_ = content.AppendLine($"# {(int)e.StatusCode} {e.StatusCode}");
}
_ = content.AppendLine($"# {e.HttpRequestError}");
_ = content.AppendLine($"Failed to fetch URL: {e.Message}");
ContentChanged();
}
catch (Exception e)
{
IsBusy = false;
_ = content.AppendLine($"# {e.GetType().Name}");
_ = content.AppendLine($"Failed to fetch URL: {e.Message}");
ContentChanged();
}
Progress = 0;
Debug.WriteLine("Converting HTML to markdown...");
string markdown = markdownConverter.Convert(html.ToString());
Debug.WriteLine("Processing markdown...");
int currentLine = 0;
string[] lines = markdown.Split('\n');
foreach (string line in lines)
{
Progress = (int)((double)currentLine / lines.Length * 100);
currentLine++;
string trimmedLine = line.Trim();
if (trimmedLine.All(c => c == '#'))
{
_ = Content.Append(trimmedLine);
continue;
}
else if (MarkdownImage().IsMatch(trimmedLine))
{
_ = content.AppendLine()
.Append(trimmedLine)
.AppendLine()
.AppendLine();
continue;
}
_ = content.AppendLine(trimmedLine);
}
ContentChanged();
IsBusy = false;
});
}
private void ContentChanged()
{
this.RaisePropertyChanged(nameof(Content));
this.RaisePropertyChanged(nameof(RawHtmlViewModel));
this.RaisePropertyChanged(nameof(RawMarkdownViewModel));
}
}
@@ -0,0 +1,8 @@
using System.Text;
namespace Markdowser.ViewModels;
public class RawHtmlViewModel(StringBuilder html) : ViewModelBase
{
public StringBuilder Html => html;
}
@@ -0,0 +1,9 @@
using System;
using System.Text;
namespace Markdowser.ViewModels;
public class RawMarkdownViewModel(Func<StringBuilder> markdown) : ViewModelBase
{
public StringBuilder Markdown => markdown();
}
@@ -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;
}
}
@@ -0,0 +1,6 @@
using ReactiveUI;
namespace Markdowser.ViewModels;
public class ViewModelBase : ReactiveObject
{
}
+138
View File
@@ -0,0 +1,138 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:cmd="using:Markdowser.Commands"
xmlns:utils="using:Markdowser.Utilities"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:md="https://github.com/whistyun/Markdown.Avalonia"
xmlns:ctxt="clr-namespace:ColorTextBlock.Avalonia;assembly=ColorTextBlock.Avalonia"
xmlns:i="https://github.com/projektanker/icons.avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Markdowser.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
Icon="/Assets/Markdowser-Light-Transparent.ico"
Title="{Binding Title}">
<Design.DataContext>
<!-- This only sets the DataContext for the previewer in an IDE,
to set the actual DataContext for runtime, set the DataContext property in code (look at App.axaml.cs) -->
<vm:MainWindowViewModel />
</Design.DataContext>
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<utils:HttpPathResolver x:Key="HttpPathResolver" />
<cmd:HyperlinkCommand x:Key="HyperlinkCommand" />
<cmd:ChangeThemeCommand x:Key="ChangeThemeCommand" />
<cmd:HomeCommand x:Key="HomeCommand" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Window.Resources>
<Grid RowDefinitions="Auto, *, Auto">
<Grid Grid.Row="0" ColumnDefinitions="*, Auto, Auto">
<TabControl Grid.Column="0" Theme="{DynamicResource ScrollTabControl}" SelectedItem="{Binding CurrentTab, Mode=TwoWay}" ItemsSource="{Binding Tabs}" />
<Button Grid.Column="1" Command="{Binding CloseTab}" IsEnabled="{Binding CloseTabEnabled}" HorizontalAlignment="Right" VerticalAlignment="Top" Height="32" i:Attached.Icon="fa-solid fa-xmark" />
<Button Grid.Column="2" Command="{Binding NewTab}" HorizontalAlignment="Right" VerticalAlignment="Top" Height="32" i:Attached.Icon="fa-solid fa-plus" />
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" utils:ColumnDefinition.IsVisible="{Binding ShowSidePanel}" />
</Grid.ColumnDefinitions>
<md:MarkdownScrollViewer Margin="10 0 10 10" IsEnabled="{Binding !IsBusy}" Markdown="{Binding Content}">
<md:MarkdownScrollViewer.Styles>
<Style Selector="ctxt|CTextBlock">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading1">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading2">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading3">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading4">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading5">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CTextBlock.Heading6">
<Setter Property="Foreground" Value="{DynamicResource SemiColorText0}" />
</Style>
<Style Selector="ctxt|CHyperlink">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLink}" />
</Style>
<Style Selector="ctxt|CHyperlink:pointerover">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkPointerover}" />
</Style>
<Style Selector="ctxt|CHyperlink:pressed">
<Setter Property="Foreground" Value="{DynamicResource SemiColorLinkVisited}" />
</Style>
<Style Selector="ctxt|CImage">
<Setter Property="FittingWhenProtrude" Value="True" />
<Setter Property="LayoutWidth" Value="500" />
<Setter Property="SaveAspectRatio" Value="True" />
</Style>
</md:MarkdownScrollViewer.Styles>
<md:MarkdownScrollViewer.Plugins>
<md:MdAvPlugins PathResolver="{StaticResource HttpPathResolver}" HyperlinkCommand="{StaticResource HyperlinkCommand}" />
</md:MarkdownScrollViewer.Plugins>
</md:MarkdownScrollViewer>
<GridSplitter Grid.Column="1" IsVisible="{Binding ShowSidePanel}" Background="{DynamicResource SemiColorTertiary}" ResizeDirection="Columns" />
<TabControl Grid.Column="2" IsVisible="{Binding ShowSidePanel}" Margin="10">
<TabItem Header="Settings">
<ScrollViewer>
<ContentControl Margin="0 10 0 0" Content="{Binding SettingsViewModel}" />
</ScrollViewer>
</TabItem>
<TabItem Header="Markdown">
<ScrollViewer>
<ContentControl Margin="0 10 0 0" Content="{Binding RawMarkdownViewModel}" />
</ScrollViewer>
</TabItem>
<TabItem Header="HTML">
<ScrollViewer>
<ContentControl Margin="0 10 0 0" Content="{Binding RawHtmlViewModel}" />
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
<Border Grid.Row="2" BorderThickness="0 2 0 0" CornerRadius="0" BorderBrush="{DynamicResource SemiColorTertiary}">
<Grid ColumnDefinitions="Auto, Auto, Auto, Auto, *, Auto">
<Button Grid.Column="0" i:Attached.Icon="fa-solid fa-caret-left" IsEnabled="{Binding BackEnabled}" Command="{Binding Back}" />
<Button Grid.Column="1" i:Attached.Icon="fa-solid fa-caret-right" IsEnabled="{Binding ForwardEnabled}" Command="{Binding Forward}" />
<Button Grid.Column="2" i:Attached.Icon="fa-solid fa-rotate-right" Command="{Binding Browse}" />
<Button Grid.Column="3" i:Attached.Icon="fa-solid fa-house" Command="{StaticResource HomeCommand}" />
<Grid Grid.Column="4">
<TextBox x:Name="urlBar" IsVisible="{Binding !IsBusy}" Watermark="Search DuckDuckGo or type a URL" Text="{Binding Url}" BorderThickness="0">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Browse}" />
</TextBox.KeyBindings>
<TextBox.Styles>
<Style Selector="TextBox:focus /template/ Border#PART_BorderElement">
<Setter Property="BorderThickness" Value="0" />
</Style>
</TextBox.Styles>
</TextBox>
<ProgressBar IsVisible="{Binding IsBusy}" IsIndeterminate="{Binding ProgressIndeterminate}" ShowProgressText="{Binding !ProgressIndeterminate}" Maximum="100" Height="32" Grid.Row="2" Value="{Binding Progress}" />
</Grid>
<Button Grid.Column="5" Command="{Binding ToggleSidePanel}" HorizontalAlignment="Right" i:Attached.Icon="fa-solid fa-table-columns" />
</Grid>
</Border>
</Grid>
</Window>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
+10
View File
@@ -0,0 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="vm:RawHtmlViewModel"
x:Class="Markdowser.Views.RawHtmlView">
<SelectableTextBlock Text="{Binding Html}" TextWrapping="Wrap" />
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class RawHtmlView : UserControl
{
public RawHtmlView()
{
InitializeComponent();
}
}
@@ -0,0 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="vm:RawMarkdownViewModel"
x:Class="Markdowser.Views.RawMarkdownView">
<SelectableTextBlock Text="{Binding Markdown}" TextWrapping="Wrap" />
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class RawMarkdownView : UserControl
{
public RawMarkdownView()
{
InitializeComponent();
}
}
+29
View File
@@ -0,0 +1,29 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:vm="using:Markdowser.ViewModels"
xmlns:cmd="using:Markdowser.Commands"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:i="https://github.com/projektanker/icons.avalonia"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:DataType="vm:SettingsViewModel"
x:Class="Markdowser.Views.SettingsView">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<cmd:ChangeThemeCommand x:Key="ChangeThemeCommand" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</UserControl.Resources>
<StackPanel Spacing="10">
<TextBlock Text="Dark Mode" />
<ToggleSwitch IsChecked="{Binding DarkMode}" Command="{StaticResource ChangeThemeCommand}" />
<TextBlock Text="Home URL" />
<TextBox Text="{Binding HomeUrl}" Watermark="URL, or leave empty to use default" />
</StackPanel>
</UserControl>
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Markdowser.Views;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="Markdowser.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>