mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
831 lines
27 KiB
C#
831 lines
27 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Reflection;
|
|
using System.Threading.Tasks;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Primitives;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.Media;
|
|
using Avalonia.Platform.Storage;
|
|
using Avalonia.Styling;
|
|
using Avalonia.Threading;
|
|
using DotNetCampus.Inking;
|
|
using DotNetCampus.Inking.Primitive;
|
|
using FluentIcons.Avalonia;
|
|
using LanMountainDesktop.ComponentSystem;
|
|
using LanMountainDesktop.Models;
|
|
using LanMountainDesktop.Services;
|
|
using SkiaSharp;
|
|
|
|
namespace LanMountainDesktop.Views.Components;
|
|
|
|
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
|
|
{
|
|
private enum WhiteboardToolMode
|
|
{
|
|
Pen,
|
|
Eraser
|
|
}
|
|
|
|
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
|
|
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
|
|
private readonly int _baseWidthCells;
|
|
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
|
private readonly IWhiteboardNotePersistenceService _notePersistenceService = new WhiteboardNotePersistenceService();
|
|
private readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromMinutes(5) };
|
|
private double _currentCellSize = 48;
|
|
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
|
private bool? _isNightModeApplied;
|
|
private SKColor _selectedInkColor = SKColors.Black;
|
|
private float _selectedInkThickness = 2.5f;
|
|
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
|
private string _placementId = string.Empty;
|
|
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
|
private bool _isApplyingPersistedSnapshot;
|
|
private bool? _lastBitmapCacheEnabled;
|
|
private int _lastBitmapCacheSize;
|
|
private bool _noteDirty;
|
|
private int _noteLoadRevision;
|
|
private bool _disposed;
|
|
|
|
public WhiteboardWidget()
|
|
: this(baseWidthCells: 2)
|
|
{
|
|
}
|
|
|
|
public WhiteboardWidget(int baseWidthCells)
|
|
{
|
|
_baseWidthCells = Math.Max(1, baseWidthCells);
|
|
InitializeComponent();
|
|
AttachedToVisualTree += OnAttachedToVisualTree;
|
|
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
|
SizeChanged += OnSizeChanged;
|
|
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
|
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
|
|
|
|
ConfigureInkCanvas();
|
|
ApplyCellSize(_currentCellSize);
|
|
RefreshFromSettings();
|
|
ApplyThemeVisual(force: true);
|
|
InitializeColorPicker();
|
|
SetToolMode(WhiteboardToolMode.Pen);
|
|
}
|
|
|
|
private void InitializeColorPicker()
|
|
{
|
|
if (InkColorPicker is not null)
|
|
{
|
|
InkColorPicker.Color = new Color(
|
|
_selectedInkColor.Alpha,
|
|
_selectedInkColor.Red,
|
|
_selectedInkColor.Green,
|
|
_selectedInkColor.Blue);
|
|
}
|
|
|
|
if (InkThicknessSlider is not null)
|
|
{
|
|
InkThicknessSlider.Value = _selectedInkThickness;
|
|
}
|
|
}
|
|
|
|
public int NoteRetentionDays => _noteRetentionDays;
|
|
|
|
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
ApplyThemeVisual(force: true);
|
|
SchedulePersistedNoteLoad();
|
|
}
|
|
|
|
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
|
{
|
|
PersistNoteImmediately();
|
|
}
|
|
|
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
|
{
|
|
ApplyCellSize(_currentCellSize);
|
|
}
|
|
|
|
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
|
{
|
|
ApplyThemeVisual(force: false);
|
|
}
|
|
|
|
private void ConfigureInkCanvas()
|
|
{
|
|
InkCanvas.EditingMode = InkCanvasEditingMode.Ink;
|
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
|
settings.IgnorePressure = true;
|
|
settings.InkThickness = _selectedInkThickness;
|
|
settings.EraserSize = new Size(20, 20);
|
|
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
|
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
|
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
|
}
|
|
|
|
public void ApplyCellSize(double cellSize)
|
|
{
|
|
_currentCellSize = Math.Max(1, cellSize);
|
|
|
|
var availableWidth = Bounds.Width > 1 ? Bounds.Width : (_currentCellSize * _baseWidthCells);
|
|
var buttonSize = Math.Clamp(availableWidth * 0.15, 24, 40);
|
|
var buttonCornerRadius = buttonSize * 0.5;
|
|
var toolbarSpacing = Math.Clamp(buttonSize * 0.25, 4, 10);
|
|
var toolbarPaddingHorizontal = Math.Clamp(buttonSize * 0.36, 6, 12);
|
|
var toolbarPaddingVertical = Math.Clamp(buttonSize * 0.24, 4, 8);
|
|
|
|
RootBorder.Padding = new Thickness(ComponentChromeCornerRadiusHelper.SafeValue(_currentCellSize * 0.14, 6, 14));
|
|
var mainRectangleCornerRadius = ComponentChromeCornerRadiusHelper.ResolveMainRectangleRadius();
|
|
RootBorder.CornerRadius = mainRectangleCornerRadius;
|
|
CanvasBorder.CornerRadius = mainRectangleCornerRadius;
|
|
ToolbarBorder.CornerRadius = mainRectangleCornerRadius;
|
|
ToolbarBorder.Padding = new Thickness(
|
|
ComponentChromeCornerRadiusHelper.SafeValue(toolbarPaddingHorizontal, 6, 12),
|
|
ComponentChromeCornerRadiusHelper.SafeValue(toolbarPaddingVertical, 4, 8));
|
|
ToolbarButtonsPanel.Spacing = toolbarSpacing;
|
|
|
|
foreach (var button in new[] { PenButton, EraserButton, ClearButton, ExportButton })
|
|
{
|
|
button.Width = buttonSize;
|
|
button.Height = buttonSize;
|
|
button.CornerRadius = new CornerRadius(buttonCornerRadius);
|
|
}
|
|
|
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
|
var eraserSize = Math.Clamp(_currentCellSize * 0.42, 12, 44);
|
|
settings.EraserSize = new Size(eraserSize, eraserSize);
|
|
UpdateInkCanvasCacheSettings(forceRefresh: false);
|
|
}
|
|
|
|
private void ApplyThemeVisual(bool force)
|
|
{
|
|
var isNightMode = ResolveIsNightMode();
|
|
if (!force && _isNightModeApplied.HasValue && _isNightModeApplied.Value == isNightMode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isNightModeApplied = isNightMode;
|
|
|
|
RootBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF181B22") : Color.Parse("#FFF1F4F9"));
|
|
CanvasBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#FF000000") : Color.Parse("#FFFFFFFF"));
|
|
CanvasBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#30FFFFFF") : Color.Parse("#24000000"));
|
|
ToolbarBorder.Background = new SolidColorBrush(isNightMode ? Color.Parse("#1AFFFFFF") : Color.Parse("#E6FFFFFF"));
|
|
ToolbarBorder.BorderBrush = new SolidColorBrush(isNightMode ? Color.Parse("#26FFFFFF") : Color.Parse("#16000000"));
|
|
|
|
RefreshToolButtonVisuals();
|
|
}
|
|
|
|
public void SetComponentPlacementContext(string componentId, string? placementId)
|
|
{
|
|
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
|
|
? BuiltInComponentIds.DesktopWhiteboard
|
|
: componentId.Trim();
|
|
var nextPlacementId = placementId?.Trim() ?? string.Empty;
|
|
|
|
if (_noteDirty &&
|
|
HasValidPersistenceContext() &&
|
|
(string.Compare(_componentId, nextComponentId, StringComparison.OrdinalIgnoreCase) != 0 ||
|
|
string.Compare(_placementId, nextPlacementId, StringComparison.OrdinalIgnoreCase) != 0))
|
|
{
|
|
PersistNoteImmediately();
|
|
}
|
|
|
|
_componentId = nextComponentId;
|
|
_placementId = nextPlacementId;
|
|
RefreshFromSettings();
|
|
ClearAllStrokes();
|
|
SchedulePersistedNoteLoad();
|
|
}
|
|
|
|
public void RefreshFromSettings()
|
|
{
|
|
try
|
|
{
|
|
if (!HasValidPersistenceContext())
|
|
{
|
|
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
|
return;
|
|
}
|
|
|
|
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
|
_noteRetentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
|
|
_notePersistenceService.TryDeleteExpiredNote(_componentId, _placementId, _noteRetentionDays);
|
|
}
|
|
catch
|
|
{
|
|
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
|
}
|
|
}
|
|
|
|
public void ForceSaveNote()
|
|
{
|
|
if (_disposed || !HasValidPersistenceContext())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_noteDirty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_noteDirty = false;
|
|
_noteSaveTimer.Stop();
|
|
var noteSnapshot = BuildNoteSnapshot();
|
|
try
|
|
{
|
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_disposed = true;
|
|
_noteSaveTimer.Stop();
|
|
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
|
|
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
|
|
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
|
|
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
|
|
}
|
|
|
|
private void RecolorAllStrokes(SKColor targetColor)
|
|
{
|
|
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
|
|
{
|
|
TrySetStrokeColor(InkCanvas.Strokes[i], targetColor);
|
|
}
|
|
|
|
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
|
InkCanvas.InvalidateVisual();
|
|
}
|
|
|
|
private static void TrySetStrokeColor(SkiaStroke stroke, SKColor color)
|
|
{
|
|
if (StrokeColorProperty is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
StrokeColorProperty.SetValue(stroke, color);
|
|
}
|
|
catch
|
|
{
|
|
// Keep current stroke color when reflection is unavailable.
|
|
}
|
|
}
|
|
|
|
private bool ResolveIsNightMode()
|
|
{
|
|
if (ActualThemeVariant == ThemeVariant.Dark)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (ActualThemeVariant == ThemeVariant.Light)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
|
|
value is ISolidColorBrush brush)
|
|
{
|
|
return CalculateRelativeLuminance(brush.Color) < 0.45;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static int NormalizeRetentionDays(int days)
|
|
{
|
|
return WhiteboardNoteRetentionPolicy.NormalizeDays(
|
|
days <= 0
|
|
? WhiteboardNoteRetentionPolicy.DefaultDays
|
|
: days);
|
|
}
|
|
|
|
private static double CalculateRelativeLuminance(Color color)
|
|
{
|
|
static double ToLinear(double channel)
|
|
{
|
|
return channel <= 0.03928
|
|
? channel / 12.92
|
|
: Math.Pow((channel + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
var r = ToLinear(color.R / 255d);
|
|
var g = ToLinear(color.G / 255d);
|
|
var b = ToLinear(color.B / 255d);
|
|
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
|
}
|
|
|
|
private void SetToolMode(WhiteboardToolMode mode)
|
|
{
|
|
_toolMode = mode;
|
|
InkCanvas.EditingMode = mode == WhiteboardToolMode.Pen
|
|
? InkCanvasEditingMode.Ink
|
|
: InkCanvasEditingMode.EraseByPoint;
|
|
|
|
if (mode == WhiteboardToolMode.Pen)
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
|
}
|
|
|
|
RefreshToolButtonVisuals();
|
|
}
|
|
|
|
private void SetInkColor(SKColor color)
|
|
{
|
|
_selectedInkColor = color;
|
|
if (_toolMode == WhiteboardToolMode.Pen)
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkColor = _selectedInkColor;
|
|
}
|
|
RefreshToolButtonVisuals();
|
|
}
|
|
|
|
private void SetInkThickness(float thickness)
|
|
{
|
|
_selectedInkThickness = Math.Clamp(thickness, 1.0f, 8.0f);
|
|
if (_toolMode == WhiteboardToolMode.Pen)
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkThickness = _selectedInkThickness;
|
|
}
|
|
}
|
|
|
|
private void RefreshToolButtonVisuals()
|
|
{
|
|
var isNightMode = _isNightModeApplied ?? ResolveIsNightMode();
|
|
var activeBackground = ResolveThemeBrush("AdaptiveAccentBrush", isNightMode ? Color.Parse("#FF93C5FD") : Color.Parse("#FF3B82F6"));
|
|
var activeForeground = ResolveThemeBrush("AdaptiveOnAccentBrush", Colors.White);
|
|
var idleForeground = ResolveThemeBrush("AdaptiveTextPrimaryBrush", isNightMode ? Color.Parse("#FFE5E7EB") : Color.Parse("#FF0F172A"));
|
|
var idleBackground = new SolidColorBrush(isNightMode ? Color.Parse("#33FFFFFF") : Color.Parse("#14000000"));
|
|
|
|
ApplyToolButtonVisual(PenButton, _toolMode == WhiteboardToolMode.Pen, activeBackground, activeForeground, idleBackground, idleForeground);
|
|
ApplyToolButtonVisual(EraserButton, _toolMode == WhiteboardToolMode.Eraser, activeBackground, activeForeground, idleBackground, idleForeground);
|
|
ApplyToolButtonVisual(ClearButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
|
|
ApplyToolButtonVisual(ExportButton, false, activeBackground, activeForeground, idleBackground, idleForeground);
|
|
}
|
|
|
|
private static void ApplyToolButtonVisual(
|
|
Button button,
|
|
bool isActive,
|
|
IBrush activeBackground,
|
|
IBrush activeForeground,
|
|
IBrush idleBackground,
|
|
IBrush idleForeground)
|
|
{
|
|
button.Background = isActive ? activeBackground : idleBackground;
|
|
button.Foreground = isActive ? activeForeground : idleForeground;
|
|
button.BorderThickness = new Thickness(0);
|
|
|
|
if (button.Content is SymbolIcon symbolIcon)
|
|
{
|
|
symbolIcon.Foreground = button.Foreground;
|
|
}
|
|
}
|
|
|
|
private IBrush ResolveThemeBrush(string key, Color fallback)
|
|
{
|
|
if (this.TryFindResource(key, out var resource) && resource is IBrush brush)
|
|
{
|
|
return brush;
|
|
}
|
|
|
|
return new SolidColorBrush(fallback);
|
|
}
|
|
|
|
private void OnPenButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
if (_toolMode == WhiteboardToolMode.Pen && ColorPickerPopup is not null)
|
|
{
|
|
if (ColorPickerPopup.IsOpen)
|
|
{
|
|
ColorPickerPopup.Close();
|
|
}
|
|
else
|
|
{
|
|
ColorPickerPopup.Open();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
SetToolMode(WhiteboardToolMode.Pen);
|
|
}
|
|
}
|
|
|
|
private void OnColorPickerColorChanged(object? sender, ColorChangedEventArgs e)
|
|
{
|
|
var color = e.NewColor;
|
|
SetInkColor(new SKColor(color.R, color.G, color.B, color.A));
|
|
}
|
|
|
|
private void OnInkThicknessSliderValueChanged(object? sender, RangeBaseValueChangedEventArgs e)
|
|
{
|
|
SetInkThickness((float)e.NewValue);
|
|
}
|
|
|
|
private void OnEraserButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
SetToolMode(WhiteboardToolMode.Eraser);
|
|
}
|
|
|
|
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
ClearAllStrokes();
|
|
QueueNoteSave();
|
|
}
|
|
|
|
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
|
|
{
|
|
var fileName = $"whiteboard-{DateTime.Now:yyyyMMdd-HHmmss}.svg";
|
|
var topLevel = TopLevel.GetTopLevel(this);
|
|
var storageProvider = topLevel?.StorageProvider;
|
|
if (storageProvider is not null)
|
|
{
|
|
var saveFile = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
|
|
{
|
|
Title = "Export Whiteboard SVG",
|
|
SuggestedFileName = fileName,
|
|
DefaultExtension = "svg",
|
|
FileTypeChoices =
|
|
[
|
|
new FilePickerFileType("SVG image")
|
|
{
|
|
Patterns = ["*.svg"],
|
|
MimeTypes = ["image/svg+xml"]
|
|
}
|
|
]
|
|
});
|
|
|
|
if (saveFile is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await using var saveStream = await saveFile.OpenWriteAsync();
|
|
ExportSvgToStream(saveStream);
|
|
return;
|
|
}
|
|
|
|
var exportFolder = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"LanMountainDesktop",
|
|
"Exports");
|
|
Directory.CreateDirectory(exportFolder);
|
|
var savePath = Path.Combine(exportFolder, fileName);
|
|
await using var fileStream = File.Create(savePath);
|
|
ExportSvgToStream(fileStream);
|
|
}
|
|
|
|
private void ExportSvgToStream(Stream stream)
|
|
{
|
|
var width = Math.Max(1d, CanvasBorder.Bounds.Width);
|
|
var height = Math.Max(1d, CanvasBorder.Bounds.Height);
|
|
var bounds = SKRect.Create((float)width, (float)height);
|
|
|
|
using var svgCanvas = SKSvgCanvas.Create(bounds, stream);
|
|
using var backgroundPaint = new SKPaint
|
|
{
|
|
IsAntialias = true,
|
|
Style = SKPaintStyle.Fill,
|
|
Color = (_isNightModeApplied ?? false) ? SKColors.Black : SKColors.White
|
|
};
|
|
svgCanvas.DrawRect(bounds, backgroundPaint);
|
|
|
|
using var strokePaint = new SKPaint
|
|
{
|
|
IsAntialias = true,
|
|
Style = SKPaintStyle.Fill
|
|
};
|
|
foreach (var stroke in InkCanvas.Strokes)
|
|
{
|
|
strokePaint.Color = stroke.Color;
|
|
svgCanvas.DrawPath(stroke.Path, strokePaint);
|
|
}
|
|
|
|
svgCanvas.Flush();
|
|
}
|
|
|
|
private void OnInkCanvasStrokeCollected(object? sender, DotNetCampus.Inking.Contexts.AvaloniaSkiaInkCanvasStrokeCollectedEventArgs e)
|
|
{
|
|
_ = sender;
|
|
_ = e;
|
|
QueueNoteSave();
|
|
}
|
|
|
|
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
|
|
{
|
|
_ = sender;
|
|
_ = e;
|
|
QueueNoteSave();
|
|
}
|
|
|
|
private void OnInkCanvasPointerCaptureLost(object? sender, Avalonia.Input.PointerCaptureLostEventArgs e)
|
|
{
|
|
_ = sender;
|
|
_ = e;
|
|
QueueNoteSave();
|
|
}
|
|
|
|
private void OnNoteSaveTimerTick(object? sender, EventArgs e)
|
|
{
|
|
_ = sender;
|
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
|
{
|
|
_noteSaveTimer.Stop();
|
|
return;
|
|
}
|
|
|
|
if (!_noteDirty)
|
|
{
|
|
_noteSaveTimer.Stop();
|
|
return;
|
|
}
|
|
|
|
var noteSnapshot = BuildNoteSnapshot();
|
|
var componentId = _componentId;
|
|
var placementId = _placementId;
|
|
var retentionDays = _noteRetentionDays;
|
|
_noteDirty = false;
|
|
_noteSaveTimer.Stop();
|
|
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
|
|
}
|
|
|
|
private void QueueNoteSave()
|
|
{
|
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
|
{
|
|
return;
|
|
}
|
|
|
|
_noteDirty = true;
|
|
if (!_noteSaveTimer.IsEnabled)
|
|
{
|
|
_noteSaveTimer.Start();
|
|
}
|
|
}
|
|
|
|
private void PersistNoteImmediately()
|
|
{
|
|
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!_noteDirty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_noteDirty = false;
|
|
_noteSaveTimer.Stop();
|
|
var noteSnapshot = BuildNoteSnapshot();
|
|
try
|
|
{
|
|
_notePersistenceService.SaveNote(_componentId, _placementId, noteSnapshot, _noteRetentionDays);
|
|
}
|
|
catch
|
|
{
|
|
}
|
|
}
|
|
|
|
private async void SchedulePersistedNoteLoad()
|
|
{
|
|
if (!HasValidPersistenceContext())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var revision = ++_noteLoadRevision;
|
|
var componentId = _componentId;
|
|
var placementId = _placementId;
|
|
var retentionDays = _noteRetentionDays;
|
|
|
|
try
|
|
{
|
|
var noteSnapshot = await Task.Run(() => _notePersistenceService.LoadNote(componentId, placementId, retentionDays));
|
|
if (_disposed || revision != _noteLoadRevision ||
|
|
!string.Equals(_componentId, componentId, StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(_placementId, placementId, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return;
|
|
}
|
|
|
|
Dispatcher.UIThread.Post(() =>
|
|
{
|
|
if (_disposed || revision != _noteLoadRevision)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_isApplyingPersistedSnapshot = true;
|
|
try
|
|
{
|
|
ClearAllStrokes();
|
|
ApplyNoteSnapshot(noteSnapshot);
|
|
}
|
|
finally
|
|
{
|
|
_isApplyingPersistedSnapshot = false;
|
|
}
|
|
});
|
|
}
|
|
catch
|
|
{
|
|
// Best effort only. Whiteboard should stay usable if persistence is unavailable.
|
|
}
|
|
}
|
|
|
|
private WhiteboardNoteSnapshot BuildNoteSnapshot()
|
|
{
|
|
return new WhiteboardNoteSnapshot
|
|
{
|
|
Strokes = InkCanvas.Strokes
|
|
.Select(BuildStrokeSnapshot)
|
|
.Where(static stroke => stroke.Points.Count > 0)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
private static WhiteboardStrokeSnapshot BuildStrokeSnapshot(SkiaStroke stroke)
|
|
{
|
|
var pointList = TryGetStrokePoints(stroke);
|
|
return new WhiteboardStrokeSnapshot
|
|
{
|
|
Color = ToHexColor(stroke.Color),
|
|
InkThickness = stroke.InkThickness,
|
|
IgnorePressure = stroke.IgnorePressure,
|
|
Points = pointList
|
|
.Select(static point => new WhiteboardStylusPointSnapshot
|
|
{
|
|
X = point.X,
|
|
Y = point.Y,
|
|
Pressure = point.Pressure,
|
|
Width = point.Width ?? 0,
|
|
Height = point.Height ?? 0
|
|
})
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
|
|
{
|
|
if (snapshot.Strokes.Count == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var renderer = InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkStrokeRenderer;
|
|
foreach (var strokeSnapshot in snapshot.Strokes)
|
|
{
|
|
var stylusPoints = strokeSnapshot.Points
|
|
.Select(ConvertStylusPoint)
|
|
.ToList();
|
|
if (stylusPoints.Count == 0)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
|
|
var staticStroke = SkiaStroke.CreateStaticStroke(
|
|
InkId.NewId(),
|
|
path,
|
|
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
|
|
ParseStrokeColor(strokeSnapshot.Color),
|
|
(float)strokeSnapshot.InkThickness,
|
|
strokeSnapshot.IgnorePressure,
|
|
renderer);
|
|
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
|
}
|
|
|
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
|
}
|
|
|
|
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
|
{
|
|
return new InkStylusPoint(point.X, point.Y, (float)Math.Clamp(point.Pressure, 0f, 1f))
|
|
{
|
|
Width = point.Width > 0 ? point.Width : null,
|
|
Height = point.Height > 0 ? point.Height : null
|
|
};
|
|
}
|
|
|
|
private static SKColor ParseStrokeColor(string? value)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
try
|
|
{
|
|
var color = Color.Parse(value);
|
|
return new SKColor(color.R, color.G, color.B, color.A);
|
|
}
|
|
catch
|
|
{
|
|
// Fall through to the default color.
|
|
}
|
|
}
|
|
|
|
return SKColors.Black;
|
|
}
|
|
|
|
private static string ToHexColor(SKColor color)
|
|
{
|
|
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
|
|
}
|
|
|
|
private void ClearAllStrokes()
|
|
{
|
|
var strokeList = InkCanvas.Strokes.ToList();
|
|
foreach (var stroke in strokeList)
|
|
{
|
|
try
|
|
{
|
|
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Keep the widget alive even if one stroke removal fails.
|
|
}
|
|
}
|
|
|
|
UpdateInkCanvasCacheSettings(forceRefresh: true);
|
|
}
|
|
|
|
private bool HasValidPersistenceContext()
|
|
{
|
|
return !string.IsNullOrWhiteSpace(_componentId) &&
|
|
!string.IsNullOrWhiteSpace(_placementId);
|
|
}
|
|
|
|
private static IReadOnlyList<InkStylusPoint> TryGetStrokePoints(SkiaStroke stroke)
|
|
{
|
|
if (StrokePointListProperty?.GetValue(stroke) is IReadOnlyList<InkStylusPoint> pointList)
|
|
{
|
|
return pointList;
|
|
}
|
|
|
|
return Array.Empty<InkStylusPoint>();
|
|
}
|
|
|
|
private void UpdateInkCanvasCacheSettings(bool forceRefresh)
|
|
{
|
|
var renderScaling = TopLevel.GetTopLevel(this)?.RenderScaling ?? 1d;
|
|
var widthPx = Math.Max(1d, CanvasBorder.Bounds.Width * renderScaling);
|
|
var heightPx = Math.Max(1d, CanvasBorder.Bounds.Height * renderScaling);
|
|
var longestSide = Math.Max(widthPx, heightPx);
|
|
var area = widthPx * heightPx;
|
|
|
|
var cacheEnabled = longestSide <= 1536d && area <= 1_400_000d;
|
|
var cacheSize = (int)Math.Clamp(Math.Ceiling(longestSide), 384d, 1536d);
|
|
if (!forceRefresh &&
|
|
_lastBitmapCacheEnabled == cacheEnabled &&
|
|
_lastBitmapCacheSize == cacheSize)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_lastBitmapCacheEnabled = cacheEnabled;
|
|
_lastBitmapCacheSize = cacheSize;
|
|
|
|
var settings = InkCanvas.AvaloniaSkiaInkCanvas.Settings;
|
|
settings.IsBitmapCacheEnabled = cacheEnabled;
|
|
settings.MaxBitmapCacheSize = cacheSize;
|
|
|
|
try
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(cacheEnabled);
|
|
if (cacheEnabled)
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
|
}
|
|
else
|
|
{
|
|
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
|
InkCanvas.InvalidateVisual();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Keep drawing available even if the underlying cache backend rejects the cache update.
|
|
}
|
|
}
|
|
}
|