mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
0.6.8
小黑板数据持久化。
This commit is contained in:
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "自习时段控制",
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
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);
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
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)" />
|
||||
</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>
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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"
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user