mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-26 20:24:26 +08:00
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:
@@ -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 单独构建 -->
|
||||
|
||||
<!-- 生成版本信息文件 -->
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
321
LanMountainDesktop/Services/WhiteboardSvgImportService.cs
Normal file
321
LanMountainDesktop/Services/WhiteboardSvgImportService.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
137
LanMountainDesktop/Views/Components/WhiteboardViewportHelper.cs
Normal file
137
LanMountainDesktop/Views/Components/WhiteboardViewportHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user