Compare commits

...

10 commits

Author SHA1 Message Date
5584ab4ec8 Replace ReactiveUI 2024-05-02 21:44:13 +02:00
43b4d50e43 Notes 2024-04-05 12:31:34 +02:00
4c2b5cca93 Batch Update 2024-03-17 22:27:01 +01:00
693d12b61c Dock 2024-02-26 18:08:18 +01:00
b1d3ec73c9 Remove .Common-project
Currently of no use
2024-02-21 02:17:33 +01:00
232231d20d Unused 2024-02-16 02:30:21 +01:00
a62b5a1f29 Workspace Metadata Rows 2024-02-16 02:24:25 +01:00
e9c6e14965 Merge Common to Desktop 2024-02-16 02:23:58 +01:00
26915defe1 .NET 8.0.2 2024-02-16 02:21:30 +01:00
6a9c9e006c Configuration 2024-02-11 02:39:36 +01:00
89 changed files with 17511 additions and 911 deletions

View file

@ -3,13 +3,13 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.1",
"version": "8.0.3",
"commands": [
"dotnet-ef"
]
},
"dotnet-aspnet-codegenerator": {
"version": "8.0.0",
"version": "8.0.1",
"commands": [
"dotnet-aspnet-codegenerator"
]

View file

@ -2,31 +2,35 @@
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
<AvaloniaVersion>11.0.9</AvaloniaVersion>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.ReactiveUI" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Dock.Avalonia" Version="11.0.0.5" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.1" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.1" />
<PackageVersion Include="Avalonia" Version="11.0.10" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="11.0.6" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.0.10" />
<PackageVersion Include="Avalonia.Desktop" Version="11.0.10" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.0.10" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.0.10" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.2.2" />
<PackageVersion Include="Dock.Avalonia" Version="11.0.0.7" />
<PackageVersion Include="Dock.Model.Mvvm" Version="11.0.0.7" />
<PackageVersion Include="DynamicData" Version="8.4.1" />
<PackageVersion Include="FluentAvaloniaUI" Version="2.0.5" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Configuration.CommandLine" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.FileProviders.Embedded" Version="8.0.3" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.0" />
<PackageVersion Include="ReactiveUI" Version="19.5.41" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="8.0.1" />
<PackageVersion Include="SmartFormat" Version="3.3.2" />
<PackageVersion Include="Splat" Version="15.0.1" />
<PackageVersion Include="Splat.Microsoft.Extensions.DependencyInjection" Version="14.8.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageVersion Include="System.IO.Hashing" Version="8.0.0" />
</ItemGroup>
</Project>

View file

@ -1,5 +1,3 @@
using InkForge.Api.Data.Infrastructure;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
@ -12,25 +10,15 @@ public class ApiDbcontext(
{
public DbSet<WorkspaceEntity> Workspaces { get; set; } = default!;
public DbSet<WorkspaceVersionEntity> WorkspaceVersions { get; set; } = default!;
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<WorkspaceEntity>(options =>
{
options.OwnsOne(m => m.Value);
options.HasKey(m => m.Id);
});
builder.Entity<WorkspaceVersionEntity>(options =>
{
options.OwnsOne(m => m.Value);
options.HasKey(m => m.Version);
options.HasIndex(nameof(WorkspaceVersionEntity.Id), nameof(WorkspaceVersionEntity.Version)).IsUnique();
});
}
}

View file

@ -1,16 +0,0 @@
using Microsoft.AspNetCore.Identity;
namespace InkForge.Api.Data.Domain;
public class Workspace
{
public string Name { get; set; } = default!;
public DateTimeOffset Created { get; set; }
public IdentityUser Owner { get; set; } = default!;
public DateTimeOffset Updated { get; set; }
public DateTimeOffset? Deleted { get; set; }
}

View file

@ -1,9 +0,0 @@
using InkForge.Api.Data.Domain;
using InkForge.Data;
namespace InkForge.Api.Data.Infrastructure
{
public class WorkspaceEntity : Entity<Workspace, int>;
public class WorkspaceVersionEntity : VersionedEntity<Workspace, int>;
}

View file

@ -0,0 +1,21 @@
using InkForge.Data;
using Microsoft.AspNetCore.Identity;
namespace InkForge.Api.Data
{
public class Workspace
{
public DateTimeOffset Created { get; set; }
public DateTimeOffset? Deleted { get; set; }
public string Name { get; set; } = default!;
public IdentityUser Owner { get; set; } = default!;
public DateTimeOffset Updated { get; set; }
}
public class WorkspaceEntity : Entity<Workspace, int>;
}

View file

@ -27,8 +27,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "design", "design", "{C78684
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InkForge.Migrations", "design\InkForge.Migrations\InkForge.Migrations.csproj", "{8DF3397E-2717-49F0-9592-82ABE9327A73}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InkForge.Common", "app\InkForge.Common\InkForge.Common.csproj", "{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -66,10 +64,6 @@ Global
{8DF3397E-2717-49F0-9592-82ABE9327A73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8DF3397E-2717-49F0-9592-82ABE9327A73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8DF3397E-2717-49F0-9592-82ABE9327A73}.Release|Any CPU.Build.0 = Release|Any CPU
{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{DD595B76-5FDE-4C37-822E-CB58BBB02C8C} = {C73D8E17-EA0A-4206-91D4-9E5BD63B3DB0}
@ -78,6 +72,5 @@ Global
{5AFA8AD9-9230-4218-BBFD-BD75F1E752DC} = {84CBD204-9573-4472-9334-68FB360BD6ED}
{F8A7563F-2647-4623-88E7-470D20F25E93} = {A9F8087F-F148-47A5-94AE-F7B6E1D33096}
{8DF3397E-2717-49F0-9592-82ABE9327A73} = {C7868400-84D7-45C5-B594-C30777EE5191}
{DCE2DCD6-D15C-4F0D-8D7F-22FF82F62F6F} = {84CBD204-9573-4472-9334-68FB360BD6ED}
EndGlobalSection
EndGlobal

View file

@ -1,11 +0,0 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="InkForge.Common.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
</Application.Styles>
</Application>

View file

@ -1,39 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using InkForge.Common.ViewModels;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
namespace InkForge.Common;
public partial class App : Application
{
public static readonly StyledProperty<IServiceProvider> ServiceProviderProperty = AvaloniaProperty.Register<App, IServiceProvider>(nameof(ServiceProvider));
public IServiceProvider ServiceProvider => GetValue(ServiceProviderProperty);
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
var viewModel = ActivatorUtilities.GetServiceOrCreateInstance<AppViewModel>(ServiceProvider);
var view = ViewLocator.Current.ResolveView(viewModel)!;
view.ViewModel = viewModel;
_ = ApplicationLifetime switch
{
IClassicDesktopStyleApplicationLifetime desktop => desktop.MainWindow = view as Window,
ISingleViewApplicationLifetime singleView => singleView.MainView = view as Control,
_ => throw new NotSupportedException(),
};
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -1,5 +0,0 @@
namespace InkForge.Common.Controllers;
public class WorkspaceController
{
}

View file

@ -1,15 +0,0 @@
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
namespace InkForge.Common.Data;
public class NoteDbContextFactory : IDbContextFactory<NoteDbContext>
{
public NoteDbContext CreateDbContext()
{
return new NoteDbContext(null);
}
}

View file

@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>InkForge</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.ReactiveUI" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\shared\InkForge.Data\InkForge.Data.csproj" />
<ProjectReference Include="..\..\shared\migrations\InkForge.Sqlite\InkForge.Sqlite.csproj" />
</ItemGroup>
</Project>

View file

@ -1,29 +0,0 @@
using InkForge.Common.Controllers;
using InkForge.Common.Data;
using InkForge.Common.ViewModels;
using InkForge.Common.ViewModels.Landing;
using InkForge.Data;
using ReactiveUI;
using Splat;
namespace Microsoft.Extensions.DependencyInjection;
public static class InkForgeServiceCollections
{
public static IServiceCollection AddInkForge(this IServiceCollection services)
{
services.AddHttpClient();
services.AddDbContextFactory<NoteDbContext, NoteDbContextFactory>();
services.AddSingleton<LandingViewModel>();
services.AddSingleton<LandingViewModelFactory>();
services.AddSingleton<WorkspaceController>();
Locator.CurrentMutable.RegisterViewsForViewModels(typeof(InkForgeServiceCollections).Assembly);
return services;
}
}

View file

@ -1,22 +0,0 @@
namespace Microsoft.Extensions.DependencyInjection
{
public static class TypeFactory<TFactory, T>
where TFactory : struct, IObjectParameters<TFactory>
{
private static ObjectFactory<T>? s_objectFactory;
public static T Create(in TFactory factory, IServiceProvider serviceProvider)
{
s_objectFactory ??= ActivatorUtilities.CreateFactory<T>(TFactory.Types);
return s_objectFactory(serviceProvider, (object[])factory);
}
}
public interface IObjectParameters<T>
where T : struct, IObjectParameters<T>
{
abstract static Type[] Types { get; }
abstract static implicit operator object[](in T self);
}
}

View file

@ -1,10 +0,0 @@
using ReactiveUI;
namespace InkForge.Common.ReactiveUI;
public abstract class RoutableReactiveObject(IScreen screen) : ReactiveObject, IRoutableViewModel
{
public abstract string? UrlPathSegment { get; }
public IScreen HostScreen => screen;
}

View file

@ -1,23 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Common.ReactiveUI;
public interface IViewModelFactory<T, TCreator>
{
abstract static ObjectFactory<T> CreateObjectFactory();
abstract static TCreator GetCreator(ObjectFactory<T> factory, IServiceProvider serviceProvider);
}
public class ViewModelFactory<T, TFactory, TCreator>
where TFactory : IViewModelFactory<T, TCreator>
where TCreator : Delegate
{
private static ObjectFactory<T>? s_factory;
public TCreator CreateFactory(IServiceProvider serviceProvider)
{
s_factory ??= TFactory.CreateObjectFactory();
return TFactory.GetCreator(s_factory, serviceProvider);
}
}

View file

@ -1,21 +0,0 @@
using InkForge.Common.Controllers;
using ReactiveUI;
namespace InkForge.Common.ViewModels;
public class AppViewModel : ReactiveObject
{
private object _view;
public object View
{
get => _view;
set => this.RaiseAndSetIfChanged(ref _view, value);
}
public AppViewModel(WorkspaceController workspace, LandingViewModel landingViewModel)
{
View = landingViewModel;
}
}

View file

@ -1,23 +0,0 @@
using InkForge.Common.Controllers;
using InkForge.Common.ReactiveUI;
using ReactiveUI;
namespace InkForge.Common.ViewModels.Landing;
public class CreateWorkspaceViewModel : LandingViewModelBase
{
public override string? UrlPathSegment => null;
private string workspaceName;
public string WorkspaceName
{
get => workspaceName;
set => this.RaiseAndSetIfChanged(ref workspaceName, value);
}
public CreateWorkspaceViewModel(LandingViewModel landing, WorkspaceController workspace) : base(landing)
{
}
}

View file

@ -1,8 +0,0 @@
using InkForge.Common.ReactiveUI;
namespace InkForge.Common.ViewModels.Landing;
public abstract class LandingViewModelBase(LandingViewModel landing) : RoutableReactiveObject(landing)
{
public LandingViewModel Landing => landing;
}

View file

@ -1,21 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Common.ViewModels.Landing;
public class LandingViewModelFactory(IServiceProvider serviceProvider)
{
public T Create<T>(LandingViewModel landing) where T : LandingViewModelBase
{
LandingViewModelsObjectParameters objectParameters = new(landing);
return TypeFactory<LandingViewModelsObjectParameters, T>.Create(objectParameters, serviceProvider);
}
readonly record struct LandingViewModelsObjectParameters(
LandingViewModel Landing
) : IObjectParameters<LandingViewModelsObjectParameters>
{
public static Type[] Types => [typeof(LandingViewModel)];
public static implicit operator object[](in LandingViewModelsObjectParameters self) => [self.Landing];
}
}

View file

@ -1,17 +0,0 @@
using System.Collections.ObjectModel;
using InkForge.Common.ReactiveUI;
namespace InkForge.Common.ViewModels.Landing;
public class OpenRecentViewModel : LandingViewModelBase
{
public override string? UrlPathSegment => null;
private readonly ReadOnlyObservableCollection<RecentItemViewModel> recentItems;
public ReadOnlyObservableCollection<RecentItemViewModel> RecentItems => recentItems;
public OpenRecentViewModel(LandingViewModel landing) : base(landing)
{
}
}

View file

@ -1,9 +0,0 @@
using ReactiveUI;
namespace InkForge.Common.ViewModels.Landing;
public record class RecentItemViewModel(
DateTimeOffset Created,
string Name,
DateTimeOffset LastUsed
) : ReactiveRecord;

View file

@ -1,29 +0,0 @@
using System.Reactive.Linq;
using InkForge.Common.ViewModels.Landing;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
namespace InkForge.Common.ViewModels;
public class LandingViewModel : ReactiveObject, IScreen
{
private readonly LandingViewModelFactory _factory;
public RoutingState Router { get; } = new();
public LandingViewModel(LandingViewModelFactory factory)
{
_factory = factory;
Router.CurrentViewModel.Where(x => x is null)
.SelectMany(Observable.Return(factory.Create<OpenRecentViewModel>(this)))
.InvokeCommand<IRoutableViewModel>(Router.NavigateAndReset);
}
public void Navigate<T>() where T : LandingViewModelBase
{
}
}

View file

@ -1,19 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:reactiveui="http://reactiveui.net"
xmlns:ifcvm="using:InkForge.Common.ViewModels"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Common.Views.LandingView"
x:DataType="ifcvm:LandingViewModel">
<Grid RowDefinitions="Auto, *, Auto">
<Label Content="InkForge"
Grid.Row="0" />
<reactiveui:RoutedViewHost Router="{CompiledBinding Router}"
Grid.Row="1" />
</Grid>
</UserControl>

View file

@ -1,13 +0,0 @@
using Avalonia.ReactiveUI;
using InkForge.Common.ViewModels;
namespace InkForge.Common.Views;
public partial class LandingView : ReactiveUserControl<LandingViewModel>
{
public LandingView()
{
InitializeComponent();
}
}

View file

@ -1,10 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
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"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Common.Views.LandingViews.CreateWorkspaceView">
Welcome to Avalonia!
</UserControl>

View file

@ -1,13 +0,0 @@
using Avalonia.ReactiveUI;
using InkForge.Common.ViewModels.Landing;
namespace InkForge.Common.Views.LandingViews;
public partial class CreateWorkspaceView : ReactiveUserControl<CreateWorkspaceViewModel>
{
public CreateWorkspaceView()
{
InitializeComponent();
}
}

View file

@ -1,29 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:vm="using:InkForge.Common.ViewModels.Landing"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Common.Views.LandingViews.OpenRecentView"
x:DataType="vm:OpenRecentViewModel">
<Grid RowDefinitions="Auto, *">
<Label Content="Open Recent"
Grid.Row="0" />
<DataGrid IsReadOnly="true"
ItemsSource="{CompiledBinding RecentItems}"
Grid.Row="1">
<DataGrid.Columns>
<DataGridTextColumn Header="Created"
Binding="{CompiledBinding Created, StringFormat={}{0:d}}" />
<DataGridTextColumn Header="Name"
Width="*"
Binding="{CompiledBinding Name}" />
<DataGridTextColumn Header="Last Used"
Binding="{CompiledBinding LastUsed, StringFormat={}{0:d}}" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>

View file

@ -1,13 +0,0 @@
using Avalonia.ReactiveUI;
using InkForge.Common.ViewModels.Landing;
namespace InkForge.Common.Views.LandingViews;
public partial class OpenRecentView : ReactiveUserControl<OpenRecentViewModel>
{
public OpenRecentView()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,17 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="InkForge.Desktop.App"
RequestedThemeVariant="Default">
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
<DockFluentTheme />
</Application.Styles>
<Application.Resources>
<FontFamily x:Key="FluentSystemIcons-Filled">/Assets/Fonts#FluentSystemIcons-Filled</FontFamily>
<FontFamily x:Key="FluentSystemIcons-Regular">/Assets/Fonts#FluentSystemIcons-Regular</FontFamily>
</Application.Resources>
</Application>

View file

@ -0,0 +1,87 @@
using Avalonia;
using Avalonia.Controls.Templates;
using Avalonia.Markup.Xaml;
using DynamicData;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Splat;
using Splat.Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop;
public partial class App : Application
{
public static readonly StyledProperty<IDataTemplate> AppDataTemplateProperty
= AvaloniaProperty.Register<App, IDataTemplate>(
name: nameof(AppDataTemplate),
coerce: OnAppDataTemplateChanged);
public static readonly StyledProperty<IServiceProvider> ServiceProviderProperty
= AvaloniaProperty.Register<App, IServiceProvider>(
name: nameof(ServiceProvider),
coerce: OnServiceProviderChanged);
public IDataTemplate AppDataTemplate => GetValue(AppDataTemplateProperty);
public IServiceProvider ServiceProvider => GetValue(ServiceProviderProperty);
public static void Configure(IServiceCollection services, ConfigurationManager configuration)
{
configuration.SetBasePath(AppContext.BaseDirectory);
configuration.AddJsonFile(
new ManifestEmbeddedFileProvider(typeof(App).Assembly),
"Properties/appsettings.json", false, false);
configuration.AddJsonFile(
Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolderOption.DoNotVerify),
"InkForge",
"usersettings.json"), true, true);
configuration.AddJsonFile("appsettings.json", true, true);
services.UseMicrosoftDependencyResolver();
Locator.CurrentMutable.InitializeSplat();
services.AddInkForge();
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
private static IDataTemplate OnAppDataTemplateChanged(AvaloniaObject @object, IDataTemplate dataTemplate)
{
var host = (IDataTemplateHost)@object;
var original = @object.GetValue(AppDataTemplateProperty);
if (original is null && dataTemplate is not null)
{
host.DataTemplates.Add(dataTemplate);
}
else if (original is not null)
{
if (dataTemplate is null)
{
host.DataTemplates.Remove(original);
}
else
{
host.DataTemplates.ReplaceOrAdd(original, dataTemplate);
}
}
return dataTemplate!;
}
private static IServiceProvider OnServiceProviderChanged(AvaloniaObject @object, IServiceProvider provider)
{
provider.UseMicrosoftDependencyResolver();
@object.SetValue(AppDataTemplateProperty, provider.GetRequiredService<IDataTemplate>());
return provider;
}
}

View file

@ -0,0 +1,45 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using InkForge.Desktop.ViewModels;
using InkForge.Desktop.ViewModels.Documents;
using InkForge.Desktop.ViewModels.Workspaces;
using InkForge.Desktop.Views.Documents;
using InkForge.Desktop.Views.Workspaces;
namespace InkForge.Desktop;
public class AppViewLocator : IDataTemplate
{
public Control? Build(object? param)
{
#pragma warning disable CS8509 // The switch expression does not handle all possible values of its input type (it is not exhaustive).
return param switch
#pragma warning restore CS8509 // The switch expression does not handle all possible values of its input type (it is not exhaustive).
{
NoteEditDocumentViewModel viewModel => _(new NoteEditDocument(), viewModel),
ViewModels.Tools.WorkspaceTool viewModel => _(new Views.Tools.WorkspaceTool(), viewModel),
WelcomePageDocumentViewModel viewModel => _(new WelcomePageDocument(), viewModel),
WorkspaceViewModel viewModel => _(new WorkspaceView(), viewModel),
};
static TView _<TView, TViewModel>(TView view, TViewModel viewModel)
where TViewModel : class
where TView : StyledElement
{
view.DataContext = viewModel;
return view;
}
}
public bool Match(object? data)
{
return data is
NoteEditDocumentViewModel or
RecentItemViewModel or
ViewModels.Tools.WorkspaceTool or
WelcomePageDocumentViewModel or
WorkspaceViewModel;
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
1.1.227

View file

@ -0,0 +1,175 @@
using System.IO.Hashing;
using System.Runtime.InteropServices;
using System.Text.Json;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Avalonia.Platform;
namespace InkForge.Desktop.Controls;
public class FluentSymbolIcon : IconElement
{
public static readonly StyledProperty<int> IconSizeProperty
= AvaloniaProperty.Register<FluentSymbolIcon, int>(nameof(IconSize), defaultValue: 20);
public static readonly StyledProperty<FontIconStyle> IconStyleProperty
= AvaloniaProperty.Register<FluentSymbolIcon, FontIconStyle>(nameof(IconStyle));
public static readonly StyledProperty<string> SymbolProperty
= AvaloniaProperty.Register<FluentSymbolIcon, string>(nameof(Symbol));
private static readonly Dictionary<(FontIconStyle, uint Key), string> _glyphCache = [];
private static readonly Dictionary<FontIconStyle, FontFamily> _iconFonts = [];
private TextLayout? _textLayout;
public int IconSize
{
get => GetValue(IconSizeProperty);
set => SetValue(IconSizeProperty, value);
}
public FontIconStyle IconStyle
{
get => GetValue(IconStyleProperty);
set => SetValue(IconStyleProperty, value);
}
public string Symbol
{
get => GetValue(SymbolProperty);
set => SetValue(SymbolProperty, value);
}
static FluentSymbolIcon()
{
AffectsMeasure<FluentSymbolIcon>([
IconStyleProperty,
SymbolProperty,
]);
}
public override void Render(DrawingContext context)
{
_textLayout ??= GenerateText();
var dstRect = new Rect(Bounds.Size);
using (context.PushClip(dstRect))
{
var pt = new Point(dstRect.Center.X - _textLayout.Width / 2,
dstRect.Center.Y - _textLayout.Height / 2);
_textLayout.Draw(context, pt);
}
}
protected override Size MeasureOverride(Size availableSize)
{
_textLayout ??= GenerateText();
return new Size(_textLayout.Width, _textLayout.Height);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
switch (change.Property.Name)
{
case nameof(IconSize):
case nameof(IconStyle):
case nameof(Symbol):
InvalidateSymbolLayout();
break;
}
}
protected override void OnMeasureInvalidated()
{
_textLayout?.Dispose();
_textLayout = null;
base.OnMeasureInvalidated();
}
private TextLayout GenerateText()
{
var glyph = GetIconGlyph(this);
if (!_iconFonts.TryGetValue(IconStyle, out var fontFamily))
{
_iconFonts[IconStyle] = fontFamily = new FontFamily($"avares://InkForge/Assets/Fonts#FluentSystemIcons-{IconStyle}");
}
return new TextLayout(glyph, new Typeface(fontFamily), FontSize, Foreground, TextAlignment.Left);
}
private void InvalidateSymbolLayout()
{
InvalidateMeasure();
}
private static string GetIconGlyph(FluentSymbolIcon icon)
{
ReadOnlySpan<char> glyphKey = $"ic_fluent_{icon.Symbol}_{icon.IconSize:0}";
var hash = Hash(glyphKey, icon.IconStyle);
if (!_glyphCache.TryGetValue((icon.IconStyle, hash), out var glyph))
{
glyph = LoadIcons(icon.IconStyle, hash);
}
if (string.IsNullOrWhiteSpace(glyph))
{
return string.Empty;
}
return glyph;
}
private static uint Hash(ReadOnlySpan<char> key, FontIconStyle iconStyle)
{
return XxHash32.HashToUInt32(MemoryMarshal.AsBytes(key), (int)iconStyle);
}
private static string LoadIcons(FontIconStyle iconStyle, uint key)
{
Optional<string>? glyph = Optional<string>.Empty;
using (var stream = AssetLoader.Open(new($"avares://InkForge/Assets/Fonts/FluentSystemIcons-{iconStyle}.json")))
using (var document = JsonDocument.Parse(stream))
{
foreach (var element in document.RootElement.EnumerateObject())
{
if (element.Value.ValueKind is not JsonValueKind.Number)
{
continue;
}
var typeSeparator = element.Name.LastIndexOf('_');
if (typeSeparator == -1)
{
continue;
}
ReadOnlySpan<char> elementKey = element.Name.AsSpan(0, typeSeparator);
var hash = Hash(elementKey, iconStyle);
var elementGlyph = char.ConvertFromUtf32(element.Value.GetInt32())!;
if (hash == key)
{
glyph = glyph switch
{
{ HasValue: false } => elementGlyph,
_ => default(Optional<string>?)
};
}
_glyphCache[(iconStyle, hash)] = elementGlyph;
}
}
return glyph?.GetValueOrDefault() ?? string.Empty;
}
public enum FontIconStyle
{
Regular, Filled
}
}

View file

@ -0,0 +1,27 @@
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using SmartFormat;
using InkForge.Desktop.Data.Options;
namespace InkForge.Desktop.Data;
public class NoteDbContextFactory(LocalWorkspaceOptions options, IConfiguration configuration) : IDbContextFactory<NoteDbContext>
{
private string? _connectionString;
public NoteDbContext CreateDbContext()
{
_connectionString ??= Smart.Format(configuration.GetConnectionString("DefaultConnection")!, new
{
WorkspaceFile = options.DbPath
});
DbContextOptionsBuilder<NoteDbContext> builder = new();
builder.UseSqlite(_connectionString, o => o.MigrationsAssembly("InkForge.Sqlite"));
return new NoteDbContext(builder.Options);
}
}

View file

@ -0,0 +1,6 @@
namespace InkForge.Desktop.Data.Options;
public class LocalWorkspaceOptions
{
public string DbPath { get; set; } = default!;
}

View file

@ -6,16 +6,42 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>InkForge</AssemblyName>
<RootNamespace>InkForge.Desktop</RootNamespace>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
<!-- <TrimMode>partial</TrimMode> -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Controls.DataGrid" />
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Dock.Avalonia" />
<PackageReference Include="Dock.Model.Mvvm" />
<PackageReference Include="DynamicData" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="SmartFormat" />
<PackageReference Include="Splat" />
<PackageReference Include="Splat.Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="System.IO.Hashing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InkForge.Common\InkForge.Common.csproj" />
<ProjectReference Include="..\..\shared\InkForge.Data\InkForge.Data.csproj" />
<ProjectReference Include="..\..\shared\migrations\InkForge.Sqlite\InkForge.Sqlite.csproj" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Properties\appsettings.json" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,85 @@
using Avalonia;
using Dock.Model.Controls;
using Dock.Model.Core;
using Dock.Model.Mvvm;
using Dock.Model.Mvvm.Controls;
using DynamicData.Binding;
using InkForge.Desktop.Managers;
using InkForge.Desktop.Models;
using InkForge.Desktop.ViewModels.Documents;
using InkForge.Desktop.ViewModels.Tools;
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop;
public class InkForgeFactory : Factory
{
private readonly IDock _mainDock;
private readonly IRootDock _rootDock;
private readonly WelcomePageDocumentViewModel _welcomePage;
private readonly WorkspaceTool _workspaceTool;
public InkForgeFactory(WorkspaceManager workspace)
{
_rootDock = CreateRootDock();
_mainDock = CreateDockDock();
_mainDock.IsCollapsable = false;
_mainDock.CanClose = false;
_welcomePage = CreateWelcomePageDocumentViewModel();
_workspaceTool = CreateWorkspaceTool();
workspace.WhenValueChanged(m => m.Workspace).Subscribe(OnWorkspaceChanged);
}
public override IRootDock CreateLayout()
{
ToolDock toolDock = new()
{
Alignment = Alignment.Left,
Proportion = 0.25,
VisibleDockables = [_workspaceTool],
};
ProportionalDock windowLayoutContent = new()
{
Orientation = Orientation.Horizontal,
VisibleDockables = [toolDock, new ProportionalDockSplitter(), _mainDock]
};
_rootDock.VisibleDockables = [windowLayoutContent];
_rootDock.DefaultDockable = windowLayoutContent;
return _rootDock;
}
private static WelcomePageDocumentViewModel CreateWelcomePageDocumentViewModel()
{
return ActivatorUtilities.CreateInstance<WelcomePageDocumentViewModel>(
Application.Current!.GetValue(App.ServiceProviderProperty)
);
}
private static ViewModels.Tools.WorkspaceTool CreateWorkspaceTool()
{
return ActivatorUtilities.CreateInstance<ViewModels.Tools.WorkspaceTool>(
Application.Current!.GetValue(App.ServiceProviderProperty)
);
}
private void OnWorkspaceChanged(Workspace? workspace)
{
IDockable dock = workspace switch
{
null => _welcomePage,
_ => workspace.Services.GetRequiredService<DocumentManager>().Dock,
};
AddDockable(_mainDock, dock);
CloseOtherDockables(dock);
}
}

View file

@ -0,0 +1,38 @@
using AvaloniaEdit.Document;
using CommunityToolkit.Mvvm.Input;
using Dock.Model.Controls;
using InkForge.Desktop.Models;
using InkForge.Desktop.ViewModels.Documents;
namespace InkForge.Desktop.Managers;
public partial class DocumentManager
{
private readonly InkForgeFactory _factory;
public IDocumentDock Dock { get; }
public DocumentManager(NoteStore noteStore, InkForgeFactory factory)
{
_factory = factory;
Dock = factory.CreateDocumentDock();
Dock.IsCollapsable = false;
Dock.CanCreateDocument = true;
Dock.CreateDocument = CreateDocumentCommand;
}
[RelayCommand]
private void OnCreateDocument()
{
NoteEditDocumentViewModel editViewModel = new(new()
{
Name = "Untitled Note",
}, new());
_factory.AddDockable(Dock, editViewModel);
_factory.SetActiveDockable(editViewModel);
_factory.SetFocusedDockable(Dock, editViewModel);
}
}

View file

@ -0,0 +1,89 @@
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using InkForge.Data;
using InkForge.Desktop.Data.Options;
using InkForge.Desktop.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.Managers;
public partial class WorkspaceManager(IServiceProvider serviceProvider) : ObservableObject
{
private readonly IServiceProvider _serviceProvider = serviceProvider;
[ObservableProperty] private Workspace? _workspace;
public ValueTask CloseWorkspace()
{
Workspace?.Dispose();
Workspace = null;
return ValueTask.CompletedTask;
}
public async ValueTask OpenWorkspace(string path, bool createFile = false)
{
await CloseWorkspace().ConfigureAwait(false);
if (await CreateLocalWorkspace(path, createFile).ConfigureAwait(false) is { } workspace)
{
Workspace = workspace;
}
}
private async ValueTask<Workspace?> CreateLocalWorkspace(string path, bool createFile)
{
FileInfo file = new(path);
if (!(createFile || file.Exists))
{
return null;
}
file.Directory!.Create();
IServiceScope? scope = null;
IWorkspaceContext workspaceContext;
try
{
scope = _serviceProvider.CreateScope();
var serviceProvider = scope.ServiceProvider;
var options = serviceProvider.GetRequiredService<LocalWorkspaceOptions>();
options.DbPath = path;
workspaceContext = serviceProvider.GetRequiredService<IWorkspaceContext>();
workspaceContext.Workspace = new Workspace(scope)
{
Name = Path.GetFileNameWithoutExtension(file.Name),
Options = options,
};
var dbFactory = serviceProvider.GetRequiredService<IDbContextFactory<NoteDbContext>>();
await using var dbContext = await dbFactory.CreateDbContextAsync().ConfigureAwait(false);
var db = dbContext.Database;
if ((await db.GetPendingMigrationsAsync().ConfigureAwait(false)).Any())
{
if ((await db.GetAppliedMigrationsAsync().ConfigureAwait(false)).Any())
{
file.CopyTo(Path.ChangeExtension(file.FullName, $"{DateTime.Now:s}{file.Extension}"));
}
await db.MigrateAsync().ConfigureAwait(false);
}
await serviceProvider.GetRequiredService<NoteStore>().Load().ConfigureAwait(false);
scope = null;
}
catch (Exception)
{
// Show Error through TopLevels.ActiveTopLevel
return null;
}
finally
{
scope?.Dispose();
}
return workspaceContext.Workspace;
}
}

View file

@ -0,0 +1,6 @@
namespace InkForge.Desktop.MarkupExtensions;
public class FluentSymbolIconExtension
{
}

View file

@ -0,0 +1,48 @@
using Avalonia.Controls.Templates;
using Dock.Model.Core;
using InkForge.Data;
using InkForge.Desktop;
using InkForge.Desktop.Data;
using InkForge.Desktop.Data.Options;
using InkForge.Desktop.Managers;
using InkForge.Desktop.Models;
using InkForge.Desktop.ViewModels.Workspaces;
using InkForge.Desktop.ViewModels.Workspaces.Internal;
using Microsoft.EntityFrameworkCore;
namespace Microsoft.Extensions.DependencyInjection;
public static class InkForgeServiceCollections
{
public static IServiceCollection AddInkForge(this IServiceCollection services)
{
services.AddHttpClient();
// Singletons
// - Concrete
services.AddSingleton<InkForgeFactory>();
services.AddSingleton<WorkspaceManager>();
// - Service
services.AddSingleton<IDataTemplate, AppViewLocator>();
services.AddSingleton<IWorkspaceViewModelFactory, WorkspaceViewModelFactory>();
// Scoped
// - Concrete
services.AddScoped<DocumentManager>();
services.AddScoped<LocalWorkspaceOptions>();
services.AddScoped<NoteStore>();
// - Service
services.AddScoped<IDbContextFactory<NoteDbContext>, NoteDbContextFactory>();
services.AddScoped<IWorkspaceContext, WorkspaceContext>();
// - Forwarders
services.AddScoped(s => s.GetRequiredService<IWorkspaceContext>().Workspace!);
return services;
}
}

View file

@ -0,0 +1,12 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace InkForge.Desktop.Models;
public partial class Note : ObservableObject
{
[ObservableProperty] private DateTimeOffset _createdTime;
[ObservableProperty] private int _id;
[ObservableProperty] private string _name = default!;
[ObservableProperty] private int? _parentId;
[ObservableProperty] private DateTimeOffset _updatedTime;
}

View file

@ -0,0 +1,81 @@
using Avalonia.Collections;
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
namespace InkForge.Desktop.Models;
public class NoteStore(IDbContextFactory<NoteDbContext> dbContextFactory)
{
public AvaloniaDictionary<int, Note> Notes { get; } = [];
public async ValueTask Load()
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
Notes.Clear();
await foreach (var note in dbContext.Notes.AsAsyncEnumerable().ConfigureAwait(false))
{
Notes.Add(note.Id, Map(note));
}
}
public void AddNote(Note note)
{
using var dbContext = dbContextFactory.CreateDbContext();
var entity = Map(note);
var entry = dbContext.Notes.Add(entity);
dbContext.SaveChanges();
}
public Note? GetById(int id)
{
if (!Notes.TryGetValue(id, out var note))
{
using var dbContext = dbContextFactory.CreateDbContext();
if (dbContext.Notes.Find(id) is not { } dbNote)
{
return null;
}
Notes.Add(id, note = Map(dbNote));
}
return note;
}
private NoteEntity Map(Note note)
{
return new()
{
Id = note.Id,
Value = new()
{
Name = note.Name,
Created = note.CreatedTime,
Updated = note.UpdatedTime,
},
Parent = note.ParentId switch
{
{ } parentId => new()
{
Id = parentId
},
_ => null
},
};
}
private Note Map(NoteEntity entity)
{
return new()
{
Id = entity.Id,
Name = entity.Value.Name,
CreatedTime = entity.Value.Created,
UpdatedTime = entity.Value.Updated,
ParentId = entity.Parent?.Id
};
}
}

View file

@ -0,0 +1,131 @@
using System.Buffers;
using System.Text;
using Avalonia.Collections;
using AvaloniaEdit.Document;
using InkForge.Data;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace InkForge.Desktop.Models;
public class TextDocumentStore(IDbContextFactory<NoteDbContext> dbContextFactory)
{
private readonly AvaloniaDictionary<int, TextDocument> _cache = [];
public void Close(int id)
{
if (_cache.Remove(id, out var document))
{
document.Remove(0, document.TextLength);
}
}
public TextDocument Get(int id)
{
if (!_cache.TryGetValue(id, out var document))
{
using var dbContext = dbContextFactory.CreateDbContext();
var connection = dbContext.Database.GetDbConnection();
using var command = connection.CreateCommand();
command.CommandText = $"SELECT rowid FROM Blobs WHERE Id={id}";
using var commandReader = command.ExecuteReader();
document = new();
if (commandReader.Read())
{
using SqliteBlob stream = new((SqliteConnection)connection, "Blob", "Value", commandReader.GetInt64(0), true);
CopyDocumentContent(stream, document);
}
_cache.Add(id, document);
}
return document;
static void CopyDocumentContent(Stream stream, TextDocument document)
{
DocumentTextWriter writer = new(document, 0);
StreamReader reader = new(stream, Encoding.UTF8);
Span<char> buffer = stackalloc char[128];
while (!reader.EndOfStream)
{
var read = reader.Read(buffer);
writer.Write(buffer[..read]);
}
}
}
public int? Save(int? id, TextDocument textDocument)
{
var state = textDocument.CreateSnapshot();
var bytes = TextBytes(state);
using var dbContext = dbContextFactory.CreateDbContext();
var connection = dbContext.Database.GetDbConnection();
using var transaction = dbContext.Database.BeginTransaction();
using var command = connection.CreateCommand();
command.CommandText = id switch
{
null => $"INSERT INTO Blobs(Value) VALUES(zeroblob({bytes})) RETURNING (Id, rowid)",
_ => $"UPDATE Blobs SET Value=zeroblob({bytes}) WHERE Id={id} RETURNING (Id, rowid)"
};
using var commandReader = command.ExecuteReader();
if (!commandReader.Read())
{
return null;
}
id = commandReader.GetInt32(0);
using (SqliteBlob stream = new((SqliteConnection)connection, "Blobs", "Value", commandReader.GetInt64(1)))
{
using StreamWriter writer = new(stream, Encoding.UTF8);
state.WriteTextTo(writer);
}
transaction.Commit();
_cache.TryAdd((int)id, textDocument);
return id;
static int TextBytes(ITextSource text)
{
int length = 0;
Span<byte> byteBuffer = stackalloc byte[128];
using var reader = text.CreateReader();
ArrayBufferWriter<char> buffer = new();
var encoder = Encoding.UTF8.GetEncoder();
while (reader.Peek() != -1)
{
var memory = buffer.GetMemory();
buffer.Advance(reader.Read(memory.Span));
ReadOnlySpan<char> chars = buffer.WrittenSpan;
convert:
encoder.Convert(buffer.WrittenSpan, byteBuffer, false, out var charsUsed, out var bytesUsed, out _);
chars = chars[charsUsed..];
if (bytesUsed > 0)
{
length += bytesUsed;
goto convert;
}
Span<char> remaining = [];
if (!chars.IsEmpty)
{
remaining = buffer.GetSpan(chars.Length);
chars.CopyTo(remaining);
}
buffer.Clear();
if (!remaining.IsEmpty)
{
buffer.Write(remaining);
}
}
return length;
}
}
}

View file

@ -0,0 +1,31 @@
using InkForge.Desktop.Data.Options;
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.Models;
public sealed class Workspace(IServiceScope scope) : IDisposable
{
private bool _disposedValue;
private IServiceScope? _scope = scope;
public string Name { get; set; } = default!;
public LocalWorkspaceOptions Options { get; set; } = default!;
public IServiceProvider Services { get; } = scope.ServiceProvider;
public void Dispose()
{
if (!_disposedValue)
{
if (_scope is { })
{
_scope.Dispose();
_scope = null;
}
_disposedValue = true;
}
}
}

View file

@ -0,0 +1,12 @@
namespace InkForge.Desktop.Models
{
public interface IWorkspaceContext
{
Workspace? Workspace { get; set; }
}
public class WorkspaceContext : IWorkspaceContext
{
public Workspace? Workspace { get; set; }
}
}

View file

@ -1,50 +1,30 @@
using Avalonia;
using Avalonia.ReactiveUI;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using InkForge.Common;
using InkForge.Common.ViewModels;
using InkForge.Data;
using InkForge.Desktop;
using InkForge.Desktop.Views;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI;
using Splat;
using Splat.Microsoft.Extensions.DependencyInjection;
static class Program
{
[STAThread]
public static void Main(string[] args)
=> BuildAvaloniaApp()
.UseMicrosoftDependencyInjection()
.StartWithClassicDesktopLifetime(args);
.UseMicrosoftDependencyInjection(out var configuration)
.StartWithClassicDesktopLifetime(args, configuration.WithMicrosoftDependencyInjection);
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseReactiveUI()
.WithInterFont()
.LogToTrace();
private static void ConfigureServices(IServiceCollection services)
{
services.UseMicrosoftDependencyResolver();
var mutableResolver = Locator.CurrentMutable;
mutableResolver.InitializeSplat();
mutableResolver.InitializeReactiveUI();
services.AddInkForge();
services.AddTransient<IViewFor<AppViewModel>, MainWindow>();
}
private static void OnSetup(this IServiceCollection services, AppBuilder appBuilder)
private static void SetupApp(this IServiceCollection services, AppBuilder appBuilder)
{
var dispatcher = Dispatcher.UIThread;
var app = appBuilder.Instance!;
services
.AddSingleton(app)
@ -53,16 +33,56 @@ static class Program
.AddSingleton(dispatcher);
var serviceProvider = services.BuildServiceProvider();
serviceProvider.UseMicrosoftDependencyResolver();
app.SetValue(App.ServiceProviderProperty, serviceProvider);
dispatcher.ShutdownFinished += (_, _) => serviceProvider.Dispose();
_ = new ServiceProviderDisposer(serviceProvider, dispatcher);
_ = app.ApplicationLifetime switch
{
IClassicDesktopStyleApplicationLifetime desktop => desktop.MainWindow = new MainWindow(),
_ => throw new NotSupportedException(),
};
}
private static AppBuilder UseMicrosoftDependencyInjection(this AppBuilder builder)
private static AppBuilder UseMicrosoftDependencyInjection(this AppBuilder builder, out ConfigurationManager configuration)
{
configuration = new();
ServiceCollection services = [];
ConfigureServices(services);
builder.AfterSetup(services.OnSetup);
services.AddSingleton<IConfiguration>(configuration);
App.Configure(services, configuration);
builder.AfterSetup(services.SetupApp);
return builder;
}
private static void WithMicrosoftDependencyInjection(this ConfigurationManager configuration, IClassicDesktopStyleApplicationLifetime lifetime)
{
configuration.AddCommandLine(lifetime.Args ?? []);
}
private class ServiceProviderDisposer
{
private readonly ServiceProvider _serviceProvider;
private ValueTask? _shutdownTask;
public ServiceProviderDisposer(ServiceProvider serviceProvider, Dispatcher dispatcher)
{
dispatcher.ShutdownFinished += OnShutdownFinished;
dispatcher.ShutdownStarted += OnShutdownStarted;
_serviceProvider = serviceProvider;
}
private void OnShutdownFinished(object? sender, EventArgs e)
{
if (_shutdownTask is { IsCompleted: false } disposeTask)
{
disposeTask.GetAwaiter().GetResult();
}
}
private void OnShutdownStarted(object? sender, EventArgs e)
{
#pragma warning disable CA2012 // This will only ever be awaited once in ShutdownFinished
_shutdownTask = _serviceProvider.DisposeAsync();
#pragma warning restore CA2012
}
}
}

View file

@ -0,0 +1,6 @@
namespace InkForge.Desktop.Properties;
public class ApplicationSettings
{
public List<string> History { get; } = [];
}

View file

@ -0,0 +1,6 @@
using Avalonia.Metadata;
[assembly: XmlnsPrefix("app:InkForge", "inkforge")]
[assembly: XmlnsDefinition("app:InkForge", "InkForge.Desktop.Controls")]
[assembly: XmlnsDefinition("app:InkForge", "InkForge.Desktop.MarkupExtensions")]
[assembly: XmlnsDefinition("app:InkForge", "InkForge.Desktop.Services")]

View file

@ -1,7 +1,8 @@
using System.Text.Json.Serialization;
namespace InkForge.Common.Properties;
namespace InkForge.Desktop.Properties;
[JsonSerializable(typeof(ApplicationSettings))]
[JsonSerializable(typeof(IDictionary<string, object>))]
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
public partial class ConfigContext : JsonSerializerContext;

View file

@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"DefaultConnection": "Data Source={WorkspaceFile}"
}
}

View file

@ -0,0 +1,13 @@
using Avalonia.Platform.Storage;
namespace InkForge.Desktop.Services;
public static class StorageProviderExtensions
{
public static IStorageProvider? GetStorageProvider(this object? context)
{
ArgumentNullException.ThrowIfNull(context);
return TopLevels.GetTopLevelForContext(context)?.StorageProvider;
}
}

View file

@ -0,0 +1,69 @@
using System.Reactive.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Controls;
namespace InkForge.Desktop.Services;
public class TopLevels
{
public static readonly AttachedProperty<object?> RegisterProperty
= AvaloniaProperty.RegisterAttached<TopLevels, Visual, object?>("Register");
private static readonly Dictionary<object, Visual> RegistrationMapper = [];
public static TopLevel? ActiveTopLevel { get; private set; }
static TopLevels()
{
RegisterProperty.Changed.AddClassHandler<Visual>(RegisterChanged);
WindowBase.IsActiveProperty.Changed.Subscribe(WindowActiveChanged);
}
public static object? GetRegister(AvaloniaObject element)
{
return element.GetValue(RegisterProperty);
}
public static TopLevel? GetTopLevelForContext(object context)
{
return TopLevel.GetTopLevel(GetVisualForContext(context));
}
public static Visual? GetVisualForContext(object context)
{
return RegistrationMapper.TryGetValue(context, out var result) ? result : null;
}
public static void SetRegister(AvaloniaObject element, object value)
{
element.SetValue(RegisterProperty, value);
}
private static void RegisterChanged(Visual sender, AvaloniaPropertyChangedEventArgs e)
{
ArgumentNullException.ThrowIfNull(sender);
// Unregister any old registered context
if (e.OldValue != null)
{
RegistrationMapper.Remove(e.OldValue);
}
// Register any new context
if (e.NewValue != null)
{
CollectionsMarshal.GetValueRefOrAddDefault(RegistrationMapper, e.NewValue, out _) = sender;
}
}
private static void WindowActiveChanged(AvaloniaPropertyChangedEventArgs<bool> e)
{
ActiveTopLevel = (e.GetOldAndNewValue<bool>(), e.Sender) switch
{
((false, true), TopLevel topLevel) => topLevel,
((true, false), { } topLevel) when topLevel == ActiveTopLevel => null,
_ => ActiveTopLevel,
};
}
}

View file

@ -0,0 +1,147 @@
using System.Reactive.Disposables;
namespace System.Reactive.Linq;
public static class ObservableSwitch
{
public static IObservable<T> Switch<T>(this IObservable<IObservable<T>> observable, T defaultValue)
{
return new SwitchObservable<T>(observable, defaultValue);
}
private class SwitchObservable<T>(IObservable<IObservable<T>> sources, T defaultValue) : ObservableBase<T>
{
protected override IDisposable SubscribeCore(IObserver<T> observer)
{
_ _ = new(defaultValue, observer);
_.Run(sources);
return _;
}
private class _(T defaultValue, IObserver<T> observer) : ObserverBase<IObservable<T>>
{
private readonly SerialDisposable _innerSerialDisposable = new();
private readonly SingleAssignmentDisposable _upstream = new();
private bool _hasLatest;
private int _latest;
private bool _stopped = false;
public void Run(IObservable<IObservable<T>> sources)
{
_upstream.Disposable = sources.Subscribe(this);
if (_innerSerialDisposable.Disposable is null)
{
observer.OnNext(defaultValue);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_innerSerialDisposable?.Dispose();
}
base.Dispose(disposing);
}
protected void ForwardOnCompleted() => observer.OnCompleted();
protected void ForwardOnError(Exception error) => observer.OnError(error);
protected void ForwardOnNext(T value) => observer.OnNext(value);
protected override void OnCompletedCore()
{
_upstream.Dispose();
_stopped = true;
if (!_hasLatest)
{
observer.OnCompleted();
}
}
protected override void OnErrorCore(Exception error) => ForwardOnError(error);
protected override void OnNextCore(IObservable<T> value)
{
uint id = unchecked((uint)Interlocked.Increment(ref _latest));
_hasLatest = true;
var innerObserver = new InnerObserver(this, id, defaultValue);
_innerSerialDisposable.Disposable = innerObserver;
innerObserver.Subscribe(value);
}
private class InnerObserver(_ parent, uint id, T defaultValue) : ObserverBase<T>
{
private readonly SingleAssignmentDisposable _upstream = new();
public bool Found { get; set; } = false;
public void Subscribe(IObservable<T> upstream)
{
_upstream.Disposable = upstream.SubscribeSafe(this);
if (!Found)
{
OnNext(defaultValue);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_upstream.Dispose();
}
base.Dispose(disposing);
}
protected override void OnCompletedCore()
{
Dispose();
if (parent._latest == id)
{
parent._hasLatest = false;
if (!Found)
{
OnNextCore(defaultValue);
}
if (parent._stopped)
{
parent.ForwardOnCompleted();
}
}
}
protected override void OnErrorCore(Exception error)
{
Dispose();
if (parent._latest == id)
{
if (!Found)
{
OnNextCore(defaultValue);
}
parent.ForwardOnError(error);
}
}
protected override void OnNextCore(T value)
{
Found = true;
if (parent._latest == id)
{
parent.ForwardOnNext(value);
}
}
}
}
}
}

View file

@ -0,0 +1,30 @@
using System.Reactive.Linq;
using AvaloniaEdit.Document;
using Dock.Model.Mvvm.Controls;
using DynamicData.Binding;
using InkForge.Desktop.Models;
namespace InkForge.Desktop.ViewModels.Documents;
public class NoteEditDocumentViewModel : Document
{
public Note Note { get; }
public TextDocument Document { get; }
public NoteEditDocumentViewModel(Note note, TextDocument textDocument)
{
Note = note;
Document = textDocument;
Observable.CombineLatest(
this.WhenValueChanged(v => v.Note.Name),
this.WhenValueChanged(v => v.Document.UndoStack.IsOriginalFile),
(name, original) => original ? name : $"{name} *"
).Subscribe(title => Title = title!);
}
}

View file

@ -0,0 +1,86 @@
using System.Reactive;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Dock.Model.Mvvm.Controls;
using InkForge.Desktop.Managers;
using InkForge.Desktop.Services;
namespace InkForge.Desktop.ViewModels.Documents;
public partial class WelcomePageDocumentViewModel : Document
{
private readonly WorkspaceManager _workspaceController;
public WelcomePageDocumentViewModel(WorkspaceManager workspaceController)
{
Title = "Welcome";
_workspaceController = workspaceController;
}
[RelayCommand]
private async Task OnCreateNew()
{
var storageProvider = this.GetStorageProvider()!;
var documents = await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
var file = await storageProvider.SaveFilePickerAsync(new()
{
DefaultExtension = ".ifdb",
FileTypeChoices =
[
new FilePickerFileType("InkForge Database File")
{
Patterns = [ "*.ifdb" ],
},
],
SuggestedStartLocation = documents,
Title = "Select InkForge Database Name",
});
if (file?.TryGetLocalPath() is not { } filePath)
{
return;
}
await _workspaceController.OpenWorkspace(filePath, true);
}
[RelayCommand]
private async Task OnOpenNew()
{
var storageProvider = this.GetStorageProvider()!;
var documents = await storageProvider.TryGetWellKnownFolderAsync(WellKnownFolder.Documents);
var files = await storageProvider.OpenFilePickerAsync(new()
{
AllowMultiple = false,
SuggestedStartLocation = documents,
FileTypeFilter =
[
new FilePickerFileType("InkForge Database File")
{
Patterns = [ "*.ifdb" ]
}
],
Title = "Open InkForge Database file"
});
if (files.Count != 1)
{
return;
}
if (files[0].TryGetLocalPath() is not { } filePath)
{
return;
}
await _workspaceController.OpenWorkspace(filePath, false);
}
}

View file

@ -0,0 +1,16 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Dock.Model.Core;
namespace InkForge.Desktop.ViewModels;
public class MainViewModel : ObservableObject
{
public IDock Layout { get; }
public MainViewModel(InkForgeFactory factory)
{
Layout = factory.CreateLayout();
factory.InitLayout(Layout);
}
}

View file

@ -0,0 +1,14 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace InkForge.Desktop.ViewModels;
public partial class RecentItemViewModel(
DateTimeOffset created,
string name,
DateTimeOffset lastUsed
) : ObservableObject
{
[ObservableProperty] private DateTimeOffset _created = created;
[ObservableProperty] private string _name = name;
[ObservableProperty] private DateTimeOffset _lastUsed = lastUsed;
}

View file

@ -0,0 +1,40 @@
using System.Reactive.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Dock.Model.Mvvm.Controls;
using DynamicData.Binding;
using InkForge.Desktop.Managers;
using InkForge.Desktop.Models;
using InkForge.Desktop.ViewModels.Workspaces;
namespace InkForge.Desktop.ViewModels.Tools;
public partial class WorkspaceTool : Tool
{
private readonly IWorkspaceViewModelFactory _workspaceViewModelFactory;
[ObservableProperty] private WorkspaceViewModel? _workspace;
public WorkspaceTool(WorkspaceManager workspaceManager, IWorkspaceViewModelFactory workspaceViewModelFactory)
{
_workspaceViewModelFactory = workspaceViewModelFactory;
Title = "Explorer";
CanClose = false;
CanFloat = false;
CanPin = false;
workspaceManager.WhenValueChanged(v => v.Workspace).Subscribe(OnWorkspaceManagerWorkspaceChanged);
}
private void OnWorkspaceManagerWorkspaceChanged(Workspace? workspace)
{
Workspace = workspace switch
{
{ } v => _workspaceViewModelFactory.Create(v),
_ => null
};
}
}

View file

@ -0,0 +1,55 @@
using System.Collections.ObjectModel;
using Avalonia;
using DynamicData;
using InkForge.Desktop.Models;
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.ViewModels.Workspaces
{
public class WorkspaceViewModel
{
private readonly Workspace _workspace;
private readonly NoteStore _noteStore;
private readonly ReadOnlyObservableCollection<Node<Note, int>> _notes;
public string Name => _workspace.Name;
public ReadOnlyObservableCollection<Node<Note, int>> Notes => _notes;
public WorkspaceViewModel(Workspace workspace, NoteStore noteStore)
{
_workspace = workspace;
_noteStore = noteStore;
noteStore.Notes
.AsObservableChangeSet(m => m.Key)
.Transform(m => m.Value, true)
.TransformToTree(m => m.Id)
.Bind(out _notes).Subscribe();
}
}
public interface IWorkspaceViewModelFactory
{
WorkspaceViewModel Create(Workspace workspace);
}
namespace Internal
{
internal class WorkspaceViewModelFactory : IWorkspaceViewModelFactory
{
private static ObjectFactory<WorkspaceViewModel>? s_workspaceViewModelFactory;
public static WorkspaceViewModel Create(Workspace workspace)
{
s_workspaceViewModelFactory ??= ActivatorUtilities.CreateFactory<WorkspaceViewModel>([typeof(Workspace)]);
return s_workspaceViewModelFactory(workspace.Services, [workspace]);
}
WorkspaceViewModel IWorkspaceViewModelFactory.Create(Workspace workspace) => Create(workspace);
}
}
}

View file

@ -0,0 +1,15 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:vm="using:InkForge.Desktop.ViewModels.Documents"
xmlns:avaloniaedit="https://github.com/avaloniaui/avaloniaedit"
xmlns:inkforge="app:InkForge"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.Documents.NoteEditDocument"
x:DataType="vm:NoteEditDocumentViewModel"
inkforge:TopLevels.Register="{CompiledBinding}">
<avaloniaedit:TextEditor Document="{CompiledBinding Document}" />
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace InkForge.Desktop.Views.Documents;
public partial class NoteEditDocument : UserControl
{
public NoteEditDocument()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,38 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:vm="using:InkForge.Desktop.ViewModels.Documents"
xmlns:inkforge="app:InkForge"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.Documents.WelcomePageDocument"
x:DataType="vm:WelcomePageDocumentViewModel"
inkforge:TopLevels.Register="{CompiledBinding}">
<Grid RowDefinitions="Auto, *, Auto">
<Label Target="RecentItemsList"
Content="Open Recent"
Grid.Row="0" />
<DataGrid Name="RecentItemsList"
IsEnabled="False"
IsReadOnly="True"
Grid.Row="1">
<DataGrid.Columns>
<DataGridTextColumn Header="Name"
Width="*" />
<DataGridTextColumn Header="Last Used" />
</DataGrid.Columns>
</DataGrid>
<Menu Grid.Row="2">
<MenuItem Header="Create New"
Command="{CompiledBinding CreateNewCommand}" />
<MenuItem Header="Open"
IsEnabled="False" />
<MenuItem Header="Open File"
Command="{CompiledBinding OpenNewCommand}" />
</Menu>
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace InkForge.Desktop.Views.Documents;
public partial class WelcomePageDocument : UserControl
{
public WelcomePageDocument()
{
InitializeComponent();
}
}

View file

@ -2,23 +2,23 @@
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:reactiveui="http://reactiveui.net"
xmlns:ifcvm="using:InkForge.Common.ViewModels"
xmlns:inkforge="app:InkForge"
xmlns:local="using:InkForge.Desktop.Views"
xmlns:vm="using:InkForge.Desktop.ViewModels"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
Width="800"
Height="450"
x:Class="InkForge.Desktop.Views.MainWindow"
x:DataType="ifcvm:AppViewModel"
Title="InkForge">
x:DataType="vm:MainViewModel"
Title="MainWindow"
inkforge:TopLevels.Register="{CompiledBinding}">
<NativeMenu.Menu>
<NativeMenu>
<NativeMenuItem Header="Test" />
</NativeMenu>
<NativeMenu />
</NativeMenu.Menu>
<DockPanel>
<NativeMenuBar />
<NativeMenuBar DockPanel.Dock="Top" />
<reactiveui:ViewModelViewHost ViewModel="{CompiledBinding View}" />
<DockControl Layout="{CompiledBinding Layout}" />
</DockPanel>
</Window>

View file

@ -1,13 +1,24 @@
using Avalonia.ReactiveUI;
using Avalonia;
using Avalonia.Controls;
using InkForge.Common.ViewModels;
using InkForge.Desktop.ViewModels;
using Microsoft.Extensions.DependencyInjection;
namespace InkForge.Desktop.Views;
public partial class MainWindow : ReactiveWindow<AppViewModel>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = CreateViewModel();
}
private static MainViewModel CreateViewModel()
{
return ActivatorUtilities.CreateInstance<MainViewModel>(
Application.Current!.GetValue(App.ServiceProviderProperty)
);
}
}

View file

@ -0,0 +1,32 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:local="using:InkForge.Desktop.Views.Tools"
xmlns:workspaces="using:InkForge.Desktop.Views.Workspaces"
xmlns:vm="using:InkForge.Desktop.ViewModels.Tools"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.Tools.WorkspaceTool"
x:DataType="vm:WorkspaceTool"
Classes.HasWorkspace="{CompiledBinding Workspace, Converter={x:Static ObjectConverters.IsNotNull}}">
<UserControl.Styles>
<Style Selector="local|WorkspaceTool">
<Setter Property="Content">
<Template>
<TextBlock>No workspace selected.</TextBlock>
</Template>
</Setter>
<Style Selector="^.HasWorkspace">
<Setter Property="Content">
<Template>
<workspaces:WorkspaceView DataContext="{CompiledBinding Workspace}" />
</Template>
</Setter>
</Style>
</Style>
</UserControl.Styles>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace InkForge.Desktop.Views.Tools;
public partial class WorkspaceTool : UserControl
{
public WorkspaceTool()
{
InitializeComponent();
}
}

View file

@ -0,0 +1,38 @@
<UserControl xmlns="https://github.com/avaloniaui"
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:vm="using:InkForge.Desktop.ViewModels.Workspaces"
xmlns:inkforge="app:InkForge"
mc:Ignorable="d"
d:DesignWidth="800"
d:DesignHeight="450"
x:Class="InkForge.Desktop.Views.Workspaces.WorkspaceView"
x:DataType="vm:WorkspaceViewModel">
<Grid ColumnDefinitions="*, Auto"
RowDefinitions="Auto, *">
<TextBlock Text="{CompiledBinding Name}"
Grid.Column="0"
Grid.Row="0" />
<StackPanel Classes="WorkspaceToolbar"
Orientation="Horizontal"
Spacing="3"
Grid.Column="1"
Grid.Row="0">
<Button AutomationProperties.Name="Add">
<inkforge:FluentSymbolIcon Symbol="document_add" />
</Button>
<Button>
<inkforge:FluentSymbolIcon Symbol="arrow_clockwise" />
</Button>
<Button>
<inkforge:FluentSymbolIcon Symbol="subtract_square_multiple" />
</Button>
</StackPanel>
<TreeView Grid.ColumnSpan="2"
Grid.Row="1" />
</Grid>
</UserControl>

View file

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace InkForge.Desktop.Views.Workspaces;
public partial class WorkspaceView : UserControl
{
public WorkspaceView()
{
InitializeComponent();
}
}

View file

@ -8,4 +8,11 @@ Performs OpenAPI calls to Sync server.
*Consideration*: Allow for syncing to local backend.
## Technical
Figure out a way to get navigation/commands relative to the window they are in.<br>
I.e. make Windows scoped, then get a shell-object in each Window, which then
consumes a Menu-service, which navigates the shell-object tree to find eligible
menu-objects to present.
## Research

View file

@ -0,0 +1,3 @@
namespace InkForge.Data;
public class Blob : Entity<byte[], int>;

View file

@ -1,18 +0,0 @@
using InkForge.Data.Infrastructure;
namespace InkForge.Data.Domain;
public class Note
{
public DateTimeOffset Created { get; set; }
public NoteEntity? Parent { get; set; }
public string Name { get; set; } = default!;
public DateTimeOffset Updated { get; set; }
public DateTimeOffset? Deleted { get; set; }
public Blob Content { get; set; } = default!;
}

View file

@ -0,0 +1,20 @@
namespace InkForge.Data
{
public abstract class ValueEntity<TEntity>
{
public TEntity Value { get; set; } = default!;
}
public abstract class Entity<TEntity, TKey>
: ValueEntity<TEntity>
{
public TKey Id { get; set; } = default!;
}
public abstract class Entity<TSelf, TEntity, TKey>
: Entity<TEntity, TKey>
where TSelf : Entity<TSelf, TEntity, TKey>
{
public TSelf? Parent { get; set; }
}
}

View file

@ -1,8 +0,0 @@
namespace InkForge.Data;
public class Blob
{
public string Id { get; set; } = default!;
public byte[] Content { get; set; } = default!;
}

View file

@ -1,25 +0,0 @@
using System.Numerics;
namespace InkForge.Data
{
public abstract class ValueEntity<TEntity>
{
public TEntity Value { get; set; } = default!;
}
public abstract class Entity<TEntity, TKey>
: ValueEntity<TEntity>
where TKey : struct, INumber<TKey>
{
public TKey? Id { get; set; }
}
public abstract class VersionedEntity<TEntity, TKey>
: ValueEntity<TEntity>
where TKey : struct, INumber<TKey>
{
public TKey Id { get; set; }
public int? Version { get; set; }
}
}

View file

@ -1,8 +0,0 @@
using InkForge.Data.Domain;
namespace InkForge.Data.Infrastructure
{
public class NoteEntity : Entity<Note, int>;
public class NoteVersionEntity : VersionedEntity<Note, int>;
}

View file

@ -0,0 +1,3 @@
namespace InkForge.Data;
public class MetadataEntity : Entity<string, string>;

View file

@ -1,34 +1,37 @@
using InkForge.Data.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace InkForge.Data;
public class NoteDbContext(
DbContextOptions<NoteDbContext> options
DbContextOptions options
) : DbContext(options)
{
public DbSet<Blob> Blobs { get; set; } = default!;
public DbSet<MetadataEntity> Metadata { get; set; } = default!;
public DbSet<NoteEntity> Notes { get; set; } = default!;
public DbSet<NoteVersionEntity> NoteVersions { get; set; } = default!;
public NoteDbContext(DbContextOptions<NoteDbContext> options) : this((DbContextOptions)options)
{ }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<NoteEntity>(options =>
modelBuilder.Entity<MetadataEntity>(options =>
{
options.OwnsOne(m => m.Value);
options.HasKey(m => m.Id);
});
modelBuilder.Entity<NoteVersionEntity>(options =>
modelBuilder.Entity<NoteEntity>(options =>
{
options.OwnsOne(m => m.Value);
options.Property(m => m.Id).IsRequired();
options.HasKey(m => m.Version);
options.HasIndex(nameof(NoteVersionEntity.Id), nameof(NoteVersionEntity.Version)).IsUnique();
options.HasKey(m => m.Id);
options.OwnsOne(m => m.Value, m =>
{
m.HasOne<Blob>().WithOne().HasForeignKey<Note>(m => m.ContentId).IsRequired();
});
options.HasOne(m => m.Parent);
});
}
}

View file

@ -0,0 +1,17 @@
namespace InkForge.Data
{
public class Note
{
public DateTimeOffset Created { get; set; }
public int ContentId { get; set; }
public DateTimeOffset? Deleted { get; set; }
public string Name { get; set; } = default!;
public DateTimeOffset Updated { get; set; }
}
public class NoteEntity : Entity<NoteEntity, Note, int>;
}

View file

@ -1,172 +0,0 @@
// <auto-generated />
using System;
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace InkForge.Sqlite.Migrations
{
[DbContext(typeof(NoteDbContext))]
[Migration("20240207000000_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.Entity("InkForge.Data.Blob", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<byte[]>("Content")
.IsRequired()
.HasColumnType("BLOB");
b.HasKey("Id");
b.ToTable("Blobs");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteEntity", b =>
{
b.Property<int?>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Notes");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteVersionEntity", b =>
{
b.Property<int?>("Version")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.HasKey("Version");
b.HasIndex("Id", "Version")
.IsUnique();
b.ToTable("NoteVersions");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteEntity", b =>
{
b.OwnsOne("InkForge.Data.Domain.Note", "Value", b1 =>
{
b1.Property<int>("ParentId")
.HasColumnType("INTEGER");
b1.Property<string>("ContentId")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset?>("Deleted")
.HasColumnType("TEXT");
b1.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b1.HasKey("ParentId");
b1.HasIndex("ContentId");
b1.ToTable("Notes");
b1.HasOne("InkForge.Data.Blob", "Content")
.WithMany()
.HasForeignKey("ContentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner("Parent")
.HasForeignKey("ParentId");
b1.Navigation("Content");
b1.Navigation("Parent");
});
b.Navigation("Value")
.IsRequired();
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteVersionEntity", b =>
{
b.OwnsOne("InkForge.Data.Domain.Note", "Value", b1 =>
{
b1.Property<int>("NoteVersionEntityVersion")
.HasColumnType("INTEGER");
b1.Property<string>("ContentId")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset?>("Deleted")
.HasColumnType("TEXT");
b1.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<int?>("ParentId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b1.HasKey("NoteVersionEntityVersion");
b1.HasIndex("ContentId");
b1.HasIndex("ParentId");
b1.ToTable("NoteVersions");
b1.HasOne("InkForge.Data.Blob", "Content")
.WithMany()
.HasForeignKey("ContentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("NoteVersionEntityVersion");
b1.HasOne("InkForge.Data.Infrastructure.NoteEntity", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b1.Navigation("Content");
b1.Navigation("Parent");
});
b.Navigation("Value")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,120 @@
// <auto-generated />
using System;
using InkForge.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace InkForge.Sqlite.Migrations
{
[DbContext(typeof(NoteDbContext))]
[Migration("20240401_Initial")]
partial class _20240401_Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
modelBuilder.Entity("InkForge.Data.Blob", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Value")
.IsRequired()
.HasColumnType("BLOB");
b.HasKey("Id");
b.ToTable("Blobs");
});
modelBuilder.Entity("InkForge.Data.MetadataEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Metadata");
});
modelBuilder.Entity("InkForge.Data.NoteEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ParentId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Notes");
});
modelBuilder.Entity("InkForge.Data.NoteEntity", b =>
{
b.HasOne("InkForge.Data.NoteEntity", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.OwnsOne("InkForge.Data.Note", "Value", b1 =>
{
b1.Property<int>("NoteEntityId")
.HasColumnType("INTEGER");
b1.Property<int>("ContentId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset?>("Deleted")
.HasColumnType("TEXT");
b1.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b1.HasKey("NoteEntityId");
b1.HasIndex("ContentId")
.IsUnique();
b1.ToTable("Notes");
b1.HasOne("InkForge.Data.Blob", null)
.WithOne()
.HasForeignKey("InkForge.Data.NoteEntity.Value#InkForge.Data.Note", "ContentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("NoteEntityId");
});
b.Navigation("Parent");
b.Navigation("Value")
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View file

@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace InkForge.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
public partial class _20240401_Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@ -15,25 +15,38 @@ namespace InkForge.Sqlite.Migrations
name: "Blobs",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Content = table.Column<byte[]>(type: "BLOB", nullable: false)
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Value = table.Column<byte[]>(type: "BLOB", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Blobs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Metadata",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Metadata", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Notes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Id = table.Column<int>(type: "INTEGER", nullable: false),
Value_Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Value_ContentId = table.Column<int>(type: "INTEGER", nullable: false),
Value_Deleted = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
Value_Name = table.Column<string>(type: "TEXT", nullable: false),
Value_Updated = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Value_Deleted = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
Value_ContentId = table.Column<string>(type: "TEXT", nullable: false)
ParentId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
@ -44,65 +57,30 @@ namespace InkForge.Sqlite.Migrations
principalTable: "Blobs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "NoteVersions",
columns: table => new
{
Version = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Value_Created = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Value_ParentId = table.Column<int>(type: "INTEGER", nullable: true),
Value_Name = table.Column<string>(type: "TEXT", nullable: false),
Value_Updated = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Value_Deleted = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
Value_ContentId = table.Column<string>(type: "TEXT", nullable: false),
Id = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NoteVersions", x => x.Version);
table.ForeignKey(
name: "FK_NoteVersions_Blobs_Value_ContentId",
column: x => x.Value_ContentId,
principalTable: "Blobs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_NoteVersions_Notes_Value_ParentId",
column: x => x.Value_ParentId,
name: "FK_Notes_Notes_ParentId",
column: x => x.ParentId,
principalTable: "Notes",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Notes_ParentId",
table: "Notes",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_Notes_Value_ContentId",
table: "Notes",
column: "Value_ContentId");
migrationBuilder.CreateIndex(
name: "IX_NoteVersions_Id_Version",
table: "NoteVersions",
columns: new[] { "Id", "Version" },
column: "Value_ContentId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NoteVersions_Value_ContentId",
table: "NoteVersions",
column: "Value_ContentId");
migrationBuilder.CreateIndex(
name: "IX_NoteVersions_Value_ParentId",
table: "NoteVersions",
column: "Value_ParentId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NoteVersions");
name: "Metadata");
migrationBuilder.DropTable(
name: "Notes");

View file

@ -15,14 +15,15 @@ namespace InkForge.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
modelBuilder.Entity("InkForge.Data.Blob", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("Content")
b.Property<byte[]>("Value")
.IsRequired()
.HasColumnType("BLOB");
@ -31,45 +32,50 @@ namespace InkForge.Sqlite.Migrations
b.ToTable("Blobs");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteEntity", b =>
modelBuilder.Entity("InkForge.Data.MetadataEntity", b =>
{
b.Property<int?>("Id")
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Metadata");
});
modelBuilder.Entity("InkForge.Data.NoteEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ParentId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Notes");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteVersionEntity", b =>
modelBuilder.Entity("InkForge.Data.NoteEntity", b =>
{
b.Property<int?>("Version")
.ValueGeneratedOnAdd()
b.HasOne("InkForge.Data.NoteEntity", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.OwnsOne("InkForge.Data.Note", "Value", b1 =>
{
b1.Property<int>("NoteEntityId")
.HasColumnType("INTEGER");
b.Property<int>("Id")
b1.Property<int>("ContentId")
.HasColumnType("INTEGER");
b.HasKey("Version");
b.HasIndex("Id", "Version")
.IsUnique();
b.ToTable("NoteVersions");
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteEntity", b =>
{
b.OwnsOne("InkForge.Data.Domain.Note", "Value", b1 =>
{
b1.Property<int>("ParentId")
.HasColumnType("INTEGER");
b1.Property<string>("ContentId")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
@ -83,83 +89,25 @@ namespace InkForge.Sqlite.Migrations
b1.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b1.HasKey("ParentId");
b1.HasKey("NoteEntityId");
b1.HasIndex("ContentId");
b1.HasIndex("ContentId")
.IsUnique();
b1.ToTable("Notes");
b1.HasOne("InkForge.Data.Blob", "Content")
.WithMany()
.HasForeignKey("ContentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner("Parent")
.HasForeignKey("ParentId");
b1.Navigation("Content");
b1.Navigation("Parent");
});
b.Navigation("Value")
.IsRequired();
});
modelBuilder.Entity("InkForge.Data.Infrastructure.NoteVersionEntity", b =>
{
b.OwnsOne("InkForge.Data.Domain.Note", "Value", b1 =>
{
b1.Property<int>("NoteVersionEntityVersion")
.HasColumnType("INTEGER");
b1.Property<string>("ContentId")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("Created")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset?>("Deleted")
.HasColumnType("TEXT");
b1.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<int?>("ParentId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("Updated")
.HasColumnType("TEXT");
b1.HasKey("NoteVersionEntityVersion");
b1.HasIndex("ContentId");
b1.HasIndex("ParentId");
b1.ToTable("NoteVersions");
b1.HasOne("InkForge.Data.Blob", "Content")
.WithMany()
.HasForeignKey("ContentId")
b1.HasOne("InkForge.Data.Blob", null)
.WithOne()
.HasForeignKey("InkForge.Data.NoteEntity.Value#InkForge.Data.Note", "ContentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("NoteVersionEntityVersion");
b1.HasOne("InkForge.Data.Infrastructure.NoteEntity", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b1.Navigation("Content");
b1.Navigation("Parent");
.HasForeignKey("NoteEntityId");
});
b.Navigation("Parent");
b.Navigation("Value")
.IsRequired();
});