From 2325aac2371190dcaf886469ad3b54d094c0b412 Mon Sep 17 00:00:00 2001 From: Shihao Shen Date: Sat, 23 May 2026 15:03:03 +0800 Subject: [PATCH 1/6] feat: Implement PowerPoint slideshow navigation and stroke management features Co-authored-by: ShihaoShen-Bot --- Ink-Canvas-Next/Ink-Canvas-Next.csproj | 3 +- Ink-Canvas-Next/Services/PowerPointService.cs | 471 ++++++++++++++++++ .../Services/SlideAnnotationManager.cs | 112 +++++ .../ViewModels/MainWindowViewModel.cs | 110 +++- Ink-Canvas-Next/Views/MainWindow.axaml | 12 + Ink-Canvas-Next/Views/MainWindow.axaml.cs | 143 ++++++ 6 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 Ink-Canvas-Next/Services/PowerPointService.cs create mode 100644 Ink-Canvas-Next/Services/SlideAnnotationManager.cs diff --git a/Ink-Canvas-Next/Ink-Canvas-Next.csproj b/Ink-Canvas-Next/Ink-Canvas-Next.csproj index 0927981..a62c9eb 100644 --- a/Ink-Canvas-Next/Ink-Canvas-Next.csproj +++ b/Ink-Canvas-Next/Ink-Canvas-Next.csproj @@ -1,7 +1,7 @@  WinExe - net11.0 + net11.0-windows enable app.manifest true @@ -25,5 +25,6 @@ + diff --git a/Ink-Canvas-Next/Services/PowerPointService.cs b/Ink-Canvas-Next/Services/PowerPointService.cs new file mode 100644 index 0000000..5657322 --- /dev/null +++ b/Ink-Canvas-Next/Services/PowerPointService.cs @@ -0,0 +1,471 @@ +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Timers; +using Microsoft.Office.Interop.PowerPoint; + +namespace Ink_Canvas_Next.Services; + +/// +/// Monitors a running PowerPoint slideshow and provides navigation control. +/// Operates in COM Interop mode (primary) or Win32 fallback mode when COM is unavailable. +/// Events are raised on a background thread – callers are responsible for dispatching to the UI thread. +/// +public sealed class PowerPointService : IDisposable +{ + // ----------------------------------------------------------------------- + // Win32 P/Invoke + // ----------------------------------------------------------------------- + + private delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + private static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); + + private const byte VK_LEFT = 0x25; + private const byte VK_RIGHT = 0x27; + private const uint KEYEVENTF_KEYDOWN = 0x0000; + private const uint KEYEVENTF_KEYUP = 0x0002; + + [DllImport("ole32.dll")] + private static extern int CLSIDFromProgID([MarshalAs(UnmanagedType.LPWStr)] string lpszProgID, out Guid lpclsid); + + [DllImport("oleaut32.dll")] + private static extern int GetActiveObject(ref Guid rclsid, IntPtr pvReserved, [MarshalAs(UnmanagedType.IUnknown)] out object ppunk); + + // ----------------------------------------------------------------------- + // Fields + // ----------------------------------------------------------------------- + + private readonly object _lock = new(); + private System.Timers.Timer? _timer; + + // COM objects – must be released via Marshal.ReleaseComObject + private Application? _pptApp; + + // Cached state + private bool _isSlideShowActive; + private int _currentSlide; + private int _totalSlides; + private bool _isComMode; + private bool _disposed; + + // Win32 fallback window handle cache + private IntPtr _win32WindowHandle = IntPtr.Zero; + + // ----------------------------------------------------------------------- + // Events + // ----------------------------------------------------------------------- + + /// Raised when the current slide index changes. Argument is the new 1-based slide index. + public event Action? SlideChanged; + + /// Raised when a slideshow becomes active. + public event Action? SlideShowStarted; + + /// Raised when the active slideshow ends or PowerPoint closes. + public event Action? SlideShowEnded; + + // ----------------------------------------------------------------------- + // Public properties + // ----------------------------------------------------------------------- + + /// Gets whether a slideshow is currently active. + public bool IsSlideShowActive + { + get { lock (_lock) { return _isSlideShowActive; } } + } + + /// Gets the current 1-based slide index, or 0 if unknown. + public int CurrentSlide + { + get { lock (_lock) { return _currentSlide; } } + } + + /// Gets the total number of slides in the active presentation, or 0 if unknown. + public int TotalSlides + { + get { lock (_lock) { return _totalSlides; } } + } + + /// Gets whether the service is operating via COM Interop (true) or Win32 fallback (false). + public bool IsComMode + { + get { lock (_lock) { return _isComMode; } } + } + + // ----------------------------------------------------------------------- + // Monitoring lifecycle + // ----------------------------------------------------------------------- + + /// + /// Starts a ~1-second polling loop that detects slideshow state changes and raises events accordingly. + /// Safe to call multiple times – subsequent calls are no-ops if monitoring is already running. + /// + public void StartMonitoring() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + lock (_lock) + { + if (_timer is not null) + return; + + _timer = new System.Timers.Timer(1000) { AutoReset = true }; + _timer.Elapsed += OnTimerElapsed; + _timer.Start(); + } + } + + /// Stops the polling loop. Does not release COM objects. + public void StopMonitoring() + { + System.Timers.Timer? t; + lock (_lock) + { + t = _timer; + _timer = null; + } + + if (t is not null) + { + t.Stop(); + t.Elapsed -= OnTimerElapsed; + t.Dispose(); + } + } + + // ----------------------------------------------------------------------- + // Navigation + // ----------------------------------------------------------------------- + + /// Advances to the next slide or animation step. + public void NextSlide() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + bool comOk = false; + lock (_lock) { comOk = _isComMode; } + + if (comOk) + { + TryComNavigate(next: true); + } + else + { + TryWin32Navigate(next: true); + } + } + + /// Goes back to the previous slide or animation step. + public void PreviousSlide() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + bool comOk = false; + lock (_lock) { comOk = _isComMode; } + + if (comOk) + { + TryComNavigate(next: false); + } + else + { + TryWin32Navigate(next: false); + } + } + + // ----------------------------------------------------------------------- + // Polling implementation + // ----------------------------------------------------------------------- + + private void OnTimerElapsed(object? sender, ElapsedEventArgs e) + { + try + { + PollState(); + } + catch + { + // Never let an unhandled exception kill the timer thread. + } + } + + /// + /// Core polling routine. Attempts COM first; falls back to Win32 if COM is unavailable. + /// Compares gathered state with cached state and fires events as needed. + /// + private void PollState() + { + bool newActive = false; + int newCurrent = 0; + int newTotal = 0; + bool newIsComMode = false; + + // --- Try COM --- + if (TryGetComState(out newActive, out newCurrent, out newTotal)) + { + newIsComMode = true; + } + else + { + // --- Fallback to Win32 --- + ReleaseComApp(); + newIsComMode = false; + newActive = TryGetWin32State(); + newCurrent = 0; + newTotal = 0; + } + + // --- Compare with previous state and raise events --- + bool prevActive; + int prevCurrent; + bool prevIsComMode; + + lock (_lock) + { + prevActive = _isSlideShowActive; + prevCurrent = _currentSlide; + prevIsComMode = _isComMode; + + _isSlideShowActive = newActive; + _currentSlide = newCurrent; + _totalSlides = newTotal; + _isComMode = newIsComMode; + } + + if (newActive && !prevActive) + { + SlideShowStarted?.Invoke(); + } + else if (!newActive && prevActive) + { + SlideShowEnded?.Invoke(); + } + + if (newActive && newIsComMode && newCurrent != 0 && newCurrent != prevCurrent) + { + SlideChanged?.Invoke(newCurrent); + } + } + + // ----------------------------------------------------------------------- + // COM helpers + // ----------------------------------------------------------------------- + + /// + /// Attempts to acquire (or reuse) a COM reference to the running PowerPoint application + /// and reads slideshow state. + /// + /// True if COM is available and state was successfully read. + private bool TryGetComState(out bool isActive, out int current, out int total) + { + isActive = false; + current = 0; + total = 0; + + try + { + // Acquire COM application object (reuse existing reference when possible) + if (_pptApp is null) + { + int hr = CLSIDFromProgID("PowerPoint.Application", out Guid clsid); + if (hr != 0) Marshal.ThrowExceptionForHR(hr); + hr = GetActiveObject(ref clsid, IntPtr.Zero, out object comObj); + if (hr != 0) Marshal.ThrowExceptionForHR(hr); + _pptApp = (Application)comObj; + } + + var slideShowWindows = _pptApp.SlideShowWindows; + int count = slideShowWindows.Count; + + if (count <= 0) + { + Marshal.ReleaseComObject(slideShowWindows); + return true; // COM is working but no slideshow is active + } + + isActive = true; + + var ssWin = slideShowWindows[1]; + var view = ssWin.View; + var presentation = ssWin.Presentation; + var slides = presentation.Slides; + + current = view.Slide.SlideIndex; + total = slides.Count; + + // Release intermediate COM objects + Marshal.ReleaseComObject(slides); + Marshal.ReleaseComObject(presentation); + Marshal.ReleaseComObject(view); + Marshal.ReleaseComObject(ssWin); + Marshal.ReleaseComObject(slideShowWindows); + + return true; + } + catch (COMException) + { + // PowerPoint not running or COM call failed + ReleaseComApp(); + return false; + } + catch (InvalidCastException) + { + ReleaseComApp(); + return false; + } + catch (Exception) + { + ReleaseComApp(); + return false; + } + } + + /// Navigates forward or backward using the COM slideshow view. + private void TryComNavigate(bool next) + { + try + { + if (_pptApp is null) + return; + + var slideShowWindows = _pptApp.SlideShowWindows; + if (slideShowWindows.Count <= 0) + { + Marshal.ReleaseComObject(slideShowWindows); + return; + } + + var ssWin = slideShowWindows[1]; + var view = ssWin.View; + + if (next) + view.Next(); + else + view.Previous(); + + Marshal.ReleaseComObject(view); + Marshal.ReleaseComObject(ssWin); + Marshal.ReleaseComObject(slideShowWindows); + } + catch (COMException) + { + ReleaseComApp(); + } + catch (Exception) + { + ReleaseComApp(); + } + } + + /// Releases and nullifies the cached COM application object. + private void ReleaseComApp() + { + var app = _pptApp; + _pptApp = null; + + if (app is not null) + { + try { Marshal.ReleaseComObject(app); } + catch { /* ignore */ } + } + } + + // ----------------------------------------------------------------------- + // Win32 fallback helpers + // ----------------------------------------------------------------------- + + /// + /// Scans all top-level windows for a PowerPoint slideshow window belonging to the POWERPNT process. + /// + /// True if a slideshow window is found. + private bool TryGetWin32State() + { + IntPtr found = IntPtr.Zero; + + EnumWindows((hWnd, _) => + { + var sb = new System.Text.StringBuilder(512); + GetWindowText(hWnd, sb, sb.Capacity); + string title = sb.ToString(); + + if (!title.Contains("Slide Show", StringComparison.OrdinalIgnoreCase) && + !title.Contains("幻灯片放映", StringComparison.Ordinal)) + { + return true; // continue enumeration + } + + // Verify process name + GetWindowThreadProcessId(hWnd, out uint pid); + try + { + using var proc = Process.GetProcessById((int)pid); + if (string.Equals(proc.ProcessName, "POWERPNT", StringComparison.OrdinalIgnoreCase)) + { + found = hWnd; + return false; // stop enumeration + } + } + catch + { + // Process may have exited + } + + return true; + }, IntPtr.Zero); + + _win32WindowHandle = found; + return found != IntPtr.Zero; + } + + /// + /// Sends a left or right arrow key to the cached PowerPoint slideshow window via keybd_event. + /// + private void TryWin32Navigate(bool next) + { + // Refresh window handle if needed + if (_win32WindowHandle == IntPtr.Zero) + TryGetWin32State(); + + if (_win32WindowHandle == IntPtr.Zero) + return; + + SetForegroundWindow(_win32WindowHandle); + + // Small delay to ensure the window is focused before sending the key + System.Threading.Thread.Sleep(50); + + byte vk = next ? VK_RIGHT : VK_LEFT; + keybd_event(vk, 0, KEYEVENTF_KEYDOWN, UIntPtr.Zero); + keybd_event(vk, 0, KEYEVENTF_KEYUP, UIntPtr.Zero); + } + + // ----------------------------------------------------------------------- + // IDisposable + // ----------------------------------------------------------------------- + + /// Stops monitoring, releases COM objects, and suppresses finalization. + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + StopMonitoring(); + ReleaseComApp(); + GC.SuppressFinalize(this); + } +} diff --git a/Ink-Canvas-Next/Services/SlideAnnotationManager.cs b/Ink-Canvas-Next/Services/SlideAnnotationManager.cs new file mode 100644 index 0000000..45f8cb6 --- /dev/null +++ b/Ink-Canvas-Next/Services/SlideAnnotationManager.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using SkiaSharp; + +namespace Ink_Canvas_Next.Services; + +/// +/// Represents the data of a single ink stroke on a slide. +/// +public class StrokeData +{ + /// + /// The path points that make up the stroke. + /// + public List Points { get; set; } = []; + + /// + /// The color of the stroke. + /// + public SKColor Color { get; set; } + + /// + /// The thickness (stroke width) of the stroke. + /// + public float Thickness { get; set; } +} + +/// +/// Manages per-slide ink stroke data in memory. +/// Each slide is identified by its index and can store a list of . +/// +public class SlideAnnotationManager +{ + private readonly Dictionary> _annotations = []; + + /// + /// Saves the strokes for the specified slide, overwriting any existing annotations. + /// A defensive copy of the stroke list is stored so that caller-side mutations + /// do not affect the internal state. + /// + /// Zero-based index of the slide. + /// The list of strokes to save. + public void SavePage(int slideIndex, List strokes) + { + _annotations[slideIndex] = CopyStrokes(strokes); + } + + /// + /// Loads the strokes for the specified slide. + /// Returns an empty list if the slide has no annotations. + /// The returned list is a defensive copy so that caller-side mutations + /// do not affect the internal state. + /// + /// Zero-based index of the slide. + /// A list of strokes for the slide, or an empty list if none exist. + public List LoadPage(int slideIndex) + { + return _annotations.TryGetValue(slideIndex, out var strokes) + ? CopyStrokes(strokes) + : []; + } + + /// + /// Determines whether the specified slide has any annotations. + /// + /// Zero-based index of the slide. + /// true if the slide has at least one stroke; otherwise, false. + public bool HasAnnotations(int slideIndex) + { + return _annotations.TryGetValue(slideIndex, out var strokes) && strokes.Count > 0; + } + + /// + /// Clears all annotations for the specified slide. + /// + /// Zero-based index of the slide. + public void ClearPage(int slideIndex) + { + _annotations.Remove(slideIndex); + } + + /// + /// Clears all annotations for every slide. + /// + public void ClearAll() + { + _annotations.Clear(); + } + + /// + /// Gets the number of pages that have at least one annotation stroke. + /// + public int AnnotatedPageCount => _annotations.Count; + + /// + /// Creates a deep copy of a stroke list so the internal storage + /// is never shared with the caller. + /// + private static List CopyStrokes(List source) + { + var copy = new List(source.Count); + foreach (var stroke in source) + { + copy.Add(new StrokeData + { + Points = new List(stroke.Points), + Color = stroke.Color, + Thickness = stroke.Thickness + }); + } + return copy; + } +} diff --git a/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs b/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs index 1d07392..07c6a4e 100644 --- a/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs +++ b/Ink-Canvas-Next/ViewModels/MainWindowViewModel.cs @@ -1,9 +1,12 @@ -using Avalonia.Media; +using Avalonia.Media; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using System.Collections.Generic; using System.Collections.ObjectModel; using System; using Ink_Canvas_Next.Models; +using Ink_Canvas_Next.Services; namespace Ink_Canvas_Next.ViewModels { @@ -33,6 +36,8 @@ public partial class MainWindowViewModel : ViewModelBase [NotifyPropertyChangedFor(nameof(ClearCanvasToolTip))] [NotifyPropertyChangedFor(nameof(SaveToolTip))] [NotifyPropertyChangedFor(nameof(ExitToolTip))] + [NotifyPropertyChangedFor(nameof(PreviousSlideToolTip))] + [NotifyPropertyChangedFor(nameof(NextSlideToolTip))] private UiLanguage _currentLanguage = UiLanguage.ZhCn; [ObservableProperty] @@ -67,6 +72,27 @@ public partial class MainWindowViewModel : ViewModelBase public Action? ClearCanvasAction { get; set; } public Action? SaveCanvasAction { get; set; } + // View-layer action callbacks for slide annotation + public Func>? GetStrokesAction { get; set; } + public Action>? RestoreStrokesAction { get; set; } + + // PowerPoint slideshow state + [ObservableProperty] + private bool _isSlideShowActive; + + [ObservableProperty] + private int _currentSlideNumber; + + [ObservableProperty] + private int _totalSlides; + + [ObservableProperty] + private string _slideInfo = ""; + + // PowerPoint services + private readonly PowerPointService _powerPointService = new(); + private readonly SlideAnnotationManager _annotationManager = new(); + partial void OnPenColorChanged(Color value) { // Skip tracking if we're restoring pen state or applying adaptive color @@ -148,6 +174,8 @@ public double GetEffectiveThickness() public string ClearCanvasToolTip => CurrentLanguage == UiLanguage.EnUs ? "Clear Canvas" : "清空画布"; public string SaveToolTip => CurrentLanguage == UiLanguage.EnUs ? "Save" : "保存"; public string ExitToolTip => CurrentLanguage == UiLanguage.EnUs ? "Exit" : "退出"; + public string PreviousSlideToolTip => CurrentLanguage == UiLanguage.EnUs ? "Previous Slide" : "上一页"; + public string NextSlideToolTip => CurrentLanguage == UiLanguage.EnUs ? "Next Slide" : "下一页"; public string CurrentLanguageDisplay => CurrentLanguage == UiLanguage.EnUs ? "English" : "中文"; @@ -219,6 +247,86 @@ private void Save() SaveCanvasAction?.Invoke(); } + public MainWindowViewModel() + { + _powerPointService.SlideShowStarted += OnSlideShowStarted; + _powerPointService.SlideShowEnded += OnSlideShowEnded; + _powerPointService.SlideChanged += OnSlideChanged; + _powerPointService.StartMonitoring(); + } + + private void OnSlideShowStarted() + { + Dispatcher.UIThread.Post(() => + { + IsSlideShowActive = true; + CurrentSlideNumber = _powerPointService.CurrentSlide; + TotalSlides = _powerPointService.TotalSlides; + UpdateSlideInfo(); + }); + } + + private void OnSlideShowEnded() + { + Dispatcher.UIThread.Post(() => + { + IsSlideShowActive = false; + CurrentSlideNumber = 0; + TotalSlides = 0; + SlideInfo = ""; + _annotationManager.ClearAll(); + ClearCanvasAction?.Invoke(); + }); + } + + private void OnSlideChanged(int newSlideIndex) + { + Dispatcher.UIThread.Post(() => + { + // Save current page strokes + if (CurrentSlideNumber > 0) + { + var currentStrokes = GetStrokesAction?.Invoke() ?? new List(); + _annotationManager.SavePage(CurrentSlideNumber, currentStrokes); + } + + // Update slide number + CurrentSlideNumber = newSlideIndex; + TotalSlides = _powerPointService.TotalSlides; + UpdateSlideInfo(); + + // Clear canvas and restore target page strokes + ClearCanvasAction?.Invoke(); + var savedStrokes = _annotationManager.LoadPage(newSlideIndex); + if (savedStrokes.Count > 0) + { + RestoreStrokesAction?.Invoke(savedStrokes); + } + }); + } + + private void UpdateSlideInfo() + { + if (TotalSlides > 0) + SlideInfo = $"{CurrentSlideNumber} / {TotalSlides}"; + else if (CurrentSlideNumber > 0) + SlideInfo = $"{CurrentSlideNumber} / ?"; + else + SlideInfo = ""; + } + + [RelayCommand] + private void NextSlide() + { + _powerPointService.NextSlide(); + } + + [RelayCommand] + private void PreviousSlide() + { + _powerPointService.PreviousSlide(); + } + [RelayCommand] private void ChangeLanguage(UiLanguage language) { diff --git a/Ink-Canvas-Next/Views/MainWindow.axaml b/Ink-Canvas-Next/Views/MainWindow.axaml index f617eb1..498a8a1 100644 --- a/Ink-Canvas-Next/Views/MainWindow.axaml +++ b/Ink-Canvas-Next/Views/MainWindow.axaml @@ -91,6 +91,18 @@ + + + + + + + + diff --git a/Ink-Canvas-Next/Views/MainWindow.axaml.cs b/Ink-Canvas-Next/Views/MainWindow.axaml.cs index 53d9879..2b7d9c7 100644 --- a/Ink-Canvas-Next/Views/MainWindow.axaml.cs +++ b/Ink-Canvas-Next/Views/MainWindow.axaml.cs @@ -1,9 +1,12 @@ using Avalonia.Controls; using Avalonia.Interactivity; using Ink_Canvas_Next.ViewModels; +using Ink_Canvas_Next.Services; using System; +using System.Collections.Generic; using System.ComponentModel; using System.IO; +using System.Linq; using System.Threading.Tasks; using Avalonia.Threading; using DotNetCampus.Inking; @@ -35,6 +38,8 @@ protected override void OnDataContextChanged(EventArgs e) vm.PropertyChanged += ViewModel_PropertyChanged; vm.ClearCanvasAction = ClearCanvas; vm.SaveCanvasAction = SaveCanvas; + vm.GetStrokesAction = GetCurrentStrokes; + vm.RestoreStrokesAction = RestoreStrokes; // Initialize UpdatePen(vm); @@ -255,6 +260,144 @@ private async Task ShowErrorDialog(string message) await dialog.ShowDialog(this); } + private List GetCurrentStrokes() + { + var result = new List(); + try + { + var skiaCanvas = MyInkCanvas.AvaloniaSkiaInkCanvas; + var strokes = skiaCanvas.StaticStrokeList; + if (strokes == null) return result; + + foreach (var stroke in strokes) + { + var data = new StrokeData + { + Color = stroke.Color, + Thickness = stroke.InkThickness + }; + + // Extract path points from SKPath + if (stroke.Path is SKPath path) + { + var points = new List(); + using var iterator = path.CreateIterator(false); + var pathPoints = new SKPoint[4]; + SKPathVerb verb; + while ((verb = iterator.Next(pathPoints)) != SKPathVerb.Done) + { + switch (verb) + { + case SKPathVerb.Move: + points.Add(pathPoints[0]); + break; + case SKPathVerb.Line: + points.Add(pathPoints[1]); + break; + case SKPathVerb.Quad: + points.Add(pathPoints[1]); + points.Add(pathPoints[2]); + break; + case SKPathVerb.Cubic: + points.Add(pathPoints[1]); + points.Add(pathPoints[2]); + points.Add(pathPoints[3]); + break; + } + } + data.Points = points; + } + + if (data.Points.Count > 0) + result.Add(data); + } + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"GetCurrentStrokes error: {ex.Message}"); + } + return result; + } + + private void RestoreStrokes(List strokes) + { + try + { + var skiaCanvas = MyInkCanvas.AvaloniaSkiaInkCanvas; + var skiaStrokeType = typeof(DotNetCampus.Inking.SkiaStroke); + var inkIdType = typeof(DotNetCampus.Inking.InkId); + + // Find the constructor: SkiaStroke(InkId id, SKPath path, bool ownSkiaPath) + var strokeCtor = skiaStrokeType.GetConstructors( + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic) + .FirstOrDefault(c => c.GetParameters().Length == 3); + + // Find InkId(int) constructor + var inkIdCtor = inkIdType.GetConstructors( + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic) + .FirstOrDefault(c => c.GetParameters().Length == 1); + + // Get Color and InkThickness property setters + var colorProp = skiaStrokeType.GetProperty("Color", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + var thicknessProp = skiaStrokeType.GetProperty("InkThickness", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + + // Get AddStaticStroke method + var addMethod = skiaCanvas.GetType().GetMethod("AddStaticStroke", + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.NonPublic); + + if (strokeCtor == null || inkIdCtor == null || addMethod == null) + { + System.Diagnostics.Debug.WriteLine("RestoreStrokes: could not find required constructors/methods via reflection"); + return; + } + + int idCounter = 100000; // Use a high base to avoid conflicts with live strokes + foreach (var strokeData in strokes) + { + if (strokeData.Points.Count < 2) continue; + + // Build SKPath from saved points + var path = new SKPath(); + path.MoveTo(strokeData.Points[0]); + for (int i = 1; i < strokeData.Points.Count; i++) + path.LineTo(strokeData.Points[i]); + + // Create InkId + var inkId = inkIdCtor.Invoke(new object[] { idCounter++ }); + + // Create SkiaStroke(id, path, ownSkiaPath=true) + var skiaStroke = strokeCtor.Invoke(new object[] { inkId, path, true }); + + // Set color and thickness + colorProp?.SetValue(skiaStroke, strokeData.Color); + thicknessProp?.SetValue(skiaStroke, strokeData.Thickness); + + // Add to canvas + addMethod.Invoke(skiaCanvas, new object[] { skiaStroke }); + } + + MyInkCanvas.InvalidateVisual(); + System.Diagnostics.Debug.WriteLine($"RestoreStrokes: restored {strokes.Count} strokes"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"RestoreStrokes error: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Stack trace: {ex.StackTrace}"); + } + } + private void CloseApp_Click(object? sender, RoutedEventArgs e) { Close(); From 134d592a7358a47206e3b540a85e273283340671 Mon Sep 17 00:00:00 2001 From: "qoderai[bot]" <215938697+qoderai[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:52:57 +0000 Subject: [PATCH 2/6] Add .github/workflows/qoder-auto-review.yml --- .github/workflows/qoder-auto-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qoder-auto-review.yml b/.github/workflows/qoder-auto-review.yml index 6b33afe..f87ff72 100644 --- a/.github/workflows/qoder-auto-review.yml +++ b/.github/workflows/qoder-auto-review.yml @@ -25,4 +25,4 @@ jobs: prompt: | /review-pr REPO:${{ github.repository }} PR_NUMBER:${{ github.event.pull_request.number }} - OUTPUT_LANGUAGE:Chinese \ No newline at end of file + OUTPUT_LANGUAGE:Chinese From f563cab84561c0eadd63b54d79ee6e38adff4b71 Mon Sep 17 00:00:00 2001 From: "qoderai[bot]" <215938697+qoderai[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:52:58 +0000 Subject: [PATCH 3/6] Add .github/workflows/qoder-assistant.yml --- .github/workflows/qoder-assistant.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/qoder-assistant.yml b/.github/workflows/qoder-assistant.yml index 11d86b0..2f7eb79 100644 --- a/.github/workflows/qoder-assistant.yml +++ b/.github/workflows/qoder-assistant.yml @@ -9,7 +9,7 @@ on: jobs: qoder-assistant: if: | - contains(github.event.comment.body, '@qoder') && + contains(github.event.comment.body, '@qoder') && !endsWith(github.event.comment.user.login, '[bot]') runs-on: ubuntu-latest permissions: @@ -36,17 +36,17 @@ jobs: URL: ${{ github.event.comment.html_url }} IS_PR: ${{ github.event.issue.pull_request != null || github.event_name == 'pull_request_review_comment' }} ISSUE_OR_PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}" - + if [ -n "${{ github.event.comment.pull_request_review_id }}" ]; then ARGS="$ARGS REVIEW_ID: ${{ github.event.comment.pull_request_review_id }}" fi - + if [ -n "${{ github.event.comment.in_reply_to_id }}" ]; then ARGS="$ARGS REPLY_TO_COMMENT_ID: ${{ github.event.comment.in_reply_to_id }}" fi - + echo "args<> $GITHUB_OUTPUT echo "$ARGS" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT @@ -58,4 +58,4 @@ jobs: prompt: | /assistant ${{ steps.build_args.outputs.args }} - OUTPUT_LANGUAGE:Chinese \ No newline at end of file + OUTPUT_LANGUAGE:Chinese From 5a3494212c67d3fc8def59b99d19aa76d0d5f877 Mon Sep 17 00:00:00 2001 From: "qoderai[bot]" <215938697+qoderai[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:59:04 +0000 Subject: [PATCH 4/6] Add .github/workflows/qoder-auto-review.yml From 8bde8f1e30b008f1d5ea5f8df7e2ef60635e0f2b Mon Sep 17 00:00:00 2001 From: "qoderai[bot]" <215938697+qoderai[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 11:59:05 +0000 Subject: [PATCH 5/6] Add .github/workflows/qoder-assistant.yml From 9e868d9d8ec1a6e5dac67a2e5c92c8c78ab40bd1 Mon Sep 17 00:00:00 2001 From: QoderAI <215938697+qoderai[bot]@users.noreply.github.com> Date: Sat, 23 May 2026 12:05:15 +0000 Subject: [PATCH 6/6] docs: Update README with full feature list, tech stack, and project structure --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 74 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index a065ddd..4dedfbc 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,104 @@ # Ink Canvas Next -一款基于 Avalonia 和 .NET 构建的创新桌面绘图应用,采用 Windows 11 Fluent Design 设计语言。 +一款基于 Avalonia 和 .NET 构建的创新桌面绘图应用,采用 Windows 11 Fluent Design 设计语言,深度集成 PowerPoint 幻灯片批注。 ## 功能特性 -- **绘图工具**:画笔荧光笔和橡皮擦 +- **三种绘图工具**:画笔(Pen)、荧光笔(Highlighter,10x 厚度 + 半透明效果)、橡皮擦(Eraser) - **调色板**:8 种预设颜色(黑色、红色、蓝色、绿色、橙色、紫色、黄色、白色) -- **画笔大小可调**:1-20 像素 -- **背景模式**:透明、白板、黑板 +- **画笔大小可调**:1–20 像素滑块调节 +- **自适应颜色**:根据背景模式自动切换笔迹颜色(白底黑字、黑底白字、透明底红色) +- **背景模式**:透明(Transparent)、白板(Whiteboard)、黑板(Blackboard) +- **PowerPoint 幻灯片集成**: + - 自动检测正在运行的幻灯片放映 + - 上一页 / 下一页导航 + - 幻灯片编号显示 + - 基于 COM Interop 和 Win32 窗口枚举双模式检测 +- **幻灯片笔迹管理**:每页幻灯片独立记录和恢复笔迹,翻页自动保存 +- **保存画布**:导出为 PNG 到 `Pictures/InkCanvasScreenshots/` +- **清除画布**:一键清除所有笔迹 +- **双语界面**:简体中文 / English 切换 - **现代化界面**:Windows 11 Fluent Design 无边框全屏窗口 ## 技术栈 -- **框架**:Avalonia 11.3.11 -- **语言**:C# (.NET 10.0) -- **架构模式**:MVVM (CommunityToolkit.Mvvm) -- **主题**:FluentAvaloniaUI 2.5.0 -- **绘图**:DotNetCampus.AvaloniaInkCanvas + SkiaSharp - -## 编译 - -```bash -cd Ink-Canvas-Next -dotnet build -``` - -## 运行 - -```bash -dotnet run -``` +| 组件 | 技术 | +|------|------| +| **框架** | Avalonia 11.3.11 | +| **语言** | C# (.NET 10.0, 目标平台 `net11.0-windows`) | +| **架构模式** | MVVM (CommunityToolkit.Mvvm 8.2.1) | +| **主题** | FluentAvaloniaUI 2.5.0 | +| **绘图引擎** | DotNetCampus.AvaloniaInkCanvas 1.0.1 + SkiaSharp | +| **Office 集成** | Microsoft.Office.Interop.PowerPoint 15.0.4420.1018 | ## 项目结构 ``` Ink-Canvas-Next/ +├── Ink Canvas Next.slnx ├── Ink-Canvas-Next/ +│ ├── Assets/ +│ │ └── avalonia-logo.ico +│ ├── Converters/ +│ │ └── UiLanguageDisplayConverter.cs +│ ├── Models/ +│ │ ├── AdaptiveColors.cs +│ │ └── HighlighterSettings.cs +│ ├── Services/ +│ │ ├── CanvasService.cs # 画布导出 PNG +│ │ ├── PowerPointService.cs # PPT 幻灯片监控与导航 +│ │ └── SlideAnnotationManager.cs # 每页笔迹持久化 │ ├── ViewModels/ │ │ ├── ViewModelBase.cs -│ │ └── MainWindowViewModel.cs +│ │ └── MainWindowViewModel.cs # 主 ViewModel │ ├── Views/ -│ │ ├── MainWindow.axaml +│ │ ├── MainWindow.axaml # 主窗口 UI │ │ └── MainWindow.axaml.cs │ ├── App.axaml │ ├── App.axaml.cs │ ├── Program.cs │ ├── ViewLocator.cs +│ ├── app.manifest │ └── Ink-Canvas-Next.csproj -└── Ink Canvas Next.slnx +├── .github/workflows/ +│ ├── build-and-release.yml +│ └── opencode.yml +├── .gitignore +├── LICENSE +├── README.md +└── TODO.md +``` + +## 系统要求 + +- Windows 10 或更高版本(需要 PowerPoint COM 支持) +- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- Microsoft PowerPoint(可选,用于幻灯片集成功能) + +## 编译 + +```bash +cd Ink-Canvas-Next +dotnet build ``` +## 运行 + +```bash +dotnet run +``` + +## 使用说明 + +1. **绘图**:选择画笔或荧光笔工具,在画布上绘制 +2. **切换颜色**:从调色板下拉菜单中选择颜色 +3. **调整大小**:拖动滑块调整笔刷粗细(1–20px) +4. **切换背景**:点击 Transparent / White / Black 切换背景模式 +5. **PPT 批注**:启动 PowerPoint 幻灯片放映后,应用自动检测并显示导航控件,可在每页幻灯片上独立批注 +6. **保存**:点击 Save 将当前画布导出为 PNG +7. **清除**:点击 Clear 清空所有笔迹 +8. **语言**:通过语言下拉菜单在中英文之间切换 + ## 许可证 MIT