mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 |
@@ -0,0 +1,157 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNotePersistenceServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
var snapshot = CreateSampleSnapshot();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||||
|
|
||||||
|
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
|
||||||
|
|
||||||
|
Assert.Single(loaded.Strokes);
|
||||||
|
Assert.Equal(2, loaded.Strokes[0].Points.Count);
|
||||||
|
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||||
|
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
|
||||||
|
|
||||||
|
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
|
||||||
|
|
||||||
|
Assert.Empty(loaded.Strokes);
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
|
||||||
|
{
|
||||||
|
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
|
||||||
|
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
|
||||||
|
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
|
||||||
|
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
|
||||||
|
|
||||||
|
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
|
||||||
|
|
||||||
|
Assert.Equal(2, deletedCount);
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
|
||||||
|
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
|
||||||
|
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot
|
||||||
|
{
|
||||||
|
Strokes =
|
||||||
|
[
|
||||||
|
new WhiteboardStrokeSnapshot
|
||||||
|
{
|
||||||
|
Color = "#FF112233",
|
||||||
|
InkThickness = 3.5d,
|
||||||
|
IgnorePressure = true,
|
||||||
|
Points =
|
||||||
|
[
|
||||||
|
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
|
||||||
|
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directoryPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.WhiteboardNoteTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
private readonly string _databasePath;
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceSandbox()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directoryPath);
|
||||||
|
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceService CreateService()
|
||||||
|
{
|
||||||
|
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
|
||||||
|
{
|
||||||
|
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
UPDATE whiteboard_notes
|
||||||
|
SET saved_at_utc_ms = $savedAtUtcMs,
|
||||||
|
expires_at_utc_ms = $expiresAtUtcMs,
|
||||||
|
updated_at_utc_ms = $updatedAtUtcMs
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Exists(string componentId, string placementId)
|
||||||
|
{
|
||||||
|
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT COUNT(1)
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
return Convert.ToInt32(command.ExecuteScalar()) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directoryPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Temporary test directories are best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,12 @@
|
|||||||
"tray.menu.restart": "Restart App",
|
"tray.menu.restart": "Restart App",
|
||||||
"tray.menu.exit": "Exit App",
|
"tray.menu.exit": "Exit App",
|
||||||
"button.back_to_windows": "Back to Windows",
|
"button.back_to_windows": "Back to Windows",
|
||||||
|
"button.back_to_platform": "Back to {0}",
|
||||||
"tooltip.back_to_windows": "Back to Windows",
|
"tooltip.back_to_windows": "Back to Windows",
|
||||||
|
"tooltip.back_to_platform": "Back to {0}",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
"tooltip.open_settings": "Settings",
|
"tooltip.open_settings": "Settings",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.shell.title": "Settings",
|
"settings.shell.title": "Settings",
|
||||||
@@ -405,6 +410,7 @@
|
|||||||
"common.monet": "Monet",
|
"common.monet": "Monet",
|
||||||
"desktop.page_index_format": "Desktop {0}",
|
"desktop.page_index_format": "Desktop {0}",
|
||||||
"launcher.title": "App Launcher",
|
"launcher.title": "App Launcher",
|
||||||
|
"launcher.folder": "Folder",
|
||||||
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
"launcher.subtitle": "Apps and folders from Windows Start Menu",
|
||||||
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
|
||||||
"launcher.empty": "No Start Menu entries found.",
|
"launcher.empty": "No Start Menu entries found.",
|
||||||
@@ -590,6 +596,18 @@
|
|||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
"component.office_recent_documents": "Recent Documents",
|
"component.office_recent_documents": "Recent Documents",
|
||||||
|
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
|
||||||
|
"whiteboard.settings.retention.title": "Note retention",
|
||||||
|
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
|
||||||
|
"whiteboard.settings.retention.option": "{0} days",
|
||||||
|
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
|
||||||
|
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
|
||||||
|
"office_recent_documents.settings.sources_title": "Recent document sources",
|
||||||
|
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office registry MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
|
||||||
|
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
|
||||||
"component.removable_storage": "Removable Storage",
|
"component.removable_storage": "Removable Storage",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
|
|||||||
@@ -7,7 +7,12 @@
|
|||||||
"tray.menu.restart": "重启应用",
|
"tray.menu.restart": "重启应用",
|
||||||
"tray.menu.exit": "退出应用",
|
"tray.menu.exit": "退出应用",
|
||||||
"button.back_to_windows": "回到Windows",
|
"button.back_to_windows": "回到Windows",
|
||||||
|
"button.back_to_platform": "回到{0}",
|
||||||
"tooltip.back_to_windows": "回到Windows",
|
"tooltip.back_to_windows": "回到Windows",
|
||||||
|
"tooltip.back_to_platform": "回到{0}",
|
||||||
|
"platform.windows": "Windows",
|
||||||
|
"platform.linux": "Linux",
|
||||||
|
"platform.macos": "macOS",
|
||||||
"tooltip.open_settings": "设置",
|
"tooltip.open_settings": "设置",
|
||||||
"settings.title": "设置",
|
"settings.title": "设置",
|
||||||
"settings.shell.title": "设置",
|
"settings.shell.title": "设置",
|
||||||
@@ -403,6 +408,7 @@
|
|||||||
"common.monet": "莫奈",
|
"common.monet": "莫奈",
|
||||||
"desktop.page_index_format": "桌面 {0}",
|
"desktop.page_index_format": "桌面 {0}",
|
||||||
"launcher.title": "应用启动台",
|
"launcher.title": "应用启动台",
|
||||||
|
"launcher.folder": "文件夹",
|
||||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||||
"launcher.empty": "未找到开始菜单条目。",
|
"launcher.empty": "未找到开始菜单条目。",
|
||||||
@@ -588,6 +594,18 @@
|
|||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
"component.office_recent_documents": "最近文档",
|
"component.office_recent_documents": "最近文档",
|
||||||
|
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
|
||||||
|
"whiteboard.settings.retention.title": "笔记保留时间",
|
||||||
|
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
|
||||||
|
"whiteboard.settings.retention.option": "{0} 天",
|
||||||
|
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
|
||||||
|
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
|
||||||
|
"office_recent_documents.settings.sources_title": "最近文档来源",
|
||||||
|
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
|
||||||
|
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
|
||||||
|
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
|
||||||
|
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
|
||||||
|
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
"component.study_session_control": "自习时段控制",
|
"component.study_session_control": "自习时段控制",
|
||||||
|
|||||||
@@ -58,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
|
|
||||||
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
|
||||||
|
|
||||||
|
public int WhiteboardNoteRetentionDays { get; set; } = 15;
|
||||||
|
|
||||||
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
|
||||||
|
|
||||||
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
|
||||||
|
|
||||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||||
|
|
||||||
|
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
|
||||||
|
|
||||||
public ComponentSettingsSnapshot Clone()
|
public ComponentSettingsSnapshot Clone()
|
||||||
{
|
{
|
||||||
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
|
||||||
@@ -91,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
|
||||||
? new List<string>(WorldClockTimeZoneIds)
|
? new List<string>(WorldClockTimeZoneIds)
|
||||||
: [];
|
: [];
|
||||||
|
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
|
||||||
|
? new List<string>(OfficeRecentDocumentsEnabledSources)
|
||||||
|
: null;
|
||||||
|
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|||||||
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public static class OfficeRecentDocumentSourceTypes
|
||||||
|
{
|
||||||
|
public const string Registry = "registry";
|
||||||
|
public const string RecentFolders = "recent_folders";
|
||||||
|
public const string JumpLists = "jump_lists";
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> SupportedValues { get; } =
|
||||||
|
[
|
||||||
|
Registry,
|
||||||
|
RecentFolders,
|
||||||
|
JumpLists
|
||||||
|
];
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> DefaultValues => SupportedValues;
|
||||||
|
|
||||||
|
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
|
||||||
|
{
|
||||||
|
if (values is null)
|
||||||
|
{
|
||||||
|
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = values
|
||||||
|
.Select(NormalizeValue)
|
||||||
|
.OfType<string>()
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (normalized.Length == 0 && useDefaultWhenEmpty)
|
||||||
|
{
|
||||||
|
return DefaultValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeValue(string? value)
|
||||||
|
{
|
||||||
|
return value?.Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
Registry => Registry,
|
||||||
|
RecentFolders => RecentFolders,
|
||||||
|
JumpLists => JumpLists,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public static class WhiteboardNoteRetentionPolicy
|
||||||
|
{
|
||||||
|
public const int MinimumDays = 7;
|
||||||
|
public const int MaximumDays = 15;
|
||||||
|
public const int DefaultDays = MaximumDays;
|
||||||
|
|
||||||
|
public static int NormalizeDays(int days)
|
||||||
|
{
|
||||||
|
if (days < MinimumDays)
|
||||||
|
{
|
||||||
|
return MinimumDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days > MaximumDays)
|
||||||
|
{
|
||||||
|
return MaximumDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNoteSnapshot
|
||||||
|
{
|
||||||
|
public int Version { get; set; } = 1;
|
||||||
|
|
||||||
|
public DateTimeOffset SavedUtc { get; set; }
|
||||||
|
|
||||||
|
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
|
||||||
|
|
||||||
|
public WhiteboardNoteSnapshot Clone()
|
||||||
|
{
|
||||||
|
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
|
||||||
|
clone.Strokes = Strokes is { Count: > 0 }
|
||||||
|
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
|
||||||
|
: [];
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WhiteboardStrokeSnapshot
|
||||||
|
{
|
||||||
|
public string Color { get; set; } = "#FF000000";
|
||||||
|
|
||||||
|
public double InkThickness { get; set; } = 2.5d;
|
||||||
|
|
||||||
|
public bool IgnorePressure { get; set; } = true;
|
||||||
|
|
||||||
|
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
|
||||||
|
|
||||||
|
public WhiteboardStrokeSnapshot Clone()
|
||||||
|
{
|
||||||
|
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
|
||||||
|
clone.Points = Points is { Count: > 0 }
|
||||||
|
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
|
||||||
|
: [];
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class WhiteboardStylusPointSnapshot
|
||||||
|
{
|
||||||
|
public double X { get; set; }
|
||||||
|
|
||||||
|
public double Y { get; set; }
|
||||||
|
|
||||||
|
public double Pressure { get; set; } = 0.5d;
|
||||||
|
|
||||||
|
public double Width { get; set; }
|
||||||
|
|
||||||
|
public double Height { get; set; }
|
||||||
|
|
||||||
|
public WhiteboardStylusPointSnapshot Clone()
|
||||||
|
{
|
||||||
|
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,6 +43,7 @@ sealed class Program
|
|||||||
|
|
||||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||||
|
ScheduleWhiteboardNoteStartupCleanup();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -88,6 +89,25 @@ sealed class Program
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ScheduleWhiteboardNoteStartupCleanup()
|
||||||
|
{
|
||||||
|
_ = Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
|
||||||
|
if (deletedCount > 0)
|
||||||
|
{
|
||||||
|
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||||
{
|
{
|
||||||
var singleInstance = SingleInstanceService.CreateDefault();
|
var singleInstance = SingleInstanceService.CreateDefault();
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
|
|||||||
_databasePath = Path.Combine(dataDirectory, "app.db");
|
_databasePath = Path.Combine(dataDirectory, "app.db");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public AppDatabaseService(string databasePath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(databasePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
_databasePath = databasePath;
|
||||||
|
}
|
||||||
|
|
||||||
public SqliteConnection OpenConnection()
|
public SqliteConnection OpenConnection()
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(_databasePath);
|
var directory = Path.GetDirectoryName(_databasePath);
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
|||||||
|
|
||||||
public void DeleteForComponent(string componentId, string? placementId)
|
public void DeleteForComponent(string componentId, string? placementId)
|
||||||
{
|
{
|
||||||
|
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
|
||||||
|
|
||||||
if (_settingsService is not null)
|
if (_settingsService is not null)
|
||||||
{
|
{
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ public static class DesktopComponentEditorRegistryFactory
|
|||||||
[BuiltInComponentIds.DesktopRemovableStorage] = new(
|
[BuiltInComponentIds.DesktopRemovableStorage] = new(
|
||||||
BuiltInComponentIds.DesktopRemovableStorage,
|
BuiltInComponentIds.DesktopRemovableStorage,
|
||||||
context => new RemovableStorageComponentEditor(context)),
|
context => new RemovableStorageComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopWhiteboard] = new(
|
||||||
|
BuiltInComponentIds.DesktopWhiteboard,
|
||||||
|
context => new WhiteboardComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
|
||||||
|
BuiltInComponentIds.DesktopBlackboardLandscape,
|
||||||
|
context => new WhiteboardComponentEditor(context)),
|
||||||
|
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
|
||||||
|
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||||
|
context => new OfficeRecentDocumentsComponentEditor(context)),
|
||||||
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
|
||||||
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
|
||||||
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public interface IWhiteboardNotePersistenceService
|
||||||
|
{
|
||||||
|
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
|
||||||
|
|
||||||
|
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||||
|
|
||||||
|
bool DeleteNote(string componentId, string? placementId);
|
||||||
|
|
||||||
|
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
|
||||||
|
|
||||||
|
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
|
||||||
|
|
||||||
|
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ using System.Runtime.Versioning;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
using Microsoft.Win32;
|
using Microsoft.Win32;
|
||||||
using MudTools.OfficeInterop;
|
using MudTools.OfficeInterop;
|
||||||
using MudTools.OfficeInterop.Excel;
|
using MudTools.OfficeInterop.Excel;
|
||||||
@@ -18,7 +19,7 @@ namespace LanMountainDesktop.Services;
|
|||||||
|
|
||||||
public interface IOfficeRecentDocumentsService
|
public interface IOfficeRecentDocumentsService
|
||||||
{
|
{
|
||||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
|
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
|
||||||
void OpenDocument(string filePath);
|
void OpenDocument(string filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,20 +49,38 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
|||||||
@"\[T(?<filetime>[0-9A-F]+)\]",
|
@"\[T(?<filetime>[0-9A-F]+)\]",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
|
||||||
{
|
{
|
||||||
var documents = new List<OfficeRecentDocument>();
|
var documents = new List<OfficeRecentDocument>();
|
||||||
|
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||||
|
enabledSources,
|
||||||
|
useDefaultWhenEmpty: enabledSources is null);
|
||||||
|
|
||||||
if (!OperatingSystem.IsWindows())
|
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
|
||||||
{
|
{
|
||||||
return documents;
|
return documents;
|
||||||
}
|
}
|
||||||
|
|
||||||
TryGetFromRegistry(documents);
|
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||||
TryGetFromRecentFolders(documents);
|
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||||
TryGetFromJumpLists(documents);
|
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (documents.Count < maxCount)
|
if (useRegistry)
|
||||||
|
{
|
||||||
|
TryGetFromRegistry(documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useRecentFolders)
|
||||||
|
{
|
||||||
|
TryGetFromRecentFolders(documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useJumpLists)
|
||||||
|
{
|
||||||
|
TryGetFromJumpLists(documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useRegistry && documents.Count < maxCount)
|
||||||
{
|
{
|
||||||
TryGetFromMudToolsInterop(documents);
|
TryGetFromMudToolsInterop(documents);
|
||||||
}
|
}
|
||||||
|
|||||||
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
|
||||||
|
{
|
||||||
|
private const int DefaultCleanupBatchSize = 256;
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly object _schemaSyncRoot = new();
|
||||||
|
private readonly AppDatabaseService _databaseService;
|
||||||
|
private bool _schemaInitialized;
|
||||||
|
|
||||||
|
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
|
||||||
|
{
|
||||||
|
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
DeleteExpiredInternal(
|
||||||
|
connection,
|
||||||
|
normalizedComponentId,
|
||||||
|
normalizedPlacementId,
|
||||||
|
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
SELECT note_json, saved_at_utc_ms
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
if (!reader.Read() || reader.IsDBNull(0))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = reader.GetString(0);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
|
||||||
|
if (!reader.IsDBNull(1))
|
||||||
|
{
|
||||||
|
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsExpired(snapshot, retentionDays))
|
||||||
|
{
|
||||||
|
DeleteNote(normalizedComponentId, normalizedPlacementId);
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.Clone();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nowUtc = DateTimeOffset.UtcNow;
|
||||||
|
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
|
||||||
|
persistedSnapshot.SavedUtc = nowUtc;
|
||||||
|
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
|
||||||
|
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
|
||||||
|
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
INSERT INTO whiteboard_notes(
|
||||||
|
component_id,
|
||||||
|
placement_id,
|
||||||
|
note_json,
|
||||||
|
saved_at_utc_ms,
|
||||||
|
expires_at_utc_ms,
|
||||||
|
updated_at_utc_ms)
|
||||||
|
VALUES(
|
||||||
|
$componentId,
|
||||||
|
$placementId,
|
||||||
|
$noteJson,
|
||||||
|
$savedAtUtcMs,
|
||||||
|
$expiresAtUtcMs,
|
||||||
|
$updatedAtUtcMs)
|
||||||
|
ON CONFLICT(component_id, placement_id) DO UPDATE SET
|
||||||
|
note_json = excluded.note_json,
|
||||||
|
saved_at_utc_ms = excluded.saved_at_utc_ms,
|
||||||
|
expires_at_utc_ms = excluded.expires_at_utc_ms,
|
||||||
|
updated_at_utc_ms = excluded.updated_at_utc_ms;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
command.Parameters.AddWithValue("$noteJson", json);
|
||||||
|
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep whiteboard usable even when persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool DeleteNote(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
|
||||||
|
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
|
||||||
|
return command.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
return DeleteExpiredInternal(
|
||||||
|
connection,
|
||||||
|
normalizedComponentId,
|
||||||
|
normalizedPlacementId,
|
||||||
|
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
|
||||||
|
DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = OpenConnection();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE rowid IN (
|
||||||
|
SELECT rowid
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE expires_at_utc_ms <= $nowUtcMs
|
||||||
|
ORDER BY expires_at_utc_ms ASC
|
||||||
|
LIMIT $batchSize
|
||||||
|
);
|
||||||
|
""";
|
||||||
|
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
|
||||||
|
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
|
||||||
|
return command.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
|
||||||
|
{
|
||||||
|
if (snapshot is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
|
||||||
|
if (!expirationUtc.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||||
|
{
|
||||||
|
if (snapshot is null || snapshot.SavedUtc == default)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection OpenConnection()
|
||||||
|
{
|
||||||
|
var connection = _databaseService.OpenConnection();
|
||||||
|
EnsureSchema(connection);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSchema(SqliteConnection connection)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_schemaSyncRoot)
|
||||||
|
{
|
||||||
|
if (_schemaInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = """
|
||||||
|
CREATE TABLE IF NOT EXISTS whiteboard_notes (
|
||||||
|
component_id TEXT NOT NULL,
|
||||||
|
placement_id TEXT NOT NULL,
|
||||||
|
note_json TEXT NOT NULL,
|
||||||
|
saved_at_utc_ms INTEGER NOT NULL,
|
||||||
|
expires_at_utc_ms INTEGER NOT NULL,
|
||||||
|
updated_at_utc_ms INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (component_id, placement_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
|
||||||
|
ON whiteboard_notes(expires_at_utc_ms);
|
||||||
|
""";
|
||||||
|
command.ExecuteNonQuery();
|
||||||
|
_schemaInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool DeleteExpiredInternal(
|
||||||
|
SqliteConnection connection,
|
||||||
|
string componentId,
|
||||||
|
string placementId,
|
||||||
|
int retentionDays,
|
||||||
|
DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
using var selectCommand = connection.CreateCommand();
|
||||||
|
selectCommand.CommandText = """
|
||||||
|
SELECT saved_at_utc_ms
|
||||||
|
FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId
|
||||||
|
LIMIT 1;
|
||||||
|
""";
|
||||||
|
selectCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
selectCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
|
||||||
|
var scalar = selectCommand.ExecuteScalar();
|
||||||
|
if (scalar is not long savedAtUtcMs)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
|
||||||
|
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||||
|
if (expiresUtc > nowUtc)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var deleteCommand = connection.CreateCommand();
|
||||||
|
deleteCommand.CommandText = """
|
||||||
|
DELETE FROM whiteboard_notes
|
||||||
|
WHERE component_id = $componentId
|
||||||
|
AND placement_id = $placementId;
|
||||||
|
""";
|
||||||
|
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
|
||||||
|
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
|
||||||
|
return deleteCommand.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryNormalizeKeys(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
out string normalizedComponentId,
|
||||||
|
out string normalizedPlacementId)
|
||||||
|
{
|
||||||
|
normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||||
|
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
return !string.IsNullOrWhiteSpace(normalizedComponentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeBatchSize(int batchSize)
|
||||||
|
{
|
||||||
|
return batchSize <= 0
|
||||||
|
? DefaultCleanupBatchSize
|
||||||
|
: Math.Clamp(batchSize, 1, 4096);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -165,7 +165,13 @@
|
|||||||
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
<Setter Property="RenderTransform" Value="scale(1.05)" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-panel">
|
<!--
|
||||||
|
半透明表面样式类
|
||||||
|
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
|
||||||
|
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Style Selector="Border.surface-translucent-panel">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.2" />
|
<Setter Property="BorderThickness" Value="1.2" />
|
||||||
@@ -174,7 +180,7 @@
|
|||||||
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-strong">
|
<Style Selector="Border.surface-translucent-strong">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
@@ -183,7 +189,7 @@
|
|||||||
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-island">
|
<Style Selector="Border.surface-translucent-island">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
|
||||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1.5" />
|
<Setter Property="BorderThickness" Value="1.5" />
|
||||||
@@ -197,7 +203,7 @@
|
|||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.mica-strong">
|
<Style Selector="Border.surface-solid-strong">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="36" />
|
<Setter Property="CornerRadius" Value="36" />
|
||||||
@@ -205,11 +211,18 @@
|
|||||||
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<Style Selector="Border.glass-overlay">
|
<Style Selector="Border.surface-translucent-overlay">
|
||||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- 向后兼容的旧样式类(已弃用) -->
|
||||||
|
<Style Selector="Border.glass-panel" />
|
||||||
|
<Style Selector="Border.glass-strong" />
|
||||||
|
<Style Selector="Border.glass-island" />
|
||||||
|
<Style Selector="Border.mica-strong" />
|
||||||
|
<Style Selector="Border.glass-overlay" />
|
||||||
|
|
||||||
</Styles>
|
</Styles>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<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"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.OfficeRecentDocumentsComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="SourcesHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="SourcesDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<CheckBox x:Name="RegistryCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
<CheckBox x:Name="RecentFoldersCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
<CheckBox x:Name="JumpListsCheckBox"
|
||||||
|
IsCheckedChanged="OnSourceSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="HintTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
Margin="12,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public OfficeRecentDocumentsComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||||
|
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||||
|
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||||
|
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
|
||||||
|
"component.office_recent_documents",
|
||||||
|
"Recent Documents");
|
||||||
|
DescriptionTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.desc",
|
||||||
|
"Choose which Windows and Office sources this widget should scan for recent documents.");
|
||||||
|
SourcesHeaderTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.sources_title",
|
||||||
|
"Recent document sources");
|
||||||
|
SourcesDescriptionTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.sources_desc",
|
||||||
|
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
|
||||||
|
RegistryCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.registry",
|
||||||
|
"Office registry MRU");
|
||||||
|
RecentFoldersCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.recent_folders",
|
||||||
|
"Windows Recent folders");
|
||||||
|
JumpListsCheckBox.Content = L(
|
||||||
|
"office_recent_documents.settings.source.jump_lists",
|
||||||
|
"Windows Jump Lists");
|
||||||
|
HintTextBlock.Text = L(
|
||||||
|
"office_recent_documents.settings.hint",
|
||||||
|
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||||
|
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||||
|
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectedSources = new[]
|
||||||
|
{
|
||||||
|
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
|
||||||
|
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
|
||||||
|
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
|
||||||
|
}
|
||||||
|
.Where(static value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<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"
|
||||||
|
x:Class="LanMountainDesktop.Views.ComponentEditors.WhiteboardComponentEditor">
|
||||||
|
<StackPanel Spacing="16">
|
||||||
|
<Border Classes="component-editor-hero-card"
|
||||||
|
Padding="24">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock x:Name="HeadlineTextBlock"
|
||||||
|
Classes="component-editor-headline"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Classes="component-editor-card"
|
||||||
|
Padding="20">
|
||||||
|
<StackPanel Spacing="12">
|
||||||
|
<TextBlock x:Name="RetentionHeaderTextBlock"
|
||||||
|
Classes="component-editor-section-title" />
|
||||||
|
<TextBlock x:Name="RetentionDescriptionTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
<ComboBox x:Name="RetentionComboBox"
|
||||||
|
Classes="component-editor-select"
|
||||||
|
HorizontalAlignment="Stretch"
|
||||||
|
SelectionChanged="OnRetentionSelectionChanged" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock x:Name="InstanceHintTextBlock"
|
||||||
|
Classes="component-editor-secondary-text"
|
||||||
|
Margin="12,0"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||||
|
|
||||||
|
public partial class WhiteboardComponentEditor : ComponentEditorViewBase
|
||||||
|
{
|
||||||
|
private bool _suppressEvents;
|
||||||
|
|
||||||
|
public WhiteboardComponentEditor()
|
||||||
|
: this(null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WhiteboardComponentEditor(DesktopComponentEditorContext? context)
|
||||||
|
: base(context)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
BuildRetentionOptions();
|
||||||
|
ApplyState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRetentionOptions()
|
||||||
|
{
|
||||||
|
RetentionComboBox.Items.Clear();
|
||||||
|
for (var days = WhiteboardNoteRetentionPolicy.MinimumDays; days <= WhiteboardNoteRetentionPolicy.MaximumDays; days++)
|
||||||
|
{
|
||||||
|
var item = new ComboBoxItem
|
||||||
|
{
|
||||||
|
Tag = days.ToString(),
|
||||||
|
Content = L(
|
||||||
|
"whiteboard.settings.retention.option",
|
||||||
|
"{0} days").Replace("{0}", days.ToString())
|
||||||
|
};
|
||||||
|
item.Classes.Add("component-editor-select-item");
|
||||||
|
RetentionComboBox.Items.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyState()
|
||||||
|
{
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
var retentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
|
||||||
|
|
||||||
|
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Blackboard";
|
||||||
|
DescriptionTextBlock.Text = L(
|
||||||
|
"whiteboard.settings.desc",
|
||||||
|
"Each blackboard keeps its own note history and saves it independently.");
|
||||||
|
RetentionHeaderTextBlock.Text = L(
|
||||||
|
"whiteboard.settings.retention.title",
|
||||||
|
"Note retention");
|
||||||
|
RetentionDescriptionTextBlock.Text = L(
|
||||||
|
"whiteboard.settings.retention.desc",
|
||||||
|
"Choose how long this blackboard should keep saved notes before expired data is removed automatically.");
|
||||||
|
InstanceHintTextBlock.Text = L(
|
||||||
|
"whiteboard.settings.instance_scope",
|
||||||
|
"This retention setting is stored per blackboard component instance.");
|
||||||
|
|
||||||
|
_suppressEvents = true;
|
||||||
|
RetentionComboBox.SelectedItem = RetentionComboBox.Items
|
||||||
|
.OfType<ComboBoxItem>()
|
||||||
|
.FirstOrDefault(item =>
|
||||||
|
item.Tag is string tag &&
|
||||||
|
int.TryParse(tag, out var days) &&
|
||||||
|
days == retentionDays);
|
||||||
|
_suppressEvents = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnRetentionSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_suppressEvents)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = LoadSnapshot();
|
||||||
|
snapshot.WhiteboardNoteRetentionDays = GetSelectedRetentionDays();
|
||||||
|
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.WhiteboardNoteRetentionDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int GetSelectedRetentionDays()
|
||||||
|
{
|
||||||
|
if (RetentionComboBox.SelectedItem is ComboBoxItem item &&
|
||||||
|
item.Tag is string tag &&
|
||||||
|
int.TryParse(tag, out var days))
|
||||||
|
{
|
||||||
|
return NormalizeRetentionDays(days);
|
||||||
|
}
|
||||||
|
|
||||||
|
return WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int NormalizeRetentionDays(int days)
|
||||||
|
{
|
||||||
|
return WhiteboardNoteRetentionPolicy.NormalizeDays(
|
||||||
|
days <= 0
|
||||||
|
? WhiteboardNoteRetentionPolicy.DefaultDays
|
||||||
|
: days);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
<Grid Grid.Row="1"
|
<Grid Grid.Row="1"
|
||||||
ColumnDefinitions="240,*"
|
ColumnDefinitions="240,*"
|
||||||
ColumnSpacing="12">
|
ColumnSpacing="12">
|
||||||
<Border Classes="glass-panel"
|
<Border Classes="surface-translucent-panel"
|
||||||
CornerRadius="24"
|
CornerRadius="24"
|
||||||
Padding="10">
|
Padding="10">
|
||||||
<ListBox x:Name="CategoryListBox"
|
<ListBox x:Name="CategoryListBox"
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border Grid.Column="1"
|
<Border Grid.Column="1"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="24"
|
CornerRadius="24"
|
||||||
Padding="10">
|
Padding="10">
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
|
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
|
||||||
|
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-panel"
|
Classes="surface-translucent-panel"
|
||||||
Padding="0"
|
Padding="0"
|
||||||
CornerRadius="24">
|
CornerRadius="24">
|
||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware
|
||||||
{
|
{
|
||||||
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
|
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
|
||||||
|
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||||
private List<OfficeRecentDocument> _documents = new();
|
private List<OfficeRecentDocument> _documents = new();
|
||||||
|
private string _componentId = BuiltInComponentIds.DesktopOfficeRecentDocuments;
|
||||||
|
private string _placementId = string.Empty;
|
||||||
|
private IReadOnlyList<string> _enabledSources = OfficeRecentDocumentSourceTypes.DefaultValues;
|
||||||
private bool _isOnActivePage;
|
private bool _isOnActivePage;
|
||||||
private bool _isEditMode;
|
private bool _isEditMode;
|
||||||
private bool _isLoading;
|
private bool _isLoading;
|
||||||
@@ -20,6 +26,7 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
|||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
_recentDocumentsService = new OfficeRecentDocumentsService();
|
_recentDocumentsService = new OfficeRecentDocumentsService();
|
||||||
|
ReloadSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -44,6 +51,15 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
|
? BuiltInComponentIds.DesktopOfficeRecentDocuments
|
||||||
|
: componentId.Trim();
|
||||||
|
_placementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
ReloadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
private async void LoadDocuments()
|
private async void LoadDocuments()
|
||||||
{
|
{
|
||||||
if (_isLoading)
|
if (_isLoading)
|
||||||
@@ -54,10 +70,12 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
|
ReloadSettings();
|
||||||
StatusTextBlock.IsVisible = false;
|
StatusTextBlock.IsVisible = false;
|
||||||
DocumentsItemsControl.ItemsSource = null;
|
DocumentsItemsControl.ItemsSource = null;
|
||||||
|
|
||||||
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20));
|
var enabledSources = _enabledSources.ToArray();
|
||||||
|
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20, enabledSources));
|
||||||
|
|
||||||
if (_documents.Count == 0)
|
if (_documents.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -80,6 +98,14 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReloadSettings()
|
||||||
|
{
|
||||||
|
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||||
|
_enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||||
|
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||||
|
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||||
|
}
|
||||||
|
|
||||||
private void UpdateDisplay()
|
private void UpdateDisplay()
|
||||||
{
|
{
|
||||||
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
|
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
d:DesignHeight="220"
|
d:DesignHeight="220"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="22"
|
CornerRadius="22"
|
||||||
Padding="12,10"
|
Padding="12,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
d:DesignHeight="220"
|
d:DesignHeight="220"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="22"
|
CornerRadius="22"
|
||||||
Padding="14,10"
|
Padding="14,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
d:DesignHeight="320"
|
d:DesignHeight="320"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="24"
|
CornerRadius="24"
|
||||||
Padding="14,10"
|
Padding="14,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
d:DesignHeight="320"
|
d:DesignHeight="320"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="24"
|
CornerRadius="24"
|
||||||
Padding="14,10"
|
Padding="14,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
d:DesignHeight="360"
|
d:DesignHeight="360"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="24"
|
CornerRadius="24"
|
||||||
Padding="16,14"
|
Padding="16,14"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
d:DesignHeight="150"
|
d:DesignHeight="150"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="18"
|
CornerRadius="18"
|
||||||
Padding="14,10"
|
Padding="14,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
d:DesignHeight="220"
|
d:DesignHeight="220"
|
||||||
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
|
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
|
||||||
<Border x:Name="RootBorder"
|
<Border x:Name="RootBorder"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
CornerRadius="22"
|
CornerRadius="22"
|
||||||
Padding="12,10"
|
Padding="12,10"
|
||||||
ClipToBounds="True">
|
ClipToBounds="True">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
@@ -9,13 +10,18 @@ using Avalonia.Interactivity;
|
|||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Platform.Storage;
|
using Avalonia.Platform.Storage;
|
||||||
using Avalonia.Styling;
|
using Avalonia.Styling;
|
||||||
|
using Avalonia.Threading;
|
||||||
using DotNetCampus.Inking;
|
using DotNetCampus.Inking;
|
||||||
|
using DotNetCampus.Inking.Primitive;
|
||||||
using FluentIcons.Avalonia;
|
using FluentIcons.Avalonia;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
|
||||||
namespace LanMountainDesktop.Views.Components;
|
namespace LanMountainDesktop.Views.Components;
|
||||||
|
|
||||||
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||||
{
|
{
|
||||||
private enum WhiteboardToolMode
|
private enum WhiteboardToolMode
|
||||||
{
|
{
|
||||||
@@ -24,11 +30,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
|
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
|
||||||
|
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
|
||||||
private readonly int _baseWidthCells;
|
private readonly int _baseWidthCells;
|
||||||
|
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||||
|
private readonly IWhiteboardNotePersistenceService _notePersistenceService = new WhiteboardNotePersistenceService();
|
||||||
|
private readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromMinutes(5) };
|
||||||
private double _currentCellSize = 48;
|
private double _currentCellSize = 48;
|
||||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||||
private bool? _isNightModeApplied;
|
private bool? _isNightModeApplied;
|
||||||
private SKColor _currentInkColor = SKColors.Black;
|
private SKColor _currentInkColor = SKColors.Black;
|
||||||
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||||
|
private string _placementId = string.Empty;
|
||||||
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
|
private bool _isApplyingPersistedSnapshot;
|
||||||
|
private bool _noteDirty;
|
||||||
|
private int _noteLoadRevision;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public WhiteboardWidget()
|
public WhiteboardWidget()
|
||||||
: this(baseWidthCells: 2)
|
: this(baseWidthCells: 2)
|
||||||
@@ -43,21 +60,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||||
SizeChanged += OnSizeChanged;
|
SizeChanged += OnSizeChanged;
|
||||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||||
|
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
|
||||||
|
|
||||||
ConfigureInkCanvas();
|
ConfigureInkCanvas();
|
||||||
ApplyCellSize(_currentCellSize);
|
ApplyCellSize(_currentCellSize);
|
||||||
|
RefreshFromSettings();
|
||||||
ApplyThemeVisual(force: true);
|
ApplyThemeVisual(force: true);
|
||||||
SetToolMode(WhiteboardToolMode.Pen);
|
SetToolMode(WhiteboardToolMode.Pen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int NoteRetentionDays => _noteRetentionDays;
|
||||||
|
|
||||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
ApplyThemeVisual(force: true);
|
ApplyThemeVisual(force: true);
|
||||||
|
SchedulePersistedNoteLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||||
{
|
{
|
||||||
// Keep all state in-memory for lightweight re-attach scenarios.
|
PersistNoteImmediately();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
@@ -79,6 +101,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
settings.EraserSize = new Size(20, 20);
|
settings.EraserSize = new Size(20, 20);
|
||||||
settings.IsBitmapCacheEnabled = true;
|
settings.IsBitmapCacheEnabled = true;
|
||||||
settings.MaxBitmapCacheSize = 2048;
|
settings.MaxBitmapCacheSize = 2048;
|
||||||
|
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||||
|
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||||
|
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ApplyCellSize(double cellSize)
|
public void ApplyCellSize(double cellSize)
|
||||||
@@ -134,6 +159,63 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
RefreshToolButtonVisuals();
|
RefreshToolButtonVisuals();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||||
|
{
|
||||||
|
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
|
||||||
|
? BuiltInComponentIds.DesktopWhiteboard
|
||||||
|
: componentId.Trim();
|
||||||
|
var nextPlacementId = placementId?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (_noteDirty &&
|
||||||
|
HasValidPersistenceContext() &&
|
||||||
|
(string.Compare(_componentId, nextComponentId, StringComparison.OrdinalIgnoreCase) != 0 ||
|
||||||
|
string.Compare(_placementId, nextPlacementId, StringComparison.OrdinalIgnoreCase) != 0))
|
||||||
|
{
|
||||||
|
PersistNoteImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
|
_componentId = nextComponentId;
|
||||||
|
_placementId = nextPlacementId;
|
||||||
|
RefreshFromSettings();
|
||||||
|
ClearAllStrokes();
|
||||||
|
SchedulePersistedNoteLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshFromSettings()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||||
|
_noteRetentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
|
||||||
|
_notePersistenceService.TryDeleteExpiredNote(_componentId, _placementId, _noteRetentionDays);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
|
||||||
|
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
|
||||||
|
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
|
||||||
|
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
|
||||||
|
}
|
||||||
|
|
||||||
private void RecolorAllStrokes(SKColor targetColor)
|
private void RecolorAllStrokes(SKColor targetColor)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
|
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
|
||||||
@@ -183,6 +265,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int NormalizeRetentionDays(int days)
|
||||||
|
{
|
||||||
|
return WhiteboardNoteRetentionPolicy.NormalizeDays(
|
||||||
|
days <= 0
|
||||||
|
? WhiteboardNoteRetentionPolicy.DefaultDays
|
||||||
|
: days);
|
||||||
|
}
|
||||||
|
|
||||||
private static double CalculateRelativeLuminance(Color color)
|
private static double CalculateRelativeLuminance(Color color)
|
||||||
{
|
{
|
||||||
static double ToLinear(double channel)
|
static double ToLinear(double channel)
|
||||||
@@ -267,25 +357,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var strokeList = InkCanvas.Strokes.ToList();
|
ClearAllStrokes();
|
||||||
foreach (var stroke in strokeList)
|
QueueNoteSave();
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
|
|
||||||
{
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Keep the widget alive even if one stroke removal fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
|
||||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
|
||||||
InkCanvas.InvalidateVisual();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
|
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
|
||||||
@@ -358,4 +431,273 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
|||||||
|
|
||||||
svgCanvas.Flush();
|
svgCanvas.Flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnInkCanvasStrokeCollected(object? sender, DotNetCampus.Inking.Contexts.AvaloniaSkiaInkCanvasStrokeCollectedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
QueueNoteSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
QueueNoteSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnInkCanvasPointerCaptureLost(object? sender, Avalonia.Input.PointerCaptureLostEventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
QueueNoteSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnNoteSaveTimerTick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_noteDirty)
|
||||||
|
{
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
|
var componentId = _componentId;
|
||||||
|
var placementId = _placementId;
|
||||||
|
var retentionDays = _noteRetentionDays;
|
||||||
|
_noteDirty = false;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QueueNoteSave()
|
||||||
|
{
|
||||||
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDirty = true;
|
||||||
|
if (!_noteSaveTimer.IsEnabled)
|
||||||
|
{
|
||||||
|
_noteSaveTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PersistNoteImmediately()
|
||||||
|
{
|
||||||
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_noteDirty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_noteDirty = false;
|
||||||
|
_noteSaveTimer.Stop();
|
||||||
|
var noteSnapshot = BuildNoteSnapshot();
|
||||||
|
var componentId = _componentId;
|
||||||
|
var placementId = _placementId;
|
||||||
|
var retentionDays = _noteRetentionDays;
|
||||||
|
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
||||||
|
componentId,
|
||||||
|
placementId,
|
||||||
|
noteSnapshot,
|
||||||
|
retentionDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void SchedulePersistedNoteLoad()
|
||||||
|
{
|
||||||
|
if (!HasValidPersistenceContext())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var revision = ++_noteLoadRevision;
|
||||||
|
var componentId = _componentId;
|
||||||
|
var placementId = _placementId;
|
||||||
|
var retentionDays = _noteRetentionDays;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var noteSnapshot = await Task.Run(() => _notePersistenceService.LoadNote(componentId, placementId, retentionDays));
|
||||||
|
if (_disposed || revision != _noteLoadRevision ||
|
||||||
|
!string.Equals(_componentId, componentId, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!string.Equals(_placementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (_disposed || revision != _noteLoadRevision)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isApplyingPersistedSnapshot = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClearAllStrokes();
|
||||||
|
ApplyNoteSnapshot(noteSnapshot);
|
||||||
|
RecolorAllStrokes(_currentInkColor);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isApplyingPersistedSnapshot = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best effort only. Whiteboard should stay usable if persistence is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private WhiteboardNoteSnapshot BuildNoteSnapshot()
|
||||||
|
{
|
||||||
|
return new WhiteboardNoteSnapshot
|
||||||
|
{
|
||||||
|
Strokes = InkCanvas.Strokes
|
||||||
|
.Select(BuildStrokeSnapshot)
|
||||||
|
.Where(static stroke => stroke.Points.Count > 0)
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WhiteboardStrokeSnapshot BuildStrokeSnapshot(SkiaStroke stroke)
|
||||||
|
{
|
||||||
|
var pointList = TryGetStrokePoints(stroke);
|
||||||
|
return new WhiteboardStrokeSnapshot
|
||||||
|
{
|
||||||
|
Color = ToHexColor(stroke.Color),
|
||||||
|
InkThickness = stroke.InkThickness,
|
||||||
|
IgnorePressure = stroke.IgnorePressure,
|
||||||
|
Points = pointList
|
||||||
|
.Select(static point => new WhiteboardStylusPointSnapshot
|
||||||
|
{
|
||||||
|
X = point.X,
|
||||||
|
Y = point.Y,
|
||||||
|
Pressure = point.Pressure,
|
||||||
|
Width = point.Width ?? 0,
|
||||||
|
Height = point.Height ?? 0
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
|
||||||
|
{
|
||||||
|
if (snapshot.Strokes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var renderer = InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkStrokeRenderer;
|
||||||
|
foreach (var strokeSnapshot in snapshot.Strokes)
|
||||||
|
{
|
||||||
|
var stylusPoints = strokeSnapshot.Points
|
||||||
|
.Select(ConvertStylusPoint)
|
||||||
|
.ToList();
|
||||||
|
if (stylusPoints.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
|
||||||
|
var staticStroke = SkiaStroke.CreateStaticStroke(
|
||||||
|
InkId.NewId(),
|
||||||
|
path,
|
||||||
|
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
|
||||||
|
ParseStrokeColor(strokeSnapshot.Color),
|
||||||
|
(float)strokeSnapshot.InkThickness,
|
||||||
|
strokeSnapshot.IgnorePressure,
|
||||||
|
renderer);
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||||
|
InkCanvas.InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||||
|
{
|
||||||
|
return new InkStylusPoint(point.X, point.Y, (float)Math.Clamp(point.Pressure, 0f, 1f))
|
||||||
|
{
|
||||||
|
Width = point.Width > 0 ? point.Width : null,
|
||||||
|
Height = point.Height > 0 ? point.Height : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SKColor ParseStrokeColor(string? value)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var color = Color.Parse(value);
|
||||||
|
return new SKColor(color.R, color.G, color.B, color.A);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Fall through to the default color.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SKColors.Black;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToHexColor(SKColor color)
|
||||||
|
{
|
||||||
|
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearAllStrokes()
|
||||||
|
{
|
||||||
|
var strokeList = InkCanvas.Strokes.ToList();
|
||||||
|
foreach (var stroke in strokeList)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
|
||||||
|
{
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Keep the widget alive even if one stroke removal fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||||
|
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||||
|
InkCanvas.InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool HasValidPersistenceContext()
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(_componentId) &&
|
||||||
|
!string.IsNullOrWhiteSpace(_placementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<InkStylusPoint> TryGetStrokePoints(SkiaStroke stroke)
|
||||||
|
{
|
||||||
|
if (StrokePointListProperty?.GetValue(stroke) is IReadOnlyList<InkStylusPoint> pointList)
|
||||||
|
{
|
||||||
|
return pointList;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.Empty<InkStylusPoint>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Interactivity;
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Media.Imaging;
|
using Avalonia.Media.Imaging;
|
||||||
@@ -102,7 +103,11 @@ public partial class MainWindow
|
|||||||
private void ApplyLocalization()
|
private void ApplyLocalization()
|
||||||
{
|
{
|
||||||
Title = L("app.title", "LanMountainDesktop");
|
Title = L("app.title", "LanMountainDesktop");
|
||||||
BackToWindowsTextBlock.Text = L("button.back_to_windows", "Back to Windows");
|
var platformName = OperatingSystem.IsWindows() ? "Windows"
|
||||||
|
: OperatingSystem.IsMacOS() ? "macOS"
|
||||||
|
: "Linux";
|
||||||
|
BackToWindowsTextBlock.Text = Lf("button.back_to_platform", "Back to {0}", platformName);
|
||||||
|
ToolTip.SetTip(BackToWindowsButton, Lf("tooltip.back_to_platform", "Back to {0}", platformName));
|
||||||
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets");
|
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets");
|
||||||
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
|
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
|
||||||
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
|
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
|
|
||||||
<Border x:Name="LauncherPagePanel"
|
<Border x:Name="LauncherPagePanel"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Classes="glass-panel"
|
Classes="surface-translucent-panel"
|
||||||
ClipToBounds="False"
|
ClipToBounds="False"
|
||||||
CornerRadius="36"
|
CornerRadius="36"
|
||||||
Padding="18">
|
Padding="18">
|
||||||
@@ -166,11 +166,9 @@
|
|||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock x:Name="LauncherTitleTextBlock"
|
<TextBlock x:Name="LauncherTitleTextBlock"
|
||||||
FontSize="24"
|
FontSize="24"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold" />
|
||||||
Text="App Launcher" />
|
|
||||||
<TextBlock x:Name="LauncherSubtitleTextBlock"
|
<TextBlock x:Name="LauncherSubtitleTextBlock"
|
||||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||||
Text="Apps and folders from Windows Start Menu." />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1"
|
<Grid Grid.Row="1"
|
||||||
@@ -188,7 +186,7 @@
|
|||||||
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
|
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
|
||||||
PointerPressed="OnLauncherFolderOverlayPointerPressed">
|
PointerPressed="OnLauncherFolderOverlayPointerPressed">
|
||||||
<Border x:Name="LauncherFolderPanel"
|
<Border x:Name="LauncherFolderPanel"
|
||||||
Classes="mica-strong"
|
Classes="surface-solid-strong"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="52"
|
Margin="52"
|
||||||
@@ -216,8 +214,7 @@
|
|||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold" />
|
||||||
Text="Folder" />
|
|
||||||
<Button x:Name="LauncherFolderCloseButton"
|
<Button x:Name="LauncherFolderCloseButton"
|
||||||
Grid.Column="2"
|
Grid.Column="2"
|
||||||
Width="38"
|
Width="38"
|
||||||
@@ -266,7 +263,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<Border x:Name="BottomTaskbarContainer"
|
<Border x:Name="BottomTaskbarContainer"
|
||||||
Classes="glass-island"
|
Classes="surface-translucent-island"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
Grid.Column="0"
|
Grid.Column="0"
|
||||||
Grid.ColumnSpan="1"
|
Grid.ColumnSpan="1"
|
||||||
@@ -290,8 +287,7 @@
|
|||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
BorderThickness="0"
|
BorderThickness="0"
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
Click="OnMinimizeClick"
|
Click="OnMinimizeClick">
|
||||||
ToolTip.Tip="回到Windows">
|
|
||||||
<StackPanel Orientation="Horizontal"
|
<StackPanel Orientation="Horizontal"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
@@ -300,8 +296,7 @@
|
|||||||
Icon="Window"
|
Icon="Window"
|
||||||
IconVariant="Regular" />
|
IconVariant="Regular" />
|
||||||
<TextBlock x:Name="BackToWindowsTextBlock"
|
<TextBlock x:Name="BackToWindowsTextBlock"
|
||||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||||
Text="回到Windows" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
@@ -436,7 +431,7 @@
|
|||||||
<Border x:Name="ComponentLibraryWindow"
|
<Border x:Name="ComponentLibraryWindow"
|
||||||
IsVisible="False"
|
IsVisible="False"
|
||||||
Opacity="0"
|
Opacity="0"
|
||||||
Classes="glass-strong"
|
Classes="surface-translucent-strong"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Bottom"
|
VerticalAlignment="Bottom"
|
||||||
Width="620"
|
Width="620"
|
||||||
@@ -481,7 +476,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Row="1"
|
<Border Grid.Row="1"
|
||||||
Classes="glass-panel"
|
Classes="surface-translucent-panel"
|
||||||
CornerRadius="12"
|
CornerRadius="12"
|
||||||
Padding="14">
|
Padding="14">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
|||||||
Reference in New Issue
Block a user