Move whiteboard persistence to file storage

Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0.
This commit is contained in:
lincube
2026-05-06 00:45:33 +08:00
parent 60e7f31ba7
commit 68ca532dc0
136 changed files with 30435 additions and 198 deletions

View File

@@ -52,7 +52,6 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" />
<PackageReference Include="Downloader" />
<PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="FluentIcons.Avalonia" />
@@ -80,6 +79,10 @@
<PackageReference Include="log4net" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ThirdParty\DotNetCampus.InkCanvas\DotNetCampus.AvaloniaInkCanvas.Avalonia12.csproj" />
</ItemGroup>
<!-- Launcher 构建目标已移除 - Launcher 现在是独立应用,由 CI/CD 单独构建 -->
<!-- 生成版本信息文件 -->

View File

@@ -5,10 +5,24 @@ namespace LanMountainDesktop.Models;
public sealed class WhiteboardNoteSnapshot
{
public int Version { get; set; } = 1;
public int Version { get; set; } = 2;
public DateTimeOffset SavedUtc { get; set; }
public DateTimeOffset? ExpiresUtc { get; set; }
public double CanvasWidth { get; set; }
public double CanvasHeight { get; set; }
public string BackgroundColor { get; set; } = "#FFFFFFFF";
public double ViewportZoom { get; set; } = 1d;
public double ViewportOffsetX { get; set; }
public double ViewportOffsetY { get; set; }
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
public WhiteboardNoteSnapshot Clone()
@@ -29,6 +43,8 @@ public sealed class WhiteboardStrokeSnapshot
public bool IgnorePressure { get; set; } = true;
public string? PathSvgData { get; set; }
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
public WhiteboardStrokeSnapshot Clone()

View File

@@ -7,7 +7,7 @@ public interface IWhiteboardNotePersistenceService
{
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool DeleteNote(string componentId, string? placementId);

View File

@@ -1,4 +1,7 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
@@ -8,18 +11,39 @@ namespace LanMountainDesktop.Services;
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
{
private const int DefaultCleanupBatchSize = 256;
private const int CurrentSnapshotVersion = 2;
private const string Category = "WhiteboardPersistence";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly object _schemaSyncRoot = new();
private readonly AppDatabaseService _databaseService;
private bool _schemaInitialized;
private readonly object _legacySchemaSyncRoot = new();
private readonly string _whiteboardsRootDirectory;
private readonly AppDatabaseService _legacyDatabaseService;
private bool _legacySchemaInitialized;
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
public WhiteboardNotePersistenceService()
: this(Path.Combine(AppDataPathProvider.GetDataRoot(), "Whiteboards"), AppDatabaseServiceFactory.CreateDefault())
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNotePersistenceService(AppDatabaseService? legacyDatabaseService)
: this(Path.Combine(AppDataPathProvider.GetDataRoot(), "Whiteboards"), legacyDatabaseService)
{
}
public WhiteboardNotePersistenceService(string whiteboardsRootDirectory, AppDatabaseService? legacyDatabaseService = null)
{
if (string.IsNullOrWhiteSpace(whiteboardsRootDirectory))
{
throw new ArgumentException("Whiteboard root directory cannot be null or whitespace.", nameof(whiteboardsRootDirectory));
}
_whiteboardsRootDirectory = Path.GetFullPath(whiteboardsRootDirectory);
_legacyDatabaseService = legacyDatabaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
@@ -29,108 +53,89 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return new WhiteboardNoteSnapshot();
}
var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
var normalizedRetentionDays = WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays);
try
{
using var connection = OpenConnection();
DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
if (File.Exists(notePath))
{
var snapshot = ReadSnapshot(notePath);
if (IsExpired(snapshot, normalizedRetentionDays))
{
TryDeleteFile(notePath);
return new WhiteboardNoteSnapshot();
}
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);
return snapshot.Clone();
}
using var reader = command.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(0))
var legacySnapshot = TryLoadLegacyNote(normalizedComponentId, normalizedPlacementId, normalizedRetentionDays);
if (legacySnapshot.Strokes.Count == 0 && legacySnapshot.SavedUtc == default)
{
return new WhiteboardNoteSnapshot();
}
var json = reader.GetString(0);
if (string.IsNullOrWhiteSpace(json))
if (!IsExpired(legacySnapshot, normalizedRetentionDays))
{
return new WhiteboardNoteSnapshot();
if (SaveNote(normalizedComponentId, normalizedPlacementId, legacySnapshot, normalizedRetentionDays))
{
_ = TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
}
return legacySnapshot.Clone();
}
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();
_ = TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
}
catch
catch (Exception ex)
{
return new WhiteboardNoteSnapshot();
AppLogger.Warn(
Category,
$"Failed to load whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'.",
ex);
}
return new WhiteboardNoteSnapshot();
}
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
public bool SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return;
return false;
}
var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
var tempPath = $"{notePath}.{Guid.NewGuid():N}.tmp";
try
{
var nowUtc = DateTimeOffset.UtcNow;
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
persistedSnapshot.Version = CurrentSnapshotVersion;
persistedSnapshot.SavedUtc = nowUtc;
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
persistedSnapshot.ExpiresUtc = nowUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
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();
var directory = Path.GetDirectoryName(notePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
File.WriteAllText(tempPath, json, Encoding.UTF8);
File.Move(tempPath, notePath, overwrite: true);
return true;
}
catch
catch (Exception ex)
{
// Keep whiteboard usable even when persistence is unavailable.
TryDeleteFile(tempPath);
AppLogger.Warn(
Category,
$"Failed to save whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'; StrokeCount={snapshot?.Strokes.Count ?? 0}.",
ex);
return false;
}
}
@@ -141,23 +146,9 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
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;
}
var deleted = TryDeleteFile(GetNoteFilePath(normalizedComponentId, normalizedPlacementId));
deleted |= TryDeleteLegacyNote(normalizedComponentId, normalizedPlacementId);
return deleted;
}
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
@@ -167,46 +158,72 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return false;
}
var notePath = GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
try
{
using var connection = OpenConnection();
return DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
if (File.Exists(notePath))
{
var snapshot = ReadSnapshot(notePath);
if (IsExpired(snapshot, retentionDays))
{
return TryDeleteFile(notePath);
}
return false;
}
return TryDeleteExpiredLegacyNote(normalizedComponentId, normalizedPlacementId, retentionDays);
}
catch
catch (Exception ex)
{
AppLogger.Warn(
Category,
$"Failed to delete expired whiteboard note. ComponentId='{normalizedComponentId}'; PlacementId='{normalizedPlacementId}'.",
ex);
return false;
}
}
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
{
var deletedCount = 0;
var normalizedBatchSize = NormalizeBatchSize(batchSize);
var nowUtc = now ?? DateTimeOffset.UtcNow;
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();
if (Directory.Exists(_whiteboardsRootDirectory))
{
foreach (var notePath in Directory.EnumerateFiles(_whiteboardsRootDirectory, "*.json", SearchOption.AllDirectories))
{
if (deletedCount >= normalizedBatchSize)
{
break;
}
try
{
var snapshot = ReadSnapshot(notePath);
if (IsExpired(snapshot, WhiteboardNoteRetentionPolicy.DefaultDays, nowUtc) &&
TryDeleteFile(notePath))
{
deletedCount++;
}
}
catch (Exception ex)
{
AppLogger.Warn(Category, $"Failed to inspect whiteboard note file '{notePath}'.", ex);
}
}
}
}
catch
catch (Exception ex)
{
return 0;
AppLogger.Warn(Category, $"Failed to scan whiteboard note directory '{_whiteboardsRootDirectory}'.", ex);
}
deletedCount += DeleteExpiredLegacyNotesBatch(Math.Max(0, normalizedBatchSize - deletedCount), nowUtc);
return deletedCount;
}
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
@@ -227,7 +244,17 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (snapshot is null || snapshot.SavedUtc == default)
if (snapshot is null)
{
return null;
}
if (snapshot.ExpiresUtc.HasValue)
{
return snapshot.ExpiresUtc.Value;
}
if (snapshot.SavedUtc == default)
{
return null;
}
@@ -235,23 +262,170 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
}
private SqliteConnection OpenConnection()
internal string GetNoteFilePathForTests(string componentId, string? placementId)
{
var connection = _databaseService.OpenConnection();
EnsureSchema(connection);
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return string.Empty;
}
return GetNoteFilePath(normalizedComponentId, normalizedPlacementId);
}
private string GetNoteFilePath(string normalizedComponentId, string normalizedPlacementId)
{
return Path.Combine(
_whiteboardsRootDirectory,
SanitizePathSegment(normalizedComponentId),
$"{SanitizePathSegment(normalizedPlacementId)}.json");
}
private static WhiteboardNoteSnapshot ReadSnapshot(string notePath)
{
var json = File.ReadAllText(notePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(json))
{
return new WhiteboardNoteSnapshot();
}
return JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
}
private WhiteboardNoteSnapshot TryLoadLegacyNote(string componentId, string placementId, int retentionDays)
{
try
{
using var connection = OpenLegacyConnection();
TryDeleteExpiredLegacyNote(connection, componentId, placementId, retentionDays, DateTimeOffset.UtcNow);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT note_json, saved_at_utc_ms, expires_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
using var reader = command.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(0))
{
return new WhiteboardNoteSnapshot();
}
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(reader.GetString(0), JsonOptions) ??
new WhiteboardNoteSnapshot();
if (!reader.IsDBNull(1))
{
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
}
if (!reader.IsDBNull(2))
{
snapshot.ExpiresUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(2));
}
return snapshot;
}
catch (Exception ex)
{
AppLogger.Warn(
Category,
$"Failed to load legacy whiteboard note. ComponentId='{componentId}'; PlacementId='{placementId}'.",
ex);
return new WhiteboardNoteSnapshot();
}
}
private bool TryDeleteLegacyNote(string componentId, string placementId)
{
try
{
using var connection = OpenLegacyConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
return command.ExecuteNonQuery() > 0;
}
catch
{
return false;
}
}
private bool TryDeleteExpiredLegacyNote(string componentId, string placementId, int retentionDays)
{
try
{
using var connection = OpenLegacyConnection();
return TryDeleteExpiredLegacyNote(
connection,
componentId,
placementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
}
catch
{
return false;
}
}
private int DeleteExpiredLegacyNotesBatch(int batchSize, DateTimeOffset nowUtc)
{
if (batchSize <= 0)
{
return 0;
}
try
{
using var connection = OpenLegacyConnection();
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", nowUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
return command.ExecuteNonQuery();
}
catch
{
return 0;
}
}
private SqliteConnection OpenLegacyConnection()
{
var connection = _legacyDatabaseService.OpenConnection();
EnsureLegacySchema(connection);
return connection;
}
private void EnsureSchema(SqliteConnection connection)
private void EnsureLegacySchema(SqliteConnection connection)
{
if (_schemaInitialized)
if (_legacySchemaInitialized)
{
return;
}
lock (_schemaSyncRoot)
lock (_legacySchemaSyncRoot)
{
if (_schemaInitialized)
if (_legacySchemaInitialized)
{
return;
}
@@ -272,11 +446,11 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
ON whiteboard_notes(expires_at_utc_ms);
""";
command.ExecuteNonQuery();
_schemaInitialized = true;
_legacySchemaInitialized = true;
}
}
private static bool DeleteExpiredInternal(
private static bool TryDeleteExpiredLegacyNote(
SqliteConnection connection,
string componentId,
string placementId,
@@ -326,7 +500,51 @@ public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenc
{
normalizedComponentId = componentId?.Trim() ?? string.Empty;
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedComponentId);
return !string.IsNullOrWhiteSpace(normalizedComponentId) &&
!string.IsNullOrWhiteSpace(normalizedPlacementId);
}
private static string SanitizePathSegment(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
var builder = new StringBuilder(value.Length);
foreach (var ch in value.Trim())
{
builder.Append(Array.IndexOf(invalidChars, ch) >= 0 ? '_' : ch);
}
var safe = builder.ToString();
if (string.IsNullOrWhiteSpace(safe))
{
return "_";
}
if (safe.Length <= 120)
{
return safe;
}
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(safe)))[..12].ToLowerInvariant();
return $"{safe[..100]}-{hash}";
}
private static bool TryDeleteFile(string path)
{
try
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
{
File.SetAttributes(path, FileAttributes.Normal);
File.Delete(path);
return true;
}
}
catch (Exception ex)
{
AppLogger.Warn(Category, $"Failed to delete whiteboard note file '{path}'.", ex);
}
return false;
}
private static int NormalizeBatchSize(int batchSize)

View File

@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Xml.Linq;
using Avalonia.Media;
using LanMountainDesktop.Models;
using SkiaSharp;
namespace LanMountainDesktop.Services;
public sealed class WhiteboardSvgImportResult
{
public List<WhiteboardStrokeSnapshot> Strokes { get; init; } = [];
public int SkippedPathCount { get; init; }
}
public static class WhiteboardSvgImportService
{
public static WhiteboardSvgImportResult Import(Stream stream, double targetWidth, double targetHeight)
{
ArgumentNullException.ThrowIfNull(stream);
var document = XDocument.Load(stream);
var root = document.Root;
if (root is null)
{
return new WhiteboardSvgImportResult();
}
var viewport = ResolveViewport(root);
var transform = ResolveTransform(viewport, targetWidth, targetHeight);
var importedStrokes = new List<WhiteboardStrokeSnapshot>();
var skippedPathCount = 0;
foreach (var pathElement in root.Descendants().Where(static element =>
string.Equals(element.Name.LocalName, "path", StringComparison.OrdinalIgnoreCase)))
{
var pathData = pathElement.Attribute("d")?.Value;
if (string.IsNullOrWhiteSpace(pathData))
{
skippedPathCount++;
continue;
}
using var parsedPath = SKPath.ParseSvgPathData(pathData);
if (parsedPath is null || parsedPath.IsEmpty)
{
skippedPathCount++;
continue;
}
using var transformedPath = new SKPath(parsedPath);
transformedPath.Transform(transform);
var style = ParseStyle(pathElement.Attribute("style")?.Value);
var fillValue = ResolvePresentationValue(pathElement, style, "fill");
var strokeValue = ResolvePresentationValue(pathElement, style, "stroke");
var strokeWidth = ResolveStrokeWidth(pathElement, style) * ResolveStrokeScale(transform);
if (IsNone(fillValue) && TryParseSvgColor(strokeValue, out var strokeColor))
{
using var filledStrokePath = StrokePathToFillPath(transformedPath, strokeWidth);
if (filledStrokePath.IsEmpty)
{
skippedPathCount++;
continue;
}
importedStrokes.Add(CreateSnapshot(filledStrokePath, strokeColor, strokeWidth));
continue;
}
if (!TryParseSvgColor(fillValue, out var fillColor) &&
!TryParseSvgColor(strokeValue, out fillColor))
{
fillColor = SKColors.Black;
}
importedStrokes.Add(CreateSnapshot(transformedPath, fillColor, Math.Max(1d, strokeWidth)));
}
return new WhiteboardSvgImportResult
{
Strokes = importedStrokes,
SkippedPathCount = skippedPathCount
};
}
private static WhiteboardStrokeSnapshot CreateSnapshot(SKPath path, SKColor color, double inkThickness)
{
return new WhiteboardStrokeSnapshot
{
Color = ToHexColor(color),
InkThickness = Math.Max(0.5d, inkThickness),
IgnorePressure = true,
PathSvgData = path.ToSvgPathData()
};
}
private static SKPath StrokePathToFillPath(SKPath sourcePath, double strokeWidth)
{
var fillPath = new SKPath();
using var paint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = Math.Max(0.5f, (float)strokeWidth),
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round
};
if (!paint.GetFillPath(sourcePath, fillPath))
{
fillPath.Reset();
}
return fillPath;
}
private static SvgViewport ResolveViewport(XElement root)
{
var viewBox = root.Attribute("viewBox")?.Value;
if (!string.IsNullOrWhiteSpace(viewBox))
{
var parts = viewBox
.Split([' ', ','], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(ParseSvgLength)
.ToArray();
if (parts.Length == 4 && parts[2] > 0 && parts[3] > 0)
{
return new SvgViewport(parts[0], parts[1], parts[2], parts[3]);
}
}
var width = ParseSvgLength(root.Attribute("width")?.Value);
var height = ParseSvgLength(root.Attribute("height")?.Value);
return new SvgViewport(0d, 0d, Math.Max(1d, width), Math.Max(1d, height));
}
private static SKMatrix ResolveTransform(SvgViewport viewport, double targetWidth, double targetHeight)
{
var scaleX = targetWidth > 0 ? targetWidth / viewport.Width : 1d;
var scaleY = targetHeight > 0 ? targetHeight / viewport.Height : 1d;
return new SKMatrix
{
ScaleX = (float)scaleX,
SkewX = 0f,
TransX = (float)(-viewport.X * scaleX),
SkewY = 0f,
ScaleY = (float)scaleY,
TransY = (float)(-viewport.Y * scaleY),
Persp0 = 0f,
Persp1 = 0f,
Persp2 = 1f
};
}
private static double ResolveStrokeScale(SKMatrix transform)
{
return Math.Max(0.01d, (Math.Abs(transform.ScaleX) + Math.Abs(transform.ScaleY)) * 0.5d);
}
private static Dictionary<string, string> ParseStyle(string? value)
{
var style = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(value))
{
return style;
}
foreach (var declaration in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var separatorIndex = declaration.IndexOf(':', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex >= declaration.Length - 1)
{
continue;
}
style[declaration[..separatorIndex].Trim()] = declaration[(separatorIndex + 1)..].Trim();
}
return style;
}
private static string? ResolvePresentationValue(
XElement pathElement,
IReadOnlyDictionary<string, string> style,
string key)
{
if (pathElement.Attribute(key)?.Value is { } attributeValue)
{
return attributeValue;
}
return style.TryGetValue(key, out var styleValue) ? styleValue : null;
}
private static double ResolveStrokeWidth(XElement pathElement, IReadOnlyDictionary<string, string> style)
{
var value = ResolvePresentationValue(pathElement, style, "stroke-width");
var parsed = ParseSvgLength(value);
return parsed > 0 ? parsed : 1d;
}
private static double ParseSvgLength(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return 0d;
}
var trimmed = value.Trim();
var end = 0;
while (end < trimmed.Length &&
(char.IsDigit(trimmed[end]) ||
trimmed[end] is '.' or '-' or '+' or 'e' or 'E'))
{
end++;
}
return double.TryParse(
trimmed[..end],
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var parsed)
? parsed
: 0d;
}
private static bool IsNone(string? value)
{
return string.Equals(value?.Trim(), "none", StringComparison.OrdinalIgnoreCase);
}
private static bool TryParseSvgColor(string? value, out SKColor color)
{
color = SKColors.Black;
if (string.IsNullOrWhiteSpace(value) || IsNone(value))
{
return false;
}
var trimmed = value.Trim();
if (string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TryParseShortHexColor(trimmed, out color))
{
return true;
}
try
{
var avaloniaColor = Color.Parse(trimmed);
color = new SKColor(avaloniaColor.R, avaloniaColor.G, avaloniaColor.B, avaloniaColor.A);
return color.Alpha > 0;
}
catch
{
return TryParseNamedColor(trimmed, out color);
}
}
private static bool TryParseShortHexColor(string value, out SKColor color)
{
color = SKColors.Black;
if (!value.StartsWith('#') || value.Length is not (4 or 5))
{
return false;
}
static byte Expand(char ch)
{
var value = Convert.ToByte(ch.ToString(), 16);
return (byte)((value << 4) | value);
}
try
{
var r = Expand(value[1]);
var g = Expand(value[2]);
var b = Expand(value[3]);
var a = value.Length == 5 ? Expand(value[4]) : (byte)255;
color = new SKColor(r, g, b, a);
return true;
}
catch
{
return false;
}
}
private static bool TryParseNamedColor(string value, out SKColor color)
{
color = value.Trim().ToLowerInvariant() switch
{
"black" => SKColors.Black,
"white" => SKColors.White,
"red" => SKColors.Red,
"green" => SKColors.Green,
"blue" => SKColors.Blue,
"yellow" => SKColors.Yellow,
"gray" or "grey" => SKColors.Gray,
_ => default
};
return color != default;
}
private static string ToHexColor(SKColor color)
{
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
}
private readonly record struct SvgViewport(double X, double Y, double Width, double Height);
}

View File

@@ -0,0 +1,132 @@
using System;
using System.Collections.Generic;
using DotNetCampus.Inking.Primitive;
using SkiaSharp;
namespace LanMountainDesktop.Views.Components;
internal static class WhiteboardStrokePathBuilder
{
private const float MinimumInkThickness = 0.5f;
private const float PointOverlapTolerance = 0.01f;
public static SKPath BuildPath(IReadOnlyList<InkStylusPoint> pointList, double inkThickness)
{
var strokePath = new SKPath();
if (pointList.Count == 0)
{
return strokePath;
}
var points = CollectFinitePoints(pointList);
if (points.Count == 0)
{
return strokePath;
}
var normalizedThickness = NormalizeInkThickness(inkThickness);
if (points.Count == 1 || AreAllPointsOverlapping(points))
{
AddSinglePointStroke(strokePath, points[0], normalizedThickness);
return strokePath;
}
using var centerPath = BuildCenterPath(points);
using var strokePaint = new SKPaint
{
IsAntialias = true,
Style = SKPaintStyle.Stroke,
StrokeWidth = normalizedThickness,
StrokeCap = SKStrokeCap.Round,
StrokeJoin = SKStrokeJoin.Round
};
if (!strokePaint.GetFillPath(centerPath, strokePath) || strokePath.IsEmpty)
{
strokePath.Reset();
AddPointFallbackStroke(strokePath, points, normalizedThickness);
}
return strokePath;
}
private static List<SKPoint> CollectFinitePoints(IReadOnlyList<InkStylusPoint> pointList)
{
var points = new List<SKPoint>(pointList.Count);
foreach (var point in pointList)
{
if (!double.IsFinite(point.X) || !double.IsFinite(point.Y))
{
continue;
}
points.Add(new SKPoint((float)point.X, (float)point.Y));
}
return points;
}
private static SKPath BuildCenterPath(IReadOnlyList<SKPoint> points)
{
var centerPath = new SKPath();
centerPath.MoveTo(points[0].X, points[0].Y);
if (points.Count == 2)
{
centerPath.LineTo(points[1].X, points[1].Y);
return centerPath;
}
for (var i = 1; i < points.Count - 1; i++)
{
var current = points[i];
var next = points[i + 1];
var midpoint = new SKPoint(
(current.X + next.X) * 0.5f,
(current.Y + next.Y) * 0.5f);
centerPath.QuadTo(current.X, current.Y, midpoint.X, midpoint.Y);
}
var lastPoint = points[^1];
centerPath.LineTo(lastPoint.X, lastPoint.Y);
return centerPath;
}
private static void AddPointFallbackStroke(SKPath path, IReadOnlyList<SKPoint> points, float inkThickness)
{
foreach (var point in points)
{
AddSinglePointStroke(path, point, inkThickness);
}
}
private static void AddSinglePointStroke(SKPath path, SKPoint point, float inkThickness)
{
path.AddCircle(point.X, point.Y, inkThickness * 0.5f);
}
private static bool AreAllPointsOverlapping(IReadOnlyList<SKPoint> points)
{
var firstPoint = points[0];
for (var i = 1; i < points.Count; i++)
{
if (Math.Abs(points[i].X - firstPoint.X) > PointOverlapTolerance ||
Math.Abs(points[i].Y - firstPoint.Y) > PointOverlapTolerance)
{
return false;
}
}
return true;
}
private static float NormalizeInkThickness(double inkThickness)
{
if (!double.IsFinite(inkThickness))
{
return MinimumInkThickness;
}
return Math.Max(MinimumInkThickness, (float)inkThickness);
}
}

View File

@@ -0,0 +1,137 @@
using System;
using Avalonia;
namespace LanMountainDesktop.Views.Components;
internal readonly record struct WhiteboardViewportState(double Zoom, Vector Offset);
internal static class WhiteboardViewportHelper
{
public const double DefaultZoom = 1d;
public const double MinZoom = 0.5d;
public const double MaxZoom = 4d;
public static WhiteboardViewportState CreateDefault(Size viewportSize, Size canvasSize)
{
return Clamp(new WhiteboardViewportState(DefaultZoom, default), viewportSize, canvasSize);
}
public static WhiteboardViewportState Clamp(
WhiteboardViewportState state,
Size viewportSize,
Size canvasSize)
{
var zoom = NormalizeZoom(state.Zoom);
var viewport = NormalizeSize(viewportSize);
var canvas = NormalizeSize(canvasSize);
var scaledWidth = canvas.Width * zoom;
var scaledHeight = canvas.Height * zoom;
return new WhiteboardViewportState(
zoom,
new Vector(
ClampAxis(state.Offset.X, viewport.Width, scaledWidth),
ClampAxis(state.Offset.Y, viewport.Height, scaledHeight)));
}
public static WhiteboardViewportState PanBy(
WhiteboardViewportState state,
Vector delta,
Size viewportSize,
Size canvasSize)
{
if (!IsFinite(delta.X) || !IsFinite(delta.Y))
{
return Clamp(state, viewportSize, canvasSize);
}
return Clamp(state with { Offset = state.Offset + delta }, viewportSize, canvasSize);
}
public static WhiteboardViewportState ZoomAt(
WhiteboardViewportState state,
double targetZoom,
Point anchorViewportPoint,
Size viewportSize,
Size canvasSize)
{
return ZoomFromGestureStart(
state,
targetZoom,
anchorViewportPoint,
anchorViewportPoint,
viewportSize,
canvasSize);
}
public static WhiteboardViewportState ZoomFromGestureStart(
WhiteboardViewportState startState,
double targetZoom,
Point initialCenter,
Point currentCenter,
Size viewportSize,
Size canvasSize)
{
var start = Clamp(startState, viewportSize, canvasSize);
var zoom = NormalizeZoom(targetZoom);
var anchor = ToLogicalPoint(start, initialCenter);
var offset = new Vector(
currentCenter.X - (anchor.X * zoom),
currentCenter.Y - (anchor.Y * zoom));
return Clamp(new WhiteboardViewportState(zoom, offset), viewportSize, canvasSize);
}
public static Point ToLogicalPoint(WhiteboardViewportState state, Point viewportPoint)
{
var zoom = NormalizeZoom(state.Zoom);
return new Point(
(viewportPoint.X - state.Offset.X) / zoom,
(viewportPoint.Y - state.Offset.Y) / zoom);
}
public static WhiteboardViewportState Fit(Size viewportSize, Size canvasSize)
{
var viewport = NormalizeSize(viewportSize);
var canvas = NormalizeSize(canvasSize);
var fitZoom = Math.Min(viewport.Width / canvas.Width, viewport.Height / canvas.Height);
return Clamp(new WhiteboardViewportState(fitZoom, default), viewport, canvas);
}
public static double NormalizeZoom(double zoom)
{
if (!IsFinite(zoom))
{
return DefaultZoom;
}
return Math.Clamp(zoom, MinZoom, MaxZoom);
}
public static Size NormalizeSize(Size size)
{
return new Size(
IsFinite(size.Width) ? Math.Max(1d, size.Width) : 1d,
IsFinite(size.Height) ? Math.Max(1d, size.Height) : 1d);
}
private static double ClampAxis(double offset, double viewportLength, double scaledCanvasLength)
{
if (!IsFinite(offset))
{
offset = 0d;
}
if (scaledCanvasLength <= viewportLength)
{
return (viewportLength - scaledCanvasLength) * 0.5d;
}
return Math.Clamp(offset, viewportLength - scaledCanvasLength, 0d);
}
private static bool IsFinite(double value)
{
return !double.IsNaN(value) && !double.IsInfinity(value);
}
}

View File

@@ -26,7 +26,21 @@
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas" />
<Grid x:Name="ViewportRoot"
ClipToBounds="True">
<Canvas x:Name="ViewportCanvas"
ClipToBounds="True">
<inking:InkCanvas x:Name="InkCanvas"
Width="1"
Height="1"
Canvas.Left="0"
Canvas.Top="0"
RenderTransformOrigin="0,0" />
</Canvas>
<Border x:Name="PanZoomInputLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<Border x:Name="ToolbarBorder"
@@ -66,6 +80,18 @@
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="HandButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Pan / Zoom"
Click="OnHandButtonClick">
<fi:SymbolIcon x:Name="HandIcon"
Symbol="HandDraw"
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ClearButton"
Width="30"
Height="30"
@@ -78,17 +104,36 @@
IconVariant="Regular"
FontSize="14" />
</Button>
<Button x:Name="ExportButton"
<Button x:Name="FileButton"
Width="30"
Height="30"
Padding="0"
CornerRadius="15"
ToolTip.Tip="Export SVG"
Click="OnExportButtonClick">
<fi:SymbolIcon x:Name="ExportIcon"
Symbol="ArrowExport"
ToolTip.Tip="SVG">
<fi:SymbolIcon x:Name="FileIcon"
Symbol="Document"
IconVariant="Regular"
FontSize="14" />
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Open SVG"
Click="OnImportButtonClick">
<MenuItem.Icon>
<fi:SymbolIcon Symbol="ArrowImport"
IconVariant="Regular"
FontSize="14" />
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Export SVG"
Click="OnExportButtonClick">
<MenuItem.Icon>
<fi:SymbolIcon Symbol="ArrowExport"
IconVariant="Regular"
FontSize="14" />
</MenuItem.Icon>
</MenuItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</Border>

View File

@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
@@ -27,17 +28,31 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private enum WhiteboardToolMode
{
Pen,
Eraser
Eraser,
PanZoom
}
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
private readonly record struct PanZoomGestureBaseline(
WhiteboardViewportState StartViewport,
Point InitialCenter,
double InitialDistance);
private static readonly BindingFlags StrokeReflectionFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color), StrokeReflectionFlags);
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList", StrokeReflectionFlags);
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 readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromSeconds(2) };
private readonly Dictionary<int, Point> _panZoomPointers = [];
private readonly ScaleTransform _viewportScaleTransform = new();
private readonly TranslateTransform _viewportTranslateTransform = new();
private double _currentCellSize = 48;
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
private Size _logicalCanvasSize = new(1, 1);
private WhiteboardViewportState _viewportState = new(WhiteboardViewportHelper.DefaultZoom, default);
private PanZoomGestureBaseline? _panZoomGestureBaseline;
private bool? _isNightModeApplied;
private SKColor _selectedInkColor = SKColors.Black;
private bool _isUserCustomColor;
@@ -47,6 +62,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
private bool _isApplyingPersistedSnapshot;
private bool _noteDirty;
private int _noteSaveRevision;
private int _noteLoadRevision;
private bool _disposed;
@@ -59,6 +75,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
{
_baseWidthCells = Math.Max(1, baseWidthCells);
InitializeComponent();
InkCanvas.RenderTransform = new TransformGroup
{
Children =
{
_viewportScaleTransform,
_viewportTranslateTransform
}
};
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
@@ -66,7 +90,10 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
ConfigureInkCanvas();
ConfigureViewportGestures();
ApplyCellSize(_currentCellSize);
EnsureLogicalCanvasSize(expandToViewport: true);
ApplyViewportTransform();
RefreshFromSettings();
ApplyThemeVisual(force: true);
InitializeColorPicker();
@@ -95,6 +122,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
ApplyThemeVisual(force: true);
EnsureLogicalCanvasSize(expandToViewport: true);
SetViewportState(_viewportState, queueSave: false);
SchedulePersistedNoteLoad();
}
@@ -106,6 +135,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
EnsureLogicalCanvasSize(expandToViewport: true);
SetViewportState(_viewportState, queueSave: false);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
@@ -120,13 +151,24 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
settings.IgnorePressure = true;
settings.InkThickness = _selectedInkThickness;
settings.EraserSize = new Size(20, 20);
settings.IsBitmapCacheEnabled = true;
settings.MaxBitmapCacheSize = 2048;
settings.IsBitmapCacheEnabled = false;
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
InkCanvas.StrokeErased += OnInkCanvasStrokeErased;
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
}
private void ConfigureViewportGestures()
{
PanZoomInputLayer.PointerPressed += OnViewportPointerPressed;
PanZoomInputLayer.PointerMoved += OnViewportPointerMoved;
PanZoomInputLayer.PointerReleased += OnViewportPointerReleased;
PanZoomInputLayer.PointerCaptureLost += OnViewportPointerCaptureLost;
PanZoomInputLayer.PointerWheelChanged += OnViewportPointerWheelChanged;
PanZoomInputLayer.PointerTouchPadGestureMagnify += OnViewportTouchPadGestureMagnify;
PanZoomInputLayer.PointerTouchPadGestureSwipe += OnViewportTouchPadGestureSwipe;
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
@@ -148,7 +190,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ComponentChromeCornerRadiusHelper.SafeValue(toolbarPaddingVertical, 4, 8));
ToolbarButtonsPanel.Spacing = toolbarSpacing;
foreach (var button in new[] { PenButton, EraserButton, ClearButton, ExportButton })
foreach (var button in new[] { PenButton, EraserButton, HandButton, ClearButton, FileButton })
{
button.Width = buttonSize;
button.Height = buttonSize;
@@ -264,15 +306,21 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
if (_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays))
{
_noteDirty = false;
}
}
catch
catch (Exception ex)
{
AppLogger.Warn(
"Whiteboard",
$"Failed to force-save whiteboard note. ComponentId='{_componentId}'; PlacementId='{_placementId}'.",
ex);
}
}
@@ -287,8 +335,17 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
_noteSaveTimer.Stop();
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
InkCanvas.StrokeErased -= OnInkCanvasStrokeErased;
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
PanZoomInputLayer.PointerPressed -= OnViewportPointerPressed;
PanZoomInputLayer.PointerMoved -= OnViewportPointerMoved;
PanZoomInputLayer.PointerReleased -= OnViewportPointerReleased;
PanZoomInputLayer.PointerCaptureLost -= OnViewportPointerCaptureLost;
PanZoomInputLayer.PointerWheelChanged -= OnViewportPointerWheelChanged;
PanZoomInputLayer.PointerTouchPadGestureMagnify -= OnViewportTouchPadGestureMagnify;
PanZoomInputLayer.PointerTouchPadGestureSwipe -= OnViewportTouchPadGestureSwipe;
ClearPanZoomPointers();
}
private void RecolorAllStrokes(SKColor targetColor)
@@ -298,7 +355,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
TrySetStrokeColor(InkCanvas.Strokes[i], targetColor);
}
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}
@@ -366,15 +422,24 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void SetToolMode(WhiteboardToolMode mode)
{
_toolMode = mode;
InkCanvas.EditingMode = mode == WhiteboardToolMode.Pen
? InkCanvasEditingMode.Ink
: InkCanvasEditingMode.EraseByPoint;
InkCanvas.EditingMode = mode switch
{
WhiteboardToolMode.Pen => InkCanvasEditingMode.Ink,
WhiteboardToolMode.Eraser => InkCanvasEditingMode.EraseByPoint,
_ => InkCanvasEditingMode.None
};
if (mode == WhiteboardToolMode.Pen)
{
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
}
if (mode != WhiteboardToolMode.PanZoom)
{
ClearPanZoomPointers();
}
PanZoomInputLayer.IsHitTestVisible = mode == WhiteboardToolMode.PanZoom;
RefreshToolButtonVisuals();
}
@@ -407,8 +472,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ApplyToolButtonVisual(PenButton, _toolMode == WhiteboardToolMode.Pen, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(EraserButton, _toolMode == WhiteboardToolMode.Eraser, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(HandButton, _toolMode == WhiteboardToolMode.PanZoom, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ClearButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(ExportButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
ApplyToolButtonVisual(FileButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
}
private static void ApplyToolButtonVisual(
@@ -476,12 +542,265 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
SetToolMode(WhiteboardToolMode.Eraser);
}
private void OnHandButtonClick(object? sender, RoutedEventArgs e)
{
SetToolMode(WhiteboardToolMode.PanZoom);
}
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
{
ClearAllStrokes();
QueueNoteSave();
}
private void OnViewportPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
if (e.Pointer.Type == PointerType.Mouse &&
!e.GetCurrentPoint(PanZoomInputLayer).Properties.IsLeftButtonPressed)
{
return;
}
_panZoomPointers[e.Pointer.Id] = e.GetPosition(PanZoomInputLayer);
TryCapturePointer(e.Pointer, PanZoomInputLayer);
ResetPanZoomGestureBaseline();
e.Handled = true;
}
private void OnViewportPointerMoved(object? sender, PointerEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom ||
!_panZoomPointers.ContainsKey(e.Pointer.Id))
{
return;
}
if (e.Pointer.Type == PointerType.Mouse &&
!e.GetCurrentPoint(PanZoomInputLayer).Properties.IsLeftButtonPressed)
{
RemovePanZoomPointer(e.Pointer);
e.Handled = true;
return;
}
_panZoomPointers[e.Pointer.Id] = e.GetPosition(PanZoomInputLayer);
ApplyPanZoomPointerGesture();
e.Handled = true;
}
private void OnViewportPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
RemovePanZoomPointer(e.Pointer);
e.Handled = true;
}
private void OnViewportPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
_panZoomPointers.Remove(e.Pointer.Id);
ResetPanZoomGestureBaseline();
}
private void OnViewportPointerWheelChanged(object? sender, PointerWheelEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
var delta = e.Delta.Y;
if (!double.IsFinite(delta) || Math.Abs(delta) <= double.Epsilon)
{
return;
}
var zoomStep = Math.Clamp(delta, -4d, 4d);
var factor = Math.Pow(1.12d, zoomStep);
ZoomViewportAt(_viewportState.Zoom * factor, e.GetPosition(PanZoomInputLayer));
e.Handled = true;
}
private void OnViewportTouchPadGestureMagnify(object? sender, PointerDeltaEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
var delta = Math.Abs(e.Delta.Y) > double.Epsilon ? e.Delta.Y : e.Delta.X;
if (!double.IsFinite(delta) || Math.Abs(delta) <= double.Epsilon)
{
return;
}
var factor = Math.Clamp(1d + delta, 0.5d, 2d);
ZoomViewportAt(_viewportState.Zoom * factor, e.GetPosition(PanZoomInputLayer));
e.Handled = true;
}
private void OnViewportTouchPadGestureSwipe(object? sender, PointerDeltaEventArgs e)
{
if (_toolMode != WhiteboardToolMode.PanZoom)
{
return;
}
PanViewport(e.Delta);
e.Handled = true;
}
private void ApplyPanZoomPointerGesture()
{
if (_panZoomPointers.Count == 0)
{
_panZoomGestureBaseline = null;
return;
}
EnsureLogicalCanvasSize(expandToViewport: true);
if (_panZoomGestureBaseline is null)
{
ResetPanZoomGestureBaseline();
}
if (_panZoomGestureBaseline is not { } baseline)
{
return;
}
var viewportSize = GetViewportSize();
if (_panZoomPointers.Count == 1)
{
var currentPoint = _panZoomPointers.Values.First();
var delta = currentPoint - baseline.InitialCenter;
SetViewportState(
WhiteboardViewportHelper.PanBy(
baseline.StartViewport,
delta,
viewportSize,
_logicalCanvasSize),
queueSave: true);
return;
}
var points = _panZoomPointers.Values.Take(2).ToArray();
var currentCenter = GetCenter(points[0], points[1]);
var currentDistance = GetDistance(points[0], points[1]);
if (baseline.InitialDistance <= 1d || currentDistance <= 1d)
{
return;
}
var targetZoom = baseline.StartViewport.Zoom * (currentDistance / baseline.InitialDistance);
SetViewportState(
WhiteboardViewportHelper.ZoomFromGestureStart(
baseline.StartViewport,
targetZoom,
baseline.InitialCenter,
currentCenter,
viewportSize,
_logicalCanvasSize),
queueSave: true);
}
private void ResetPanZoomGestureBaseline()
{
if (_panZoomPointers.Count == 0)
{
_panZoomGestureBaseline = null;
return;
}
if (_panZoomPointers.Count == 1)
{
_panZoomGestureBaseline = new PanZoomGestureBaseline(
_viewportState,
_panZoomPointers.Values.First(),
0d);
return;
}
var points = _panZoomPointers.Values.Take(2).ToArray();
_panZoomGestureBaseline = new PanZoomGestureBaseline(
_viewportState,
GetCenter(points[0], points[1]),
GetDistance(points[0], points[1]));
}
private void PanViewport(Vector delta)
{
EnsureLogicalCanvasSize(expandToViewport: true);
SetViewportState(
WhiteboardViewportHelper.PanBy(
_viewportState,
delta,
GetViewportSize(),
_logicalCanvasSize),
queueSave: true);
}
private void ZoomViewportAt(double targetZoom, Point anchorViewportPoint)
{
EnsureLogicalCanvasSize(expandToViewport: true);
SetViewportState(
WhiteboardViewportHelper.ZoomAt(
_viewportState,
targetZoom,
anchorViewportPoint,
GetViewportSize(),
_logicalCanvasSize),
queueSave: true);
}
private void RemovePanZoomPointer(IPointer pointer)
{
_panZoomPointers.Remove(pointer.Id);
TryCapturePointer(pointer, null);
ResetPanZoomGestureBaseline();
}
private void ClearPanZoomPointers()
{
_panZoomPointers.Clear();
_panZoomGestureBaseline = null;
}
private static Point GetCenter(Point first, Point second)
{
return new Point((first.X + second.X) * 0.5d, (first.Y + second.Y) * 0.5d);
}
private static double GetDistance(Point first, Point second)
{
return Math.Sqrt(Math.Pow(first.X - second.X, 2d) + Math.Pow(first.Y - second.Y, 2d));
}
private static void TryCapturePointer(IPointer pointer, IInputElement? target)
{
try
{
pointer.Capture(target);
}
catch
{
// Pointer capture is best-effort; the viewport still works without it.
}
}
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
{
var fileName = $"whiteboard-{DateTime.Now:yyyyMMdd-HHmmss}.svg";
@@ -524,10 +843,75 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
ExportSvgToStream(fileStream);
}
private async void OnImportButtonClick(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
var storageProvider = topLevel?.StorageProvider;
if (storageProvider is null)
{
return;
}
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Open Whiteboard SVG",
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType("SVG image")
{
Patterns = ["*.svg"],
MimeTypes = ["image/svg+xml"]
}
]
});
var importFile = files.FirstOrDefault();
if (importFile is null)
{
return;
}
try
{
await using var stream = await importFile.OpenReadAsync();
EnsureLogicalCanvasSize(expandToViewport: true);
var importResult = WhiteboardSvgImportService.Import(
stream,
Math.Max(1d, _logicalCanvasSize.Width),
Math.Max(1d, _logicalCanvasSize.Height));
if (importResult.Strokes.Count == 0)
{
AppLogger.Warn(
"Whiteboard",
$"SVG import did not contain supported paths. SkippedPathCount={importResult.SkippedPathCount}.");
return;
}
ClearAllStrokes();
ApplyNoteSnapshot(new WhiteboardNoteSnapshot
{
Version = 2,
CanvasWidth = Math.Max(1d, _logicalCanvasSize.Width),
CanvasHeight = Math.Max(1d, _logicalCanvasSize.Height),
BackgroundColor = ToHexColor((_isNightModeApplied ?? false) ? SKColors.Black : SKColors.White),
Strokes = importResult.Strokes
});
ResetViewportToFit(queueSave: false);
QueueNoteSave();
}
catch (Exception ex)
{
AppLogger.Warn("Whiteboard", "Failed to import SVG into whiteboard.", ex);
}
}
private void ExportSvgToStream(Stream stream)
{
var width = Math.Max(1d, CanvasBorder.Bounds.Width);
var height = Math.Max(1d, CanvasBorder.Bounds.Height);
EnsureLogicalCanvasSize(expandToViewport: true);
var width = Math.Max(1d, _logicalCanvasSize.Width);
var height = Math.Max(1d, _logicalCanvasSize.Height);
var bounds = SKRect.Create((float)width, (float)height);
using var svgCanvas = SKSvgCanvas.Create(bounds, stream);
@@ -560,6 +944,13 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
QueueNoteSave();
}
private void OnInkCanvasStrokeErased(object? sender, DotNetCampus.Inking.Contexts.ErasingCompletedEventArgs e)
{
_ = sender;
_ = e;
QueueNoteSave();
}
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
{
_ = sender;
@@ -593,9 +984,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var componentId = _componentId;
var placementId = _placementId;
var retentionDays = _noteRetentionDays;
_noteDirty = false;
var saveRevision = _noteSaveRevision;
_noteSaveTimer.Stop();
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays))
.ContinueWith(task =>
{
var saved = task.Status == TaskStatus.RanToCompletion && task.Result;
Dispatcher.UIThread.Post(() =>
{
if (_disposed || saveRevision != _noteSaveRevision)
{
return;
}
_noteDirty = !saved;
if (!saved && !_noteSaveTimer.IsEnabled)
{
_noteSaveTimer.Start();
}
});
});
}
private void QueueNoteSave()
@@ -606,10 +1014,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
_noteDirty = true;
if (!_noteSaveTimer.IsEnabled)
{
_noteSaveTimer.Start();
}
_noteSaveRevision++;
_noteSaveTimer.Stop();
_noteSaveTimer.Start();
}
private void PersistNoteImmediately()
@@ -624,15 +1031,21 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return;
}
_noteDirty = false;
_noteSaveTimer.Stop();
var noteSnapshot = BuildNoteSnapshot();
try
{
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
if (_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays))
{
_noteDirty = false;
}
}
catch
catch (Exception ex)
{
AppLogger.Warn(
"Whiteboard",
$"Failed to persist whiteboard immediately. ComponentId='{_componentId}'; PlacementId='{_placementId}'.",
ex);
}
}
@@ -683,13 +1096,110 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
private void ApplySnapshotCanvasAndViewport(WhiteboardNoteSnapshot snapshot)
{
var viewportSize = GetViewportSize();
var canvasWidth = snapshot.CanvasWidth > 0 ? snapshot.CanvasWidth : viewportSize.Width;
var canvasHeight = snapshot.CanvasHeight > 0 ? snapshot.CanvasHeight : viewportSize.Height;
SetLogicalCanvasSize(new Size(canvasWidth, canvasHeight));
SetViewportState(
new WhiteboardViewportState(
snapshot.ViewportZoom,
new Vector(snapshot.ViewportOffsetX, snapshot.ViewportOffsetY)),
queueSave: false);
}
private void ResetViewportToFit(bool queueSave)
{
EnsureLogicalCanvasSize(expandToViewport: true);
SetViewportState(
WhiteboardViewportHelper.Fit(GetViewportSize(), _logicalCanvasSize),
queueSave);
}
private void EnsureLogicalCanvasSize(bool expandToViewport)
{
var viewportSize = GetViewportSize();
var normalizedCanvasSize = WhiteboardViewportHelper.NormalizeSize(_logicalCanvasSize);
if (_logicalCanvasSize.Width <= 1d || _logicalCanvasSize.Height <= 1d)
{
normalizedCanvasSize = viewportSize;
}
else if (expandToViewport)
{
normalizedCanvasSize = new Size(
Math.Max(normalizedCanvasSize.Width, viewportSize.Width),
Math.Max(normalizedCanvasSize.Height, viewportSize.Height));
}
SetLogicalCanvasSize(normalizedCanvasSize);
}
private void SetLogicalCanvasSize(Size canvasSize)
{
_logicalCanvasSize = WhiteboardViewportHelper.NormalizeSize(canvasSize);
InkCanvas.Width = _logicalCanvasSize.Width;
InkCanvas.Height = _logicalCanvasSize.Height;
}
private Size GetViewportSize()
{
var width = CanvasBorder.Bounds.Width > 1d
? CanvasBorder.Bounds.Width
: Math.Max(1d, _currentCellSize * _baseWidthCells);
var height = CanvasBorder.Bounds.Height > 1d
? CanvasBorder.Bounds.Height
: Math.Max(1d, Bounds.Height > 1d ? Bounds.Height : _currentCellSize * Math.Max(2, _baseWidthCells));
return WhiteboardViewportHelper.NormalizeSize(new Size(width, height));
}
private void SetViewportState(WhiteboardViewportState nextState, bool queueSave)
{
var next = WhiteboardViewportHelper.Clamp(nextState, GetViewportSize(), _logicalCanvasSize);
var changed = !AreViewportStatesClose(_viewportState, next);
_viewportState = next;
ApplyViewportTransform();
if (changed && queueSave)
{
QueueNoteSave();
}
}
private void ApplyViewportTransform()
{
SetLogicalCanvasSize(_logicalCanvasSize);
_viewportScaleTransform.ScaleX = _viewportState.Zoom;
_viewportScaleTransform.ScaleY = _viewportState.Zoom;
_viewportTranslateTransform.X = _viewportState.Offset.X;
_viewportTranslateTransform.Y = _viewportState.Offset.Y;
InkCanvas.InvalidateVisual();
}
private static bool AreViewportStatesClose(WhiteboardViewportState first, WhiteboardViewportState second)
{
const double tolerance = 0.001d;
return Math.Abs(first.Zoom - second.Zoom) <= tolerance &&
Math.Abs(first.Offset.X - second.Offset.X) <= tolerance &&
Math.Abs(first.Offset.Y - second.Offset.Y) <= tolerance;
}
private WhiteboardNoteSnapshot BuildNoteSnapshot()
{
EnsureLogicalCanvasSize(expandToViewport: true);
return new WhiteboardNoteSnapshot
{
Version = 2,
CanvasWidth = Math.Max(1d, _logicalCanvasSize.Width),
CanvasHeight = Math.Max(1d, _logicalCanvasSize.Height),
BackgroundColor = ToHexColor((_isNightModeApplied ?? false) ? SKColors.Black : SKColors.White),
ViewportZoom = _viewportState.Zoom,
ViewportOffsetX = _viewportState.Offset.X,
ViewportOffsetY = _viewportState.Offset.Y,
Strokes = InkCanvas.Strokes
.Select(BuildStrokeSnapshot)
.Where(static stroke => stroke.Points.Count > 0)
.Where(static stroke => stroke.Points.Count > 0 || !string.IsNullOrWhiteSpace(stroke.PathSvgData))
.ToList()
};
}
@@ -702,6 +1212,7 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
Color = ToHexColor(stroke.Color),
InkThickness = stroke.InkThickness,
IgnorePressure = stroke.IgnorePressure,
PathSvgData = stroke.Path.ToSvgPathData(),
Points = pointList
.Select(static point => new WhiteboardStylusPointSnapshot
{
@@ -717,6 +1228,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
{
ApplySnapshotCanvasAndViewport(snapshot);
if (snapshot.Strokes.Count == 0)
{
return;
@@ -728,24 +1241,29 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
var stylusPoints = strokeSnapshot.Points
.Select(ConvertStylusPoint)
.ToList();
if (stylusPoints.Count == 0)
if (stylusPoints.Count == 0 && string.IsNullOrWhiteSpace(strokeSnapshot.PathSvgData))
{
continue;
}
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
var path = BuildRestoredStrokePath(stylusPoints, strokeSnapshot, renderer);
if (path.IsEmpty)
{
path.Dispose();
continue;
}
var staticStroke = SkiaStroke.CreateStaticStroke(
InkId.NewId(),
path,
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
ParseStrokeColor(strokeSnapshot.Color),
(float)strokeSnapshot.InkThickness,
strokeSnapshot.IgnorePressure,
true,
renderer);
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
}
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
InkCanvas.InvalidateVisual();
}
@@ -781,6 +1299,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
}
private static SKPath BuildRestoredStrokePath(
IReadOnlyList<InkStylusPoint> stylusPoints,
WhiteboardStrokeSnapshot strokeSnapshot,
DotNetCampus.Inking.StrokeRenderers.ISkiaInkStrokeRenderer? renderer)
{
if (stylusPoints.Count > 0)
{
return renderer?.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness) ??
WhiteboardStrokePathBuilder.BuildPath(stylusPoints, strokeSnapshot.InkThickness);
}
return !string.IsNullOrWhiteSpace(strokeSnapshot.PathSvgData)
? SKPath.ParseSvgPathData(strokeSnapshot.PathSvgData) ?? new SKPath()
: new SKPath();
}
private void ClearAllStrokes()
{
var strokeList = InkCanvas.Strokes.ToList();
@@ -799,8 +1333,6 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IC
}
}
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
InkCanvas.InvalidateVisual();
}