小黑板数据持久化。
This commit is contained in:
lincube
2026-03-19 16:27:16 +08:00
parent b3a74aa072
commit cb86ca10e7
27 changed files with 1216 additions and 52 deletions

View File

@@ -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.
}
}
}
}

View File

@@ -410,6 +410,7 @@
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.folder": "Folder",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
@@ -595,6 +596,18 @@
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"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.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",

View File

@@ -408,6 +408,7 @@
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.folder": "文件夹",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
@@ -593,6 +594,18 @@
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"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.study_environment": "环境",
"component.study_session_control": "自习时段控制",

View File

@@ -58,12 +58,15 @@ public sealed class ComponentSettingsSnapshot
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public int WhiteboardNoteRetentionDays { get; set; } = 15;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
public ComponentSettingsSnapshot Clone()
{

View 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;
}
}

View 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();
}
}

View File

@@ -43,6 +43,7 @@ sealed class Program
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
ScheduleWhiteboardNoteStartupCleanup();
try
{
@@ -88,6 +89,25 @@ sealed class Program
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)
{
var singleInstance = SingleInstanceService.CreateDefault();

View File

@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
_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()
{
var directory = Path.GetDirectoryName(_databasePath);

View File

@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
public void DeleteForComponent(string componentId, string? placementId)
{
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(

View File

@@ -75,6 +75,12 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
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)),

View File

@@ -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);
}

View 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);
}
}

View File

@@ -165,7 +165,13 @@
<Setter Property="RenderTransform" Value="scale(1.05)" />
</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="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1.2" />
@@ -174,7 +180,7 @@
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
</Style>
<Style Selector="Border.glass-strong">
<Style Selector="Border.surface-translucent-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
@@ -183,7 +189,7 @@
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
</Style>
<Style Selector="Border.glass-island">
<Style Selector="Border.surface-translucent-island">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
@@ -197,7 +203,7 @@
</Setter>
</Style>
<Style Selector="Border.mica-strong">
<Style Selector="Border.surface-solid-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="36" />
@@ -205,11 +211,18 @@
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
</Style>
<Style Selector="Border.glass-overlay">
<Style Selector="Border.surface-translucent-overlay">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
</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>

View File

@@ -29,7 +29,9 @@ public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewB
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Office Recent Documents";
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.");

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -38,7 +38,7 @@
<Grid Grid.Row="1"
ColumnDefinitions="240,*"
ColumnSpacing="12">
<Border Classes="glass-panel"
<Border Classes="surface-translucent-panel"
CornerRadius="24"
Padding="10">
<ListBox x:Name="CategoryListBox"
@@ -70,7 +70,7 @@
</Border>
<Border Grid.Column="1"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="10">
<ScrollViewer VerticalScrollBarVisibility="Auto"

View File

@@ -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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
<Border x:Name="RootBorder"
Classes="glass-panel"
Classes="surface-translucent-panel"
Padding="0"
CornerRadius="24">
<StackPanel Orientation="Horizontal"

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="14,10"
ClipToBounds="True">

View File

@@ -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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -8,7 +8,7 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="14,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="360"
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="24"
Padding="16,14"
ClipToBounds="True">

View File

@@ -8,7 +8,7 @@
d:DesignHeight="150"
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="18"
Padding="14,10"
ClipToBounds="True">

View File

@@ -7,7 +7,7 @@
d:DesignHeight="220"
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
<Border x:Name="RootBorder"
Classes="glass-strong"
Classes="surface-translucent-strong"
CornerRadius="22"
Padding="12,10"
ClipToBounds="True">

View File

@@ -1,4 +1,5 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -9,13 +10,18 @@ using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using Avalonia.Threading;
using DotNetCampus.Inking;
using DotNetCampus.Inking.Primitive;
using FluentIcons.Avalonia;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using SkiaSharp;
namespace LanMountainDesktop.Views.Components;
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
{
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? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
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 WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private bool? _isNightModeApplied;
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()
: this(baseWidthCells: 2)
@@ -43,21 +60,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
ConfigureInkCanvas();
ApplyCellSize(_currentCellSize);
RefreshFromSettings();
ApplyThemeVisual(force: true);
SetToolMode(WhiteboardToolMode.Pen);
}
public int NoteRetentionDays => _noteRetentionDays;
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyThemeVisual(force: true);
SchedulePersistedNoteLoad();
}
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)
@@ -79,6 +101,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
}
public void ApplyCellSize(double cellSize)
@@ -134,6 +159,63 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
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)
{
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
@@ -183,6 +265,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
return false;
}
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
@@ -267,25 +357,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
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();
ClearAllStrokes();
QueueNoteSave();
}
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
@@ -358,4 +431,273 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
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>();
}
}

View File

@@ -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:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
@@ -158,7 +158,7 @@
<Border x:Name="LauncherPagePanel"
Grid.Column="1"
Classes="glass-panel"
Classes="surface-translucent-panel"
ClipToBounds="False"
CornerRadius="36"
Padding="18">
@@ -166,11 +166,9 @@
<StackPanel Spacing="4">
<TextBlock x:Name="LauncherTitleTextBlock"
FontSize="24"
FontWeight="SemiBold"
Text="App Launcher" />
FontWeight="SemiBold" />
<TextBlock x:Name="LauncherSubtitleTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Apps and folders from Windows Start Menu." />
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
<Grid Grid.Row="1"
@@ -188,7 +186,7 @@
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
PointerPressed="OnLauncherFolderOverlayPointerPressed">
<Border x:Name="LauncherFolderPanel"
Classes="mica-strong"
Classes="surface-solid-strong"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Margin="52"
@@ -216,8 +214,7 @@
Grid.Column="1"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="Folder" />
FontWeight="SemiBold" />
<Button x:Name="LauncherFolderCloseButton"
Grid.Column="2"
Width="38"
@@ -266,7 +263,7 @@
</Border>
<Border x:Name="BottomTaskbarContainer"
Classes="glass-island"
Classes="surface-translucent-island"
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="1"
@@ -434,7 +431,7 @@
<Border x:Name="ComponentLibraryWindow"
IsVisible="False"
Opacity="0"
Classes="glass-strong"
Classes="surface-translucent-strong"
HorizontalAlignment="Center"
VerticalAlignment="Bottom"
Width="620"
@@ -479,7 +476,7 @@
</Grid>
<Border Grid.Row="1"
Classes="glass-panel"
Classes="surface-translucent-panel"
CornerRadius="12"
Padding="14">
<Grid>