diff --git a/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml b/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml
index 8e96fd8a9..a48ca19ea 100644
--- a/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml
+++ b/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml
@@ -5,6 +5,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:props="clr-namespace:Ink_Canvas.Properties"
xmlns:controls="clr-namespace:Ink_Canvas.Controls;assembly=InkCanvas.Controls"
+ xmlns:ui="http://schemas.inkore.net/lib/ui/wpf/modern"
mc:Ignorable="d"
d:DesignWidth="380" d:DesignHeight="300">
@@ -43,6 +44,36 @@
Foreground="{DynamicResource FloatingBarForegroundBrush}"
Opacity="0.72"
FontSize="11" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml.cs b/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml.cs
index d0f1652c6..7b796f959 100644
--- a/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml.cs
+++ b/Ink Canvas/Controls/Popups/BoardRoamingPopupContent.xaml.cs
@@ -1,3 +1,4 @@
+using iNKORE.UI.WPF.Modern.Controls;
using System;
using System.Windows;
using System.Windows.Controls;
@@ -26,6 +27,8 @@ private enum DragInputDevice
public Image PreviewImageControl => PreviewImage;
public Button CloseButtonControl => Shell?.CloseButtonControl;
+ public ToggleSwitch TwoFingerZoomToggle => ToggleSwitchEnableTwoFingerZoom;
+ public ToggleSwitch TwoFingerRotationToggle => ToggleSwitchEnableTwoFingerRotation;
public BoardRoamingPopupContent()
{
diff --git a/Ink Canvas/Helpers/MessageBoxHelper.cs b/Ink Canvas/Helpers/MessageBoxHelper.cs
index 7ab5a1e64..df8f25a73 100644
--- a/Ink Canvas/Helpers/MessageBoxHelper.cs
+++ b/Ink Canvas/Helpers/MessageBoxHelper.cs
@@ -1,7 +1,13 @@
+using iNKORE.UI.WPF.Modern.Common;
+using iNKORE.UI.WPF.Modern.Common.IconKeys;
using System;
using System.Linq;
+using System.Media;
using System.Threading.Tasks;
using System.Windows;
+using System.Windows.Interop;
+using System.Windows.Media;
+using System.Windows.Threading;
using MessageBox = iNKORE.UI.WPF.Modern.Controls.MessageBox;
namespace Ink_Canvas.Helpers
@@ -159,5 +165,262 @@ public static Task ShowAsync(
}
#endregion
+
+ #region 支持自定义显示位置(ShowAt / ShowAtAsync / ShowAtNonBlocking)
+
+ ///
+ /// 将可视元素上的坐标点换算为屏幕坐标(DIP,即 Window.Left/Top 使用的单位),
+ /// 供 ShowAt / ShowAtAsync / ShowAtNonBlocking 定位使用。
+ /// 假定目标点与该可视元素位于同一显示器(DPI 按该元素当前所在显示器换算)。
+ /// 失败时返回 NaN 点。
+ ///
+ public static Point TranslateToScreen(Visual visual, Point point)
+ {
+ try
+ {
+ var source = PresentationSource.FromVisual(visual);
+ if (source?.CompositionTarget == null) return new Point(double.NaN, double.NaN);
+ var fromDevice = source.CompositionTarget.TransformToDevice;
+ var screenPx = visual.PointToScreen(point);
+ return new Point(screenPx.X / fromDevice.M11, screenPx.Y / fromDevice.M22);
+ }
+ catch
+ {
+ return new Point(double.NaN, double.NaN);
+ }
+ }
+
+ ///
+ /// 以指定屏幕位置(DIP)显示同步模态弹窗,返回点击结果。
+ /// 位置无效(NaN/∞)时回退为居中显示。
+ ///
+ public static MessageBoxResult ShowAt(
+ DependencyObject context,
+ double screenX, double screenY,
+ string messageBoxText,
+ string caption = "",
+ MessageBoxButton button = MessageBoxButton.OK,
+ MessageBoxImage icon = MessageBoxImage.None,
+ Action configure = null)
+ {
+ var app = Application.Current;
+ var dispatcher = app?.Dispatcher;
+
+ if (dispatcher != null && !dispatcher.CheckAccess())
+ {
+ return dispatcher.Invoke(() => ShowAt(context, screenX, screenY, messageBoxText, caption, button, icon, configure));
+ }
+
+ var owner = GetDefaultOwner(context);
+ var box = CreatePositionedMessageBox(owner, screenX, screenY, messageBoxText, caption, button, icon);
+ configure?.Invoke(box);
+ return box.ShowDialog();
+ }
+
+ ///
+ /// 以指定屏幕位置(DIP)显示异步弹窗(非阻塞调用线程,等待用户点击后返回结果)。
+ ///
+ public static Task ShowAtAsync(
+ DependencyObject context,
+ double screenX, double screenY,
+ string messageBoxText,
+ string caption = "",
+ MessageBoxButton button = MessageBoxButton.OK,
+ MessageBoxImage icon = MessageBoxImage.None,
+ Action configure = null)
+ {
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ ShowAtNonBlocking(context, screenX, screenY, messageBoxText, caption, button, icon,
+ onClosed: r => tcs.TrySetResult(r),
+ autoCloseSeconds: null,
+ showActivated: true,
+ configure: configure);
+ return tcs.Task;
+ }
+
+ ///
+ /// 以指定屏幕位置(DIP)显示非模态弹窗:不阻塞调用方、默认不抢焦点。
+ /// 可通过 设置自动关闭;
+ /// 弹窗关闭时(含自动关闭、用户点击、外部关闭)以最终结果回调 ,
+ /// 未点击按钮时结果为 。
+ /// 返回弹窗实例,可通过其 Close(MessageBoxResult) 主动关闭。
+ ///
+ public static MessageBox ShowAtNonBlocking(
+ DependencyObject context,
+ double screenX, double screenY,
+ string messageBoxText,
+ string caption = "",
+ MessageBoxButton button = MessageBoxButton.OK,
+ MessageBoxImage icon = MessageBoxImage.None,
+ Action onClosed = null,
+ double? autoCloseSeconds = null,
+ bool showActivated = false,
+ Action configure = null)
+ {
+ var app = Application.Current;
+ var dispatcher = app?.Dispatcher;
+
+ if (dispatcher != null && !dispatcher.CheckAccess())
+ {
+ return dispatcher.Invoke(() => ShowAtNonBlocking(context, screenX, screenY, messageBoxText, caption, button, icon, onClosed, autoCloseSeconds, showActivated, configure));
+ }
+
+ var owner = GetDefaultOwner(context);
+ var box = CreatePositionedMessageBox(owner, screenX, screenY, messageBoxText, caption, button, icon, showActivated);
+ configure?.Invoke(box);
+
+ var completed = false;
+
+ DispatcherTimer autoCloseTimer = null;
+ if (autoCloseSeconds.HasValue && autoCloseSeconds.Value > 0)
+ {
+ autoCloseTimer = new DispatcherTimer(DispatcherPriority.Normal, dispatcher ?? Dispatcher.CurrentDispatcher)
+ {
+ Interval = TimeSpan.FromSeconds(autoCloseSeconds.Value)
+ };
+ autoCloseTimer.Tick += (s, e) =>
+ {
+ try { box.Close(MessageBoxResult.None); } catch { }
+ };
+ autoCloseTimer.Start();
+ }
+
+ void Complete()
+ {
+ if (completed) return;
+ completed = true;
+ autoCloseTimer?.Stop();
+ // 库的 Close(result) 先写入 _result 再调用 Close()(此时 Window.Closed 同步触发),
+ // 库自身的 Closed 事件要等 Close() 返回后才触发(晚于 Window.Closed),
+ // 因此必须直接读 Result 属性:按钮点击路径能取到点击结果,外部关闭路径为 None。
+ onClosed?.Invoke(box.Result);
+ }
+
+ // Window.Closed 覆盖所有关闭路径(含 Alt+F4 等外部关闭)
+ ((Window)box).Closed += (s, e) => Complete();
+
+ try
+ {
+ box.Show();
+ }
+ catch
+ {
+ Complete();
+ throw;
+ }
+
+ return box;
+ }
+
+ ///
+ /// 构建以 Manual 方式定位到屏幕指定位置(DIP)的 MessageBox 实例。
+ /// 镜像 Owner 的置顶状态(置顶窗口的弹窗需同置顶才能保持可见),
+ /// 并在显示后钳制到所在显示器工作区内,避免弹窗超出屏幕。
+ ///
+ private static MessageBox CreatePositionedMessageBox(
+ Window owner,
+ double screenX, double screenY,
+ string messageBoxText, string caption,
+ MessageBoxButton button, MessageBoxImage icon,
+ bool showActivated = true)
+ {
+ bool manualPosition = IsFinite(screenX) && IsFinite(screenY);
+
+ var box = new MessageBox
+ {
+ Owner = owner,
+ Content = messageBoxText,
+ Caption = caption ?? string.Empty,
+ MessageBoxButtons = button,
+ IconSource = CreateIconSource(icon),
+ ShowInTaskbar = false,
+ ShowActivated = showActivated,
+ Topmost = owner != null && owner.Topmost,
+ WindowStartupLocation = manualPosition
+ ? WindowStartupLocation.Manual
+ : (owner != null ? WindowStartupLocation.CenterOwner : WindowStartupLocation.CenterScreen),
+ Left = manualPosition ? screenX : 0,
+ Top = manualPosition ? screenY : 0,
+ };
+
+ if (MessageBox.MakeSound)
+ {
+ box.SystemSoundOnLoaded = CreateSystemSound(icon);
+ }
+
+ box.ContentRendered += (s, e) => ClampToWorkArea(box);
+ return box;
+ }
+
+ /// 将 映射为库内图标(与库静态 Show 的映射一致)。
+ private static IconSource CreateIconSource(MessageBoxImage icon)
+ {
+ FontIconData symbol;
+ switch (icon)
+ {
+ case MessageBoxImage.Error: symbol = SegoeFluentIcons.ErrorBadge; break;
+ case MessageBoxImage.Information: symbol = SegoeFluentIcons.Info; break;
+ case MessageBoxImage.Warning: symbol = SegoeFluentIcons.Warning; break;
+ case MessageBoxImage.Question: symbol = SegoeFluentIcons.Unknown; break;
+ default: return null;
+ }
+ return new FontIconSource { Icon = symbol, FontSize = 30 };
+ }
+
+ /// 将 映射为系统提示音(与库静态 Show 的映射一致)。
+ private static SystemSound CreateSystemSound(MessageBoxImage icon)
+ {
+ switch (icon)
+ {
+ case MessageBoxImage.Error: return SystemSounds.Hand;
+ case MessageBoxImage.Information: return SystemSounds.Asterisk;
+ case MessageBoxImage.Warning: return SystemSounds.Exclamation;
+ case MessageBoxImage.Question: return SystemSounds.Question;
+ default: return null;
+ }
+ }
+
+ /// 显示后按实际渲染尺寸将弹窗钳制回其所在显示器的工作区,防止溢出屏幕。
+ private static void ClampToWorkArea(MessageBox box)
+ {
+ try
+ {
+ if (box.ActualWidth <= 0 || box.ActualHeight <= 0) return;
+ if (!IsFinite(box.Left) || !IsFinite(box.Top)) return;
+
+ var hwnd = new WindowInteropHelper(box).Handle;
+ if (hwnd == IntPtr.Zero) return;
+ var screen = System.Windows.Forms.Screen.FromHandle(hwnd);
+ if (screen == null) return;
+
+ var dpi = VisualTreeHelper.GetDpi(box);
+ double waLeft = screen.WorkingArea.Left / dpi.PixelsPerDip;
+ double waTop = screen.WorkingArea.Top / dpi.PixelsPerDip;
+ double waRight = screen.WorkingArea.Right / dpi.PixelsPerDip;
+ double waBottom = screen.WorkingArea.Bottom / dpi.PixelsPerDip;
+
+ double left = box.Left;
+ double top = box.Top;
+ if (left + box.ActualWidth > waRight) left = waRight - box.ActualWidth;
+ if (top + box.ActualHeight > waBottom) top = waBottom - box.ActualHeight;
+ if (left < waLeft) left = waLeft;
+ if (top < waTop) top = waTop;
+
+ const double epsilon = 0.5;
+ if (Math.Abs(left - box.Left) > epsilon) box.Left = left;
+ if (Math.Abs(top - box.Top) > epsilon) box.Top = top;
+ }
+ catch
+ {
+ // 钳制失败不影响弹窗本身
+ }
+ }
+
+ private static bool IsFinite(double value)
+ {
+ return !double.IsNaN(value) && !double.IsInfinity(value);
+ }
+
+ #endregion
}
}
diff --git a/Ink Canvas/MainWindow.xaml b/Ink Canvas/MainWindow.xaml
index 2fa18b60f..f59fdd7ae 100644
--- a/Ink Canvas/MainWindow.xaml
+++ b/Ink Canvas/MainWindow.xaml
@@ -338,82 +338,28 @@
-
-
+
-
-
-
-
-
-
-
-
-
+
diff --git a/Ink Canvas/MainWindow.xaml.cs b/Ink Canvas/MainWindow.xaml.cs
index 640d352a3..159e8529f 100644
--- a/Ink Canvas/MainWindow.xaml.cs
+++ b/Ink Canvas/MainWindow.xaml.cs
@@ -2496,7 +2496,7 @@ private void inkCanvas_StylusDown(object sender, StylusDownEventArgs e)
inkCanvas.CaptureStylus();
ViewboxFloatingBar.IsHitTestVisible = false;
BlackboardUIGridForInkReplay.IsHitTestVisible = false;
- BeginBoardRoaming(e.GetPosition(inkCanvas));
+ BeginBoardRoamingContact(e.StylusDevice.Id, e.GetPosition(inkCanvas));
e.Handled = true;
return;
}
@@ -2520,9 +2520,9 @@ private void inkCanvas_StylusMove(object sender, StylusEventArgs e)
e.Handled = true;
return;
}
- if (!_isBoardRoamingPointerDown) return;
+ if (!_isBoardRoamingPointerDown && !_isBoardRoamingTwoFingerGesture && _boardRoamingContacts.Count == 0) return;
- MoveBoardRoaming(e.GetPosition(inkCanvas));
+ MoveBoardRoamingContact(e.StylusDevice.Id, e.GetPosition(inkCanvas));
e.Handled = true;
}
@@ -2530,12 +2530,15 @@ private void inkCanvas_StylusMove(object sender, StylusEventArgs e)
private void inkCanvas_StylusUp(object sender, StylusEventArgs e)
{
EndSecAgentStrokeErase();
- if (_isBoardRoamingPointerDown)
+ if (_isBoardRoamingPointerDown || _isBoardRoamingTwoFingerGesture || _boardRoamingContacts.Count > 0)
{
- EndBoardRoaming();
- inkCanvas.ReleaseStylusCapture();
- ViewboxFloatingBar.IsHitTestVisible = true;
- BlackboardUIGridForInkReplay.IsHitTestVisible = true;
+ EndBoardRoamingContact(e.StylusDevice.Id);
+ if (_boardRoamingContacts.Count == 0)
+ {
+ inkCanvas.ReleaseStylusCapture();
+ ViewboxFloatingBar.IsHitTestVisible = true;
+ BlackboardUIGridForInkReplay.IsHitTestVisible = true;
+ }
e.Handled = true;
return;
}
diff --git a/Ink Canvas/MainWindow_cs/MW_AnnotationDotHint.cs b/Ink Canvas/MainWindow_cs/MW_AnnotationDotHint.cs
index 310668c01..6186031e7 100644
--- a/Ink Canvas/MainWindow_cs/MW_AnnotationDotHint.cs
+++ b/Ink Canvas/MainWindow_cs/MW_AnnotationDotHint.cs
@@ -1,4 +1,5 @@
using Ink_Canvas.Helpers;
+using Ink_Canvas.Properties;
using System;
using System.Collections.Generic;
using System.Windows;
@@ -7,7 +8,6 @@
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
-using System.Windows.Threading;
using Point = System.Windows.Point;
namespace Ink_Canvas
@@ -28,10 +28,16 @@ public partial class MainWindow
private readonly Queue _annotationDotPositions = new Queue();
/// 最近点击位置队列的最大容量。
private const int AnnotationDotMaxQueueSize = 10;
- /// 提示自动隐藏计时器。
- private DispatcherTimer _annotationDotHintTimer;
+ /// 最近一次「长笔迹」(真实书写)的提交时间;用于 30 秒未书写的空闲门控。
+ private DateTime? _lastLongStrokeTime;
+ /// 距离上次真实书写多久后,功能按「空闲」状态触发(秒)。
+ private const double AnnotationDotIdleSeconds = 30;
+ /// 上一次点击笔迹处理时的空闲状态;用于检测刚跨越 30 秒空闲边界的时刻。
+ private bool _lastClickWasIdle;
/// 提示是否正在显示。
private bool _annotationDotHintVisible;
+ /// 当前显示中的「批注中」提示弹窗(iNKORE MessageBox,非模态),用于重复触发时判断与外部关闭。
+ private iNKORE.UI.WPF.Modern.Controls.MessageBox _annotationDotHintBox;
///
/// 在 后调用,检测短墨迹(点击)并判断是否需要显示提示。
@@ -46,20 +52,49 @@ internal void HandleAnnotationDotAfterStroke(Stroke stroke)
if (currentMode == 1) return; // 白板模式不启用
if (!Settings?.Canvas?.IsEnableAnnotationDotHint ?? true) return;
- var bounds = stroke.GetBounds();
- double maxDim = Math.Max(bounds.Width, bounds.Height);
+ // 注意:Stroke.GetBounds() 会按笔宽向外扩展(单击 ≈ 笔宽 + 路径跨度),
+ // 笔较粗时单击也会超过阈值而被误判为书写,导致本功能整体失效。
+ // 因此这里改用笔迹路径本身的跨度(仅由采样点构成,不含笔宽)判断。
+ var pathExtent = GetStrokePathExtent(stroke);
+ if (pathExtent.IsEmpty) return;
+ double maxDim = Math.Max(pathExtent.Width, pathExtent.Height);
double strokeThreshold = Settings.Canvas.AnnotationDotHintStrokeLengthThreshold;
- // 仅对极短墨迹(点击)进行追踪
- if (maxDim > strokeThreshold) return;
+ // 长笔迹 = 真实书写:重置 30 秒空闲计时器,并清空点击轨迹。
+ if (maxDim > strokeThreshold)
+ {
+ _lastLongStrokeTime = DateTime.Now;
+ _annotationDotPositions.Clear();
+ _lastClickWasIdle = false;
+ return;
+ }
- var center = new Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2);
+ var center = new Point(pathExtent.Left + pathExtent.Width / 2, pathExtent.Top + pathExtent.Height / 2);
if (double.IsNaN(center.X) || double.IsNaN(center.Y)) return;
- // 对单点 / 极短墨迹补画可见圆点(不影响原始墨迹管线)
+ // 对单点 / 极短墨迹补画可见圆点(视觉直径与笔的实际粗细一致,不加粗)
EnsureDotVisible(stroke, center);
- TrackAnnotationDotPosition(center);
+ bool isIdle = IsAnnotationIdle();
+
+ // 刚跨过 30 秒空闲边界时清空点击轨迹:边界前按「非空闲」规则记录的点击
+ // 不参与空闲期的「批注中」提示计数,否则空闲后的首次点击就可能误弹提示。
+ if (isIdle && !_lastClickWasIdle)
+ _annotationDotPositions.Clear();
+ _lastClickWasIdle = isIdle;
+
+ if (isIdle)
+ {
+ // 30 秒未书写:每次点击都显示指示小圆点
+ ShowAnnotationDotIndicator(center);
+ // 短时内连续点击达到阈值 → 显示「批注中」提示
+ TrackAnnotationDotPosition(center, showHint: true);
+ }
+ else
+ {
+ // 30 秒内有书写:仅连续点击达到阈值时,在末次点击显示一次指示小圆点(不显示批注提示)
+ TrackAnnotationDotPosition(center, showHint: false);
+ }
}
catch (Exception ex)
{
@@ -67,6 +102,26 @@ internal void HandleAnnotationDotAfterStroke(Stroke stroke)
}
}
+ ///
+ /// 计算笔迹路径本身的包围盒(仅由采样点构成,不含笔宽外扩)。
+ /// 会按 DrawingAttributes 宽高向外各扩一圈,
+ /// 结果随笔粗增大,不能用于区分「点击」与「书写」。
+ ///
+ private static Rect GetStrokePathExtent(Stroke stroke)
+ {
+ double minX = double.MaxValue, minY = double.MaxValue;
+ double maxX = double.MinValue, maxY = double.MinValue;
+ foreach (StylusPoint sp in stroke.StylusPoints)
+ {
+ if (sp.X < minX) minX = sp.X;
+ if (sp.X > maxX) maxX = sp.X;
+ if (sp.Y < minY) minY = sp.Y;
+ if (sp.Y > maxY) maxY = sp.Y;
+ }
+ if (minX > maxX || minY > maxY) return Rect.Empty;
+ return new Rect(minX, minY, maxX - minX, maxY - minY);
+ }
+
///
/// 对点击产生的极短墨迹补充可见圆点。
/// 使用 避免触发 递归。
@@ -80,9 +135,10 @@ private void EnsureDotVisible(Stroke originalStroke, Point center)
{
// 单点墨迹(StylusPoints.Count == 1)在视觉上不可见,需补点
// 多点但极短墨迹(如 2px 线段)可能也不明显,同样补点
+ var pathExtent = GetStrokePathExtent(originalStroke);
bool needsDot = originalStroke.StylusPoints.Count <= 1
- || originalStroke.GetBounds().Width < 3
- || originalStroke.GetBounds().Height < 3;
+ || pathExtent.Width < 3
+ || pathExtent.Height < 3;
if (!needsDot) return;
@@ -90,16 +146,18 @@ private void EnsureDotVisible(Stroke originalStroke, Point center)
?? (inkCanvas.DefaultDrawingAttributes?.Clone()
?? new DrawingAttributes { Color = Colors.Black, Width = 2, Height = 2 });
- drawingAttrs.Width = Math.Max(drawingAttrs.Width, 3);
- drawingAttrs.Height = Math.Max(drawingAttrs.Height, 3);
+ // 墨迹的渲染直径 = 2×路径半径 + 笔画宽。取路径半径 = 原笔宽/4、笔画宽减半,
+ // 使圆点最终视觉直径恰好等于笔的实际粗细,与非点击墨迹粗细一致。
+ double radius = Math.Min(drawingAttrs.Width, drawingAttrs.Height) / 4;
+ drawingAttrs.Width /= 2;
+ drawingAttrs.Height /= 2;
- // 构建一个由 8 个点组成的微小圆(半径 2px),确保视觉可见
+ // 构建一个由 8 个点组成的微小圆,确保视觉可见
var points = new StylusPointCollection();
- double r = 2;
for (int i = 0; i < 8; i++)
{
double angle = Math.PI * 2 * i / 8;
- points.Add(new StylusPoint(center.X + r * Math.Cos(angle), center.Y + r * Math.Sin(angle)));
+ points.Add(new StylusPoint(center.X + radius * Math.Cos(angle), center.Y + radius * Math.Sin(angle), 0.5f));
}
var dotStroke = new Stroke(points) { DrawingAttributes = drawingAttrs };
@@ -122,11 +180,12 @@ private void EnsureDotVisible(Stroke originalStroke, Point center)
}
///
- /// 记录点击位置到追踪队列,并检查是否需要显示提示。
- /// 仅检查最近 N 个点(N = 点击次数阈值),而非队列全部点,
- /// 避免跨区域点击导致判定失败。
+ /// 记录点击位置到追踪队列,并在连续点击达到阈值时执行相应动作。
+ /// 为 true 时触发「批注中」提示(30 秒空闲后路径);
+ /// 为 false 时仅显示一次指示小圆点(30 秒内路径,不显示批注提示)。
+ /// 仅检查最近 N 个点(N = 点击次数阈值),而非队列全部点,避免跨区域误判。
///
- private void TrackAnnotationDotPosition(Point position)
+ private void TrackAnnotationDotPosition(Point position, bool showHint)
{
if (double.IsNaN(position.X) || double.IsNaN(position.Y)) return;
@@ -143,7 +202,16 @@ private void TrackAnnotationDotPosition(Point position)
// 而非队列中所有点,避免队列中混入旧区域点导致误判
if (IsRecentClusterWithinRadius(clickCount, clusterRadius))
{
- ShowAnnotationDotHint(position);
+ if (showHint)
+ {
+ ShowAnnotationDotHint(position);
+ }
+ else
+ {
+ // 30 秒内连续点击达到阈值:末次点击显示一次指示小圆点,但不显示「批注中」提示
+ ShowAnnotationDotIndicator(position);
+ }
+ _annotationDotPositions.Clear();
}
}
@@ -181,137 +249,137 @@ private bool IsRecentClusterWithinRadius(int count, double radius)
}
///
- /// 显示批注状态提示。使用屏幕坐标绝对定位,边缘点击时对齐锚点而非居中。
+ /// 显示「批注中」提示弹框:使用 iNKORE MessageBox(非模态、不抢焦点、自动关闭)替代原 Popup。
+ /// 定位策略与原 Popup 版本一致:先在画布坐标系内按锚点所在半屏对齐并钳制到画布内,
+ /// 再通过 换算为屏幕 DIP 坐标传给弹窗。
///
private void ShowAnnotationDotHint(Point anchor)
{
_annotationDotPositions.Clear();
if (_annotationDotHintVisible) return;
- _annotationDotHintVisible = true;
-
- var popup = AnnotationDotHintPopup;
- if (popup == null) return;
-
- // 直接将画布坐标转为屏幕坐标(考虑 RenderTransform 等)
- var clickScreen = inkCanvas.PointToScreen(anchor);
+ if (inkCanvas == null) return;
- // 使用实际 Border 宽度,确保与 XAML 定义一致
- double hintWidth = (AnnotationDotHintBorder?.ActualWidth > 0) ? AnnotationDotHintBorder.ActualWidth : 380;
- double hintHeight = 60;
+ // MessageBox 按内容自适应,此处为对齐与钳制用的估算尺寸
+ const double hintWidth = 360;
+ const double hintHeight = 170;
const double margin = 20;
- var workArea = SystemParameters.WorkArea;
- double screenW = workArea.Width;
- double screenH = workArea.Height;
+ double canvasW = inkCanvas.ActualWidth;
+ double canvasH = inkCanvas.ActualHeight;
double hintLeft, hintTop;
- // 水平:靠近左边缘时对齐左边缘,靠近右边缘时对齐右边缘
- if (clickScreen.X < workArea.Left + screenW / 2)
+ // 水平:靠近左半边时对齐左边缘,靠近右半边时对齐右边缘
+ if (anchor.X < canvasW / 2)
{
- // 左半屏:提示左边缘对齐锚点
- hintLeft = clickScreen.X;
+ // 左半边:提示左边缘对齐锚点
+ hintLeft = anchor.X;
}
else
{
- // 右半屏:提示右边缘对齐锚点
- hintLeft = clickScreen.X - hintWidth;
+ // 右半边:提示右边缘对齐锚点
+ hintLeft = anchor.X - hintWidth;
}
- // 垂直:上半屏放锚点下方,下半屏放锚点上方
- if (clickScreen.Y < workArea.Top + screenH / 2)
+ // 垂直:上半边放锚点下方,下半边放锚点上方
+ if (anchor.Y < canvasH / 2)
{
- hintTop = clickScreen.Y + 10;
+ hintTop = anchor.Y + 10;
}
else
{
- hintTop = clickScreen.Y - hintHeight - 10;
+ hintTop = anchor.Y - hintHeight - 10;
}
- // 钳制到屏幕工作区域内
- if (hintLeft < workArea.Left + margin)
- hintLeft = workArea.Left + margin;
- if (hintLeft + hintWidth > workArea.Right - margin)
- hintLeft = workArea.Right - hintWidth - margin;
- if (hintTop < workArea.Top + margin)
- hintTop = workArea.Top + margin;
- if (hintTop + hintHeight > workArea.Bottom - margin)
- hintTop = workArea.Bottom - hintHeight - margin;
-
- popup.HorizontalOffset = hintLeft;
- popup.VerticalOffset = hintTop;
- popup.IsOpen = true;
+ // 钳制到画布可见区域内
+ if (hintLeft < margin)
+ hintLeft = margin;
+ if (hintLeft + hintWidth > canvasW - margin)
+ hintLeft = canvasW - hintWidth - margin;
+ if (hintTop < margin)
+ hintTop = margin;
+ if (hintTop + hintHeight > canvasH - margin)
+ hintTop = canvasH - hintHeight - margin;
- if (AnnotationDotHintBorder != null)
- {
- AnnotationDotHintBorder.Opacity = 0;
- var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300))
- {
- EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut }
- };
- AnnotationDotHintBorder.BeginAnimation(UIElement.OpacityProperty, fadeIn);
- }
+ // 画布坐标 → 屏幕 DIP 坐标(DPI 安全换算),失败则放弃本次提示
+ var screenPoint = MessageBoxHelper.TranslateToScreen(inkCanvas, new Point(hintLeft, hintTop));
+ if (double.IsNaN(screenPoint.X) || double.IsNaN(screenPoint.Y)) return;
- StopAnnotationDotHintTimer();
double displaySeconds = Settings?.Canvas?.AnnotationDotHintDisplayDurationSeconds ?? 3;
- _annotationDotHintTimer = new DispatcherTimer(DispatcherPriority.Normal, Dispatcher)
- {
- Interval = TimeSpan.FromSeconds(displaySeconds)
- };
- _annotationDotHintTimer.Tick += AnnotationDotHintTimer_Tick;
- _annotationDotHintTimer.Start();
- }
- private void HideAnnotationDotHint()
- {
- _annotationDotHintVisible = false;
- StopAnnotationDotHintTimer();
-
- if (AnnotationDotHintBorder != null)
- {
- var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200))
+ var box = MessageBoxHelper.ShowAtNonBlocking(
+ this,
+ screenPoint.X, screenPoint.Y,
+ FloatingBarStrings.Canvas_AnnotationDotHint_Text,
+ string.Empty,
+ MessageBoxButton.YesNo,
+ MessageBoxImage.None,
+ onClosed: result =>
{
- EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn }
- };
- fadeOut.Completed += (s, e) =>
+ _annotationDotHintVisible = false;
+ _annotationDotHintBox = null;
+ if (result == MessageBoxResult.No)
+ {
+ // 「退出批注」:退出批注模式
+ CursorIcon_Click(null, null);
+ }
+ },
+ autoCloseSeconds: displaySeconds > 0 ? displaySeconds : (double?)null,
+ configure: b =>
{
- if (AnnotationDotHintPopup != null)
- AnnotationDotHintPopup.IsOpen = false;
- };
- AnnotationDotHintBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
- }
- else
- {
- if (AnnotationDotHintPopup != null)
- AnnotationDotHintPopup.IsOpen = false;
- }
- }
+ b.YesButtonText = FloatingBarStrings.Canvas_AnnotationDotHint_Keep;
+ b.NoButtonText = FloatingBarStrings.Canvas_AnnotationDotHint_Exit;
+ });
- private void StopAnnotationDotHintTimer()
- {
- if (_annotationDotHintTimer != null)
- {
- _annotationDotHintTimer.Stop();
- _annotationDotHintTimer.Tick -= AnnotationDotHintTimer_Tick;
- _annotationDotHintTimer = null;
- }
+ if (box == null) return;
+ _annotationDotHintVisible = true;
+ _annotationDotHintBox = box;
}
- private void AnnotationDotHintTimer_Tick(object sender, EventArgs e)
+ ///
+ /// 当前是否满足「30 秒未在白板内书写」的空闲条件。
+ /// 从未书写过( 为空)时视为空闲。
+ ///
+ private bool IsAnnotationIdle()
{
- HideAnnotationDotHint();
+ return !_lastLongStrokeTime.HasValue
+ || (DateTime.Now - _lastLongStrokeTime.Value).TotalSeconds >= AnnotationDotIdleSeconds;
}
- private void AnnotationDotHintKeep_Click(object sender, RoutedEventArgs e)
+ ///
+ /// 显示「处于批注状态」的指示小圆点:带圆角容器,渐显出现、短暂停留后渐隐消失。
+ /// Popup 使用 Placement=RelativePoint 且 PlacementTarget=inkCanvas,坐标直接使用画布(逻辑)坐标。
+ ///
+ private void ShowAnnotationDotIndicator(Point anchor)
{
- HideAnnotationDotHint();
- }
+ var popup = AnnotationDotIndicatorPopup;
+ var border = AnnotationDotIndicatorBorder;
+ if (popup == null || border == null) return;
+
+ // 容器尺寸与 XAML 定义一致(28×28),居中对齐点击位置
+ const double size = 28;
+ popup.HorizontalOffset = anchor.X - size / 2;
+ popup.VerticalOffset = anchor.Y - size / 2;
+ popup.IsOpen = true;
- private void AnnotationDotHintExit_Click(object sender, RoutedEventArgs e)
- {
- HideAnnotationDotHint();
- CursorIcon_Click(null, null);
+ // 渐显 → 短暂停留 → 渐隐
+ var anim = new DoubleAnimationUsingKeyFrames();
+ anim.KeyFrames.Add(new LinearDoubleKeyFrame(0, KeyTime.FromTimeSpan(TimeSpan.Zero)));
+ anim.KeyFrames.Add(new LinearDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(150))));
+ anim.KeyFrames.Add(new LinearDoubleKeyFrame(1, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(550))));
+ anim.KeyFrames.Add(new LinearDoubleKeyFrame(0, KeyTime.FromTimeSpan(TimeSpan.FromMilliseconds(750))));
+ anim.Completed += (s, e) =>
+ {
+ if (AnnotationDotIndicatorPopup != null)
+ AnnotationDotIndicatorPopup.IsOpen = false;
+ };
+
+ // 结束上一次未完成的动画,避免快速连点时叠加
+ border.BeginAnimation(UIElement.OpacityProperty, null);
+ border.Opacity = 0;
+ border.BeginAnimation(UIElement.OpacityProperty, anim);
}
+
}
}
\ No newline at end of file
diff --git a/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs b/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs
index 054ef17b4..49353c908 100644
--- a/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs
+++ b/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs
@@ -25,6 +25,18 @@ public partial class MainWindow
private Rect _boardRoamingPreviewMovementBounds;
private bool _isUpdatingBoardRoamingPopup;
private bool _boardRoamingPopupEventsAttached;
+ private bool _isSyncingBoardRoamingToggles;
+ // 漫游触摸接触点(StylusDevice.Id -> 最近位置,按落下顺序排列)。
+ // 漫游模式下 Stylus 事件全部标记 Handled,WPF 不会把它们提升为 Touch/Manipulation,
+ // 双指手势必须直接由接触点计算(WispLogic.PromoteMainToOther:Stylus Handled 即跳过提升)。
+ private readonly List> _boardRoamingContacts = new List>();
+ private bool _isBoardRoamingTwoFingerGesture;
+ private bool _isBoardRoamingPopupDragActive;
+ // 单指拖动挂起:第一指落下后先不拖动,位移超过阈值才激活,
+ // 避免放置第二指过程中的手部漂移带动板书产生异常位移。
+ private bool _isBoardRoamingSingleFingerPending;
+ private Point _boardRoamingPendingStartPoint;
+ private const double BoardRoamingSingleFingerActivationThreshold = 10.0;
internal void ActivateBoardRoamingMode()
{
@@ -36,6 +48,7 @@ internal void ActivateBoardRoamingMode()
}
HideEdgeExpandHint();
+ ResetBoardRoamingGestureState();
ResetTouchStates();
CancelSingleFingerDragMode();
drawingShapeMode = 0;
@@ -92,10 +105,8 @@ private void MoveBoardRoaming(Point point)
var delta = point - _boardRoamingLastPoint;
if (delta.X == 0 && delta.Y == 0) return;
+ // 视口世界坐标由 TransformBoardRoamingContent 按逆矩阵统一维护
TranslateBoardRoamingContent(delta.X, delta.Y);
- _boardRoamingViewportWorldPosition = new Point(
- _boardRoamingViewportWorldPosition.X - delta.X,
- _boardRoamingViewportWorldPosition.Y - delta.Y);
_boardRoamingLastPoint = point;
RefreshBoardRoamingPopup(false);
@@ -111,6 +122,258 @@ private void EndBoardRoaming()
inkCanvas.Cursor = IsBoardRoamingMode ? Cursors.Hand : Cursors.Arrow;
}
+ ///
+ /// 强制结束漫游交互(单指拖动/双指手势),用于切换工具等场景。
+ ///
+ private void CancelBoardRoamingInteraction()
+ {
+ // 双指手势进行中时 _isBoardRoamingPointerDown 已被清除
+ //(见 BeginBoardRoamingTwoFingerGesture),直接调 EndBoardRoaming 会
+ // 提前返回,导致手势历史不提交、CompletePluginCanvasViewportTransform 被跳过。
+ var wasTwoFingerGesture = _isBoardRoamingTwoFingerGesture;
+ ResetBoardRoamingGestureState();
+ if (wasTwoFingerGesture)
+ EndBoardRoamingTwoFingerGesture();
+ else
+ EndBoardRoaming();
+ }
+
+ private void ResetBoardRoamingGestureState()
+ {
+ _boardRoamingContacts.Clear();
+ _isBoardRoamingTwoFingerGesture = false;
+ _isBoardRoamingPopupDragActive = false;
+ _isBoardRoamingSingleFingerPending = false;
+ }
+
+ ///
+ /// 漫游模式下的接触点落下:第 1 指开始单指拖动,第 2 指切换为双指手势。
+ ///
+ private void BeginBoardRoamingContact(int contactId, Point point)
+ {
+ if (!IsBoardRoamingMode) return;
+ if (_boardRoamingContacts.FindIndex(c => c.Key == contactId) >= 0) return;
+
+ _boardRoamingContacts.Add(new KeyValuePair(contactId, point));
+
+ if (_boardRoamingContacts.Count == 1)
+ {
+ // 先挂起,等位移越过激活阈值再启动单指拖动(见 MoveBoardRoamingContact)。
+ _isBoardRoamingSingleFingerPending = true;
+ _boardRoamingPendingStartPoint = point;
+ }
+ else if (_boardRoamingContacts.Count == 2 && !_isBoardRoamingTwoFingerGesture)
+ {
+ BeginBoardRoamingTwoFingerGesture();
+ }
+ }
+
+ private void MoveBoardRoamingContact(int contactId, Point point)
+ {
+ var index = _boardRoamingContacts.FindIndex(c => c.Key == contactId);
+ if (index < 0) return;
+
+ if (!IsBoardRoamingMode)
+ {
+ ResetBoardRoamingGestureState();
+ EndBoardRoaming();
+ return;
+ }
+
+ var previousPoint = _boardRoamingContacts[index].Value;
+
+ if (_isBoardRoamingTwoFingerGesture && _boardRoamingContacts.Count >= 2)
+ {
+ var previousFirst = _boardRoamingContacts[0].Value;
+ var previousSecond = _boardRoamingContacts[1].Value;
+ _boardRoamingContacts[index] = new KeyValuePair(contactId, point);
+ ApplyBoardRoamingTwoFingerGesture(previousFirst, previousSecond,
+ _boardRoamingContacts[0].Value, _boardRoamingContacts[1].Value);
+ RefreshBoardRoamingPopup(false);
+ return;
+ }
+
+ _boardRoamingContacts[index] = new KeyValuePair(contactId, point);
+
+ if (_isBoardRoamingTwoFingerGesture)
+ {
+ // 双指手势进行中但仅剩一指:继续按该指平移(与批注态 Manipulation 一致)。
+ var delta = point - previousPoint;
+ if (delta.X != 0 || delta.Y != 0)
+ {
+ var matrix = Matrix.Identity;
+ matrix.Translate(delta.X, delta.Y);
+ TransformBoardRoamingContent(matrix, delta, 1);
+ RefreshBoardRoamingPopup(false);
+ }
+ return;
+ }
+
+ if (_isBoardRoamingSingleFingerPending)
+ {
+ var offset = point - _boardRoamingPendingStartPoint;
+ if (offset.Length < BoardRoamingSingleFingerActivationThreshold) return;
+
+ // 激活单指拖动:以当前位置为拖动起点,本次移动不产生位移(无跳变)。
+ _isBoardRoamingSingleFingerPending = false;
+ BeginBoardRoaming(point);
+ return;
+ }
+
+ MoveBoardRoaming(point);
+ }
+
+ ///
+ /// 漫游模式下的接触点抬起。全部抬起时提交手势历史。
+ ///
+ private void EndBoardRoamingContact(int contactId)
+ {
+ var index = _boardRoamingContacts.FindIndex(c => c.Key == contactId);
+ if (index >= 0) _boardRoamingContacts.RemoveAt(index);
+
+ if (_isBoardRoamingTwoFingerGesture)
+ {
+ if (_boardRoamingContacts.Count == 0)
+ EndBoardRoamingTwoFingerGesture();
+ return;
+ }
+
+ if (_boardRoamingContacts.Count == 0)
+ {
+ _isBoardRoamingSingleFingerPending = false;
+ EndBoardRoaming();
+ }
+ }
+
+ private void BeginBoardRoamingTwoFingerGesture()
+ {
+ // 取消单指挂起并提交可能存在的单指拖动阶段历史,双指手势单独成一步撤销记录。
+ _isBoardRoamingSingleFingerPending = false;
+ EndBoardRoaming();
+
+ _isBoardRoamingTwoFingerGesture = true;
+ _boardRoamingStrokeHistory = new Dictionary();
+ foreach (var stroke in inkCanvas.Strokes)
+ _boardRoamingStrokeHistory[stroke] = stroke.StylusPoints.Clone();
+ }
+
+ private void EndBoardRoamingTwoFingerGesture()
+ {
+ _isBoardRoamingTwoFingerGesture = false;
+ _isBoardRoamingPointerDown = false;
+ CommitBoardRoamingHistory();
+ CompletePluginCanvasViewportTransform();
+ inkCanvas.Cursor = IsBoardRoamingMode ? Cursors.Hand : Cursors.Arrow;
+ RefreshBoardRoamingPopup();
+ }
+
+ ///
+ /// 双指手势:以两指连线中点为锚点——内容跟随指心平移,按两指间距比例
+ /// 等比缩放(非变形,横纵向同比例),旋转按两指角度变化绕中点进行。
+ ///
+ private void ApplyBoardRoamingTwoFingerGesture(Point previousFirst, Point previousSecond, Point currentFirst, Point currentSecond)
+ {
+ bool enableZoom = Settings.Gesture.IsEnableTwoFingerZoomRoaming;
+ bool enableRotate = Settings.Gesture.IsEnableTwoFingerRotationRoaming;
+
+ var previousMidpoint = new Point(
+ (previousFirst.X + previousSecond.X) / 2,
+ (previousFirst.Y + previousSecond.Y) / 2);
+ var currentMidpoint = new Point(
+ (currentFirst.X + currentSecond.X) / 2,
+ (currentFirst.Y + currentSecond.Y) / 2);
+
+ var m = new Matrix();
+ double scale = 1;
+
+ if (enableZoom)
+ {
+ var previousDistance = GetDistance(previousFirst, previousSecond);
+ var currentDistance = GetDistance(currentFirst, currentSecond);
+ if (previousDistance > 0.001 && currentDistance > 0.001)
+ {
+ scale = currentDistance / previousDistance;
+ m.ScaleAt(scale, scale, previousMidpoint.X, previousMidpoint.Y);
+ }
+ }
+
+ if (enableRotate)
+ {
+ var previousAngle = Math.Atan2(previousSecond.Y - previousFirst.Y, previousSecond.X - previousFirst.X) * 180 / Math.PI;
+ var currentAngle = Math.Atan2(currentSecond.Y - currentFirst.Y, currentSecond.X - currentFirst.X) * 180 / Math.PI;
+ m.RotateAt(currentAngle - previousAngle, previousMidpoint.X, previousMidpoint.Y);
+ }
+
+ var trans = currentMidpoint - previousMidpoint;
+ m.Translate(trans.X, trans.Y);
+
+ TransformBoardRoamingContent(m, trans, scale);
+ }
+
+ ///
+ /// 对板书内容应用任意矩阵(平移/等比缩放/旋转),同步图片、圆圈标注、插件视口、
+ /// 视口世界坐标与视频展台预览。平移分量单独传给展台(展台不支持缩放旋转)。
+ ///
+ private void TransformBoardRoamingContent(Matrix matrix, Vector translationDelta, double scale)
+ {
+ var previousCommitType = _currentCommitType;
+ _currentCommitType = CommitReason.CodeInput;
+ try
+ {
+ foreach (var stroke in inkCanvas.Strokes)
+ {
+ stroke.Transform(matrix, false);
+ if (scale != 1)
+ {
+ try
+ {
+ stroke.DrawingAttributes.Width *= scale;
+ stroke.DrawingAttributes.Height *= scale;
+ }
+ catch (Exception ex) { System.Diagnostics.Debug.WriteLine(ex); }
+ }
+ }
+
+ TransformCanvasImages(matrix);
+
+ foreach (var circle in circles)
+ {
+ circle.R = GetDistance(circle.Stroke.StylusPoints[0].ToPoint(),
+ circle.Stroke.StylusPoints[circle.Stroke.StylusPoints.Count / 2].ToPoint()) / 2;
+ circle.Centroid = new Point(
+ (circle.Stroke.StylusPoints[0].X +
+ circle.Stroke.StylusPoints[circle.Stroke.StylusPoints.Count / 2].X) / 2,
+ (circle.Stroke.StylusPoints[0].Y +
+ circle.Stroke.StylusPoints[circle.Stroke.StylusPoints.Count / 2].Y) / 2);
+ }
+
+ PublishPluginCanvasViewportTransform(matrix);
+
+ if (_isVideoPresenterSpecialMode)
+ {
+ _boothPreviewTranslateX += translationDelta.X;
+ _boothPreviewTranslateY += translationDelta.Y;
+ ApplyBoothPreviewTransform();
+ ResetRotationBaseline();
+ }
+
+ // 视口世界坐标跟随内容变换的逆矩阵(纯平移时等价于 pos -= delta,与既有行为一致)。
+ if (matrix.HasInverse)
+ {
+ var inverse = matrix;
+ inverse.Invert();
+ var origin = inverse.Transform(new Point(0, 0));
+ _boardRoamingViewportWorldPosition = new Point(
+ _boardRoamingViewportWorldPosition.X + origin.X,
+ _boardRoamingViewportWorldPosition.Y + origin.Y);
+ }
+ }
+ finally
+ {
+ _currentCommitType = previousCommitType;
+ }
+ }
+
private void CommitBoardRoamingHistory()
{
if (_boardRoamingStrokeHistory == null) return;
@@ -132,6 +395,19 @@ private void CommitBoardRoamingHistory()
StrokeInitialHistory[item.Key] = item.Value.Item2;
}
+ // 双指捏合缩放除变换几何外还会修改 DrawingAttributes.Width/Height
+ //(见 TransformBoardRoamingContent),事件驱动的 DrawingAttributesHistory
+ // 会挂起这些变化。若不在此提交:撤销只回退几何、保留缩放后的笔宽,
+ // 且过期条目会被后续无关操作一并提交。提交后重置,与 MW_Colors /
+ // MW_SelectionGestures 的提交模式一致。
+ if (DrawingAttributesHistory.Count > 0)
+ {
+ timeMachine.CommitStrokeDrawingAttributesHistory(DrawingAttributesHistory);
+ DrawingAttributesHistory = new Dictionary>();
+ foreach (var item in DrawingAttributesHistoryFlag)
+ item.Value.Clear();
+ }
+
if (history.Count > 0 || inkCanvas.Children.Count > 0)
MarkCurrentPageInkChanged();
@@ -144,12 +420,29 @@ private void ShowBoardRoamingPopup()
if (BoardRoamingPopup == null || BoardRoamingPopupContent == null) return;
AttachBoardRoamingPopupEvents();
+ SyncBoardRoamingToggleStates();
BoardRoamingPopup.IsOpen = false;
RefreshBoardRoamingPopup();
AnimationsHelper.ShowPopupWithSlideAndFade(BoardRoamingPopup);
_popupManager?.BringToFront(BoardRoamingPopup);
}
+ private void SyncBoardRoamingToggleStates()
+ {
+ _isSyncingBoardRoamingToggles = true;
+ try
+ {
+ if (BoardRoamingPopupContent.TwoFingerZoomToggle != null)
+ BoardRoamingPopupContent.TwoFingerZoomToggle.IsOn = Settings.Gesture.IsEnableTwoFingerZoomRoaming;
+ if (BoardRoamingPopupContent.TwoFingerRotationToggle != null)
+ BoardRoamingPopupContent.TwoFingerRotationToggle.IsOn = Settings.Gesture.IsEnableTwoFingerRotationRoaming;
+ }
+ finally
+ {
+ _isSyncingBoardRoamingToggles = false;
+ }
+ }
+
private void AttachBoardRoamingPopupEvents()
{
if (_boardRoamingPopupEventsAttached || BoardRoamingPopupContent == null) return;
@@ -158,10 +451,50 @@ private void AttachBoardRoamingPopupEvents()
BoardRoamingPopupContent.ViewportDragStarted += BeginBoardRoamingPopupDrag;
BoardRoamingPopupContent.ViewportDragCompleted += EndBoardRoamingPopupDrag;
if (BoardRoamingPopupContent.CloseButtonControl != null)
- BoardRoamingPopupContent.CloseButtonControl.Click += (s, e) => BoardRoamingPopup.IsOpen = false;
+ {
+ // 按下阶段立即关闭,避免首次点击被拖拽/捕获等逻辑吞掉导致需要点两次。
+ BoardRoamingPopupContent.CloseButtonControl.PreviewMouseLeftButtonDown +=
+ BoardRoamingCloseButton_PreviewInputDown;
+ BoardRoamingPopupContent.CloseButtonControl.PreviewStylusDown +=
+ BoardRoamingCloseButton_PreviewStylusDown;
+ }
+ if (BoardRoamingPopupContent.TwoFingerZoomToggle != null)
+ BoardRoamingPopupContent.TwoFingerZoomToggle.Toggled += BoardRoamingTwoFingerZoom_Toggled;
+ if (BoardRoamingPopupContent.TwoFingerRotationToggle != null)
+ BoardRoamingPopupContent.TwoFingerRotationToggle.Toggled += BoardRoamingTwoFingerRotation_Toggled;
_boardRoamingPopupEventsAttached = true;
}
+ private void BoardRoamingCloseButton_PreviewInputDown(object sender, MouseButtonEventArgs e)
+ {
+ BoardRoamingPopup.IsOpen = false;
+ e.Handled = true;
+ }
+
+ private void BoardRoamingCloseButton_PreviewStylusDown(object sender, StylusDownEventArgs e)
+ {
+ BoardRoamingPopup.IsOpen = false;
+ e.Handled = true;
+ }
+
+ private void BoardRoamingTwoFingerZoom_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (_isSyncingBoardRoamingToggles) return;
+ var toggle = sender as iNKORE.UI.WPF.Modern.Controls.ToggleSwitch;
+ if (toggle == null) return;
+ Settings.Gesture.IsEnableTwoFingerZoomRoaming = toggle.IsOn;
+ SaveSettingsToFile();
+ }
+
+ private void BoardRoamingTwoFingerRotation_Toggled(object sender, RoutedEventArgs e)
+ {
+ if (_isSyncingBoardRoamingToggles) return;
+ var toggle = sender as iNKORE.UI.WPF.Modern.Controls.ToggleSwitch;
+ if (toggle == null) return;
+ Settings.Gesture.IsEnableTwoFingerRotationRoaming = toggle.IsOn;
+ SaveSettingsToFile();
+ }
+
private void RefreshBoardRoamingPopup()
{
RefreshBoardRoamingPopup(true);
@@ -306,9 +639,9 @@ private void BoardRoamingPopupContent_ViewportPositionChanged(Point previewPosit
private void BeginBoardRoamingPopupDrag()
{
- if (_isBoardRoamingPointerDown) return;
+ if (_isBoardRoamingPopupDragActive || _isBoardRoamingPointerDown || _boardRoamingContacts.Count > 0) return;
- _isBoardRoamingPointerDown = true;
+ _isBoardRoamingPopupDragActive = true;
_boardRoamingStrokeHistory = new Dictionary();
foreach (var stroke in inkCanvas.Strokes)
_boardRoamingStrokeHistory[stroke] = stroke.StylusPoints.Clone();
@@ -316,9 +649,9 @@ private void BeginBoardRoamingPopupDrag()
private void EndBoardRoamingPopupDrag()
{
- if (!_isBoardRoamingPointerDown) return;
+ if (!_isBoardRoamingPopupDragActive) return;
- _isBoardRoamingPointerDown = false;
+ _isBoardRoamingPopupDragActive = false;
CommitBoardRoamingHistory();
CompletePluginCanvasViewportTransform();
RefreshBoardRoamingPopup();
@@ -328,28 +661,7 @@ private void TranslateBoardRoamingContent(double deltaX, double deltaY)
{
var matrix = Matrix.Identity;
matrix.Translate(deltaX, deltaY);
- var previousCommitType = _currentCommitType;
- _currentCommitType = CommitReason.CodeInput;
- try
- {
- foreach (var stroke in inkCanvas.Strokes)
- stroke.Transform(matrix, false);
- TransformCanvasImages(matrix);
- PublishPluginCanvasViewportTransform(matrix);
- // 视频展台特殊模式:漫游时预览画面与墨迹同步平移
- // (否则只有墨迹会动,展台背景不动)
- if (_isVideoPresenterSpecialMode)
- {
- _boothPreviewTranslateX += deltaX;
- _boothPreviewTranslateY += deltaY;
- ApplyBoothPreviewTransform();
- ResetRotationBaseline();
- }
- }
- finally
- {
- _currentCommitType = previousCommitType;
- }
+ TransformBoardRoamingContent(matrix, new Vector(deltaX, deltaY), 1);
}
private static bool AreStylusPointsEqual(StylusPointCollection first, StylusPointCollection second)
diff --git a/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs b/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs
index 54dbfbd7b..8c695dc11 100644
--- a/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs
+++ b/Ink Canvas/MainWindow_cs/MW_FloatingBarIcons.cs
@@ -3650,7 +3650,7 @@ internal void PenIcon_Click(object sender, MouseButtonEventArgs e)
{
if (TryBlockFrozenPageMutation("切换到画笔")) return;
- EndBoardRoaming();
+ CancelBoardRoamingInteraction();
if (lastBorderMouseDownObject is Panel panel)
panel.Background = new SolidColorBrush(Colors.Transparent);
diff --git a/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs b/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs
index c61bb80a7..50b8e33ba 100644
--- a/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs
+++ b/Ink Canvas/MainWindow_cs/MW_TouchEvents.cs
@@ -2576,11 +2576,13 @@ private void Main_Grid_ManipulationDelta(object sender, ManipulationDeltaEventAr
bool isBoardMode = currentMode == 1;
bool enableTranslate = IsBoardRoamingMode || (isBoardMode ? Settings.Gesture.IsEnableTwoFingerTranslateBoard : Settings.Gesture.IsEnableTwoFingerTranslate);
- bool enableRotate = !IsBoardRoamingMode && (isBoardMode ? Settings.Gesture.IsEnableTwoFingerRotationBoard : Settings.Gesture.IsEnableTwoFingerRotation);
- bool enableZoom = !IsBoardRoamingMode && (isBoardMode ? Settings.Gesture.IsEnableTwoFingerZoomBoard : Settings.Gesture.IsEnableTwoFingerZoom);
- bool enableGestureTranslateOrRotate = IsBoardRoamingMode || (isBoardMode
- ? (Settings.Gesture.IsEnableTwoFingerTranslateBoard || Settings.Gesture.IsEnableTwoFingerRotationBoard)
- : (Settings.Gesture.IsEnableTwoFingerTranslate || Settings.Gesture.IsEnableTwoFingerRotation));
+ bool enableRotate = IsBoardRoamingMode
+ ? Settings.Gesture.IsEnableTwoFingerRotationRoaming
+ : (isBoardMode ? Settings.Gesture.IsEnableTwoFingerRotationBoard : Settings.Gesture.IsEnableTwoFingerRotation);
+ bool enableZoom = IsBoardRoamingMode
+ ? Settings.Gesture.IsEnableTwoFingerZoomRoaming
+ : (isBoardMode ? Settings.Gesture.IsEnableTwoFingerZoomBoard : Settings.Gesture.IsEnableTwoFingerZoom);
+ bool enableGestureTranslateOrRotate = enableTranslate || enableRotate;
if (enableTranslate)
m.Translate(trans.X, trans.Y); // 移动
diff --git a/Ink Canvas/MainWindow_cs/MW_UriHandler.cs b/Ink Canvas/MainWindow_cs/MW_UriHandler.cs
index bda6e9004..87df14e99 100644
--- a/Ink Canvas/MainWindow_cs/MW_UriHandler.cs
+++ b/Ink Canvas/MainWindow_cs/MW_UriHandler.cs
@@ -10,7 +10,7 @@ namespace Ink_Canvas
{
///
/// 处理 icc: URL 协议命令
- /// 支持:收纳/展开/切换、彻底隐藏、点名/计时器/白板、工具状态切换与查询、配置方案列表与切换。
+ /// 支持:收纳/展开/切换、彻底隐藏、点名/计时器/白板/展台/批注、工具状态切换与查询、配置方案列表与切换。
/// 支持:重启/退出、清空墨迹、撤销/重做、翻页/新建/删除白板页、截图、选择工具。
/// 配置方案:icc://config-profile/list 输出列表到 %TEMP%\InkCanvasConfigProfileList.json;
/// icc://config-profile/switch?name=方案名 切换方案,结果写入 %TEMP%\InkCanvasConfigProfileSwitchResult.txt。
@@ -119,6 +119,25 @@ public void HandleUriCommand(string uri)
case "board":
ImageBlackboard_MouseUp(null, null);
return;
+ case "booth":
+ case "videopresenter":
+ // 与浮动栏「视频展台」按钮一致:希沃模式下直接启动希沃视频展台,
+ // 否则先打开白板,再触发内置展台
+ if (Settings.Canvas.LaunchSeewoVideoShowcaseForWhiteboardBooth == true)
+ {
+ SoftwareLauncher.LaunchEasiCamera("希沃视频展台");
+ }
+ else
+ {
+ ImageBlackboard_MouseUp(null, null);
+ ToggleVideoPresenterSidebarPublic();
+ }
+ return;
+ case "annotate":
+ case "annotation":
+ // 批注:切换到画笔模式
+ PenIcon_Click(null, null);
+ return;
case "restart":
ShowNotification(Properties.MainWindowStrings.Main_Uri_Restart);
_ = Task.Delay(300).ContinueWith(_ => Dispatcher.Invoke(() => AppRestartHelper.RestartWithCurrentPrivileges()));
diff --git a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx
index b3b6d4d80..3ba7c8026 100644
--- a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx
+++ b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx
@@ -691,7 +691,7 @@
How long the hint button stays visible after you stop writing. Writing again restarts the timer; hovering the button pauses it.
- Currently in annotation mode
+ Currently in screen annotation
To interact with other apps, exit annotation mode first.
Keep
@@ -703,7 +703,7 @@
Annotation status dot hint
- Show a hint when clicking repeatedly in annotation mode to remind you're in annotation mode
+ In annotation mode, once nothing has been written for 30 seconds, each canvas click briefly flashes a status dot; reaching the click threshold within a short time also shows the "annotating" hint. Writing a long stroke restarts the timer.
Cluster radius
diff --git a/Ink Canvas/Properties/FloatingBarStrings.resx b/Ink Canvas/Properties/FloatingBarStrings.resx
index 772641abc..9b9e8de90 100644
--- a/Ink Canvas/Properties/FloatingBarStrings.resx
+++ b/Ink Canvas/Properties/FloatingBarStrings.resx
@@ -691,7 +691,7 @@
停止书写后,提示按钮保持显示的时间;再次书写会重新计时,悬停按钮时暂停计时。
- 当前正处于批注状态
+ 当前正处于屏幕批注
如需与其它应用交互,需要先退出批注状态
保持
@@ -703,7 +703,7 @@
批注状态点提示
- 批注模式下连续点击画布时,显示提示提醒当前处于批注模式
+ 批注模式下 30 秒未书写后,点击画布会短暂闪现状态小圆点;短时内连续点击达到次数阈值时,才显示「批注中」提示。书写长笔迹会重新计时。
连续点击范围大小
diff --git a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx
index 2707613fb..248321b21 100644
--- a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx
+++ b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx
@@ -664,7 +664,7 @@
停笔之后提示按钮再待多久就消失;接着写会重新计时,鼠标放上去先不数。
- 现在是在批注中哦
+ 现在是在屏幕批注中哦
要与其它应用交互,需要先退出批注状态
保持
@@ -676,7 +676,7 @@
批注状态点提示
- 在批注中一直点画布,会弹出来提醒你现在是在批注中哦
+ 批注中 30 秒没写字时,点画布会闪出状态小圆点;短时间内连点到次数,才会跳出「正在批注」的提示。写长一点的笔迹会重新计时。
连续点击范围大小
diff --git a/Ink Canvas/Properties/StartupStrings.Designer.cs b/Ink Canvas/Properties/StartupStrings.Designer.cs
index f2a72e59b..3984bab12 100644
--- a/Ink Canvas/Properties/StartupStrings.Designer.cs
+++ b/Ink Canvas/Properties/StartupStrings.Designer.cs
@@ -133,5 +133,23 @@ public static string GetString(string key)
public static string UpdatePackageArchitecture => ResourceManager.GetString(nameof(UpdatePackageArchitecture), _resourceCulture);
public static string UpdatePackageArchitectureHint => ResourceManager.GetString(nameof(UpdatePackageArchitectureHint), _resourceCulture);
+
+ public static string ExternalProtocol_ShortcutCreate => ResourceManager.GetString(nameof(ExternalProtocol_ShortcutCreate), _resourceCulture);
+
+ public static string ExternalProtocol_ShortcutCreateHint => ResourceManager.GetString(nameof(ExternalProtocol_ShortcutCreateHint), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Board => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Board), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Booth => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Booth), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Random => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Random), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Settings => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Settings), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Annotate => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Annotate), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Created => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Created), _resourceCulture);
+
+ public static string ExternalProtocol_Shortcut_Failed => ResourceManager.GetString(nameof(ExternalProtocol_Shortcut_Failed), _resourceCulture);
}
}
\ No newline at end of file
diff --git a/Ink Canvas/Properties/StartupStrings.en-US.resx b/Ink Canvas/Properties/StartupStrings.en-US.resx
index b21caec71..5f9128587 100644
--- a/Ink Canvas/Properties/StartupStrings.en-US.resx
+++ b/Ink Canvas/Properties/StartupStrings.en-US.resx
@@ -183,4 +183,31 @@
Select the architecture for the update package. By default, it matches the current software architecture.
+
+ Create desktop shortcuts
+
+
+ Click an icon to create a desktop shortcut for that feature
+
+
+ Whiteboard
+
+
+ Booth
+
+
+ Random pick
+
+
+ Settings
+
+
+ Annotate
+
+
+ Desktop shortcut created: {0}
+
+
+ Failed to create shortcut, check logs for details
+
diff --git a/Ink Canvas/Properties/StartupStrings.resx b/Ink Canvas/Properties/StartupStrings.resx
index 5f07817bd..3f93e5ee0 100644
--- a/Ink Canvas/Properties/StartupStrings.resx
+++ b/Ink Canvas/Properties/StartupStrings.resx
@@ -183,4 +183,31 @@
选择要下载的更新包架构,默认跟随当前软件架构
+
+ 创建快捷方式
+
+
+ 点击图标在桌面创建对应功能的快捷方式
+
+
+ 白板
+
+
+ 展台
+
+
+ 抽选
+
+
+ 设置
+
+
+ 批注
+
+
+ 已创建桌面快捷方式:{0}
+
+
+ 创建快捷方式失败,请查看日志
+
diff --git a/Ink Canvas/Properties/StartupStrings.zh-ME.resx b/Ink Canvas/Properties/StartupStrings.zh-ME.resx
index 34e641f0a..75502eebd 100644
--- a/Ink Canvas/Properties/StartupStrings.zh-ME.resx
+++ b/Ink Canvas/Properties/StartupStrings.zh-ME.resx
@@ -183,4 +183,31 @@
选要下载的更新包架构,默认跟当前软件架构走
+
+ 快捷方式造一个
+
+
+ 点一下图标,把对应功能的桌面快捷方式带回家
+
+
+ 白板
+
+
+ 展台
+
+
+ 抽选
+
+
+ 设置
+
+
+ 批注
+
+
+ 桌面快捷方式已就位:{0}
+
+
+ 快捷方式没造出来,瞅一眼日志呗
+
diff --git a/Ink Canvas/Resources/Settings.cs b/Ink Canvas/Resources/Settings.cs
index 86f60ff7b..9acaa277c 100644
--- a/Ink Canvas/Resources/Settings.cs
+++ b/Ink Canvas/Resources/Settings.cs
@@ -618,6 +618,11 @@ public class Gesture
public bool IsEnableTwoFingerTranslateBoard { get; set; } = true;
[JsonProperty("isEnableTwoFingerRotationBoard")]
public bool IsEnableTwoFingerRotationBoard { get; set; }
+
+ [JsonProperty("isEnableTwoFingerZoomRoaming")]
+ public bool IsEnableTwoFingerZoomRoaming { get; set; } = true;
+ [JsonProperty("isEnableTwoFingerRotationRoaming")]
+ public bool IsEnableTwoFingerRotationRoaming { get; set; }
}
// 更新通道枚举
diff --git a/Ink Canvas/Windows/OobePresetWindow.xaml.cs b/Ink Canvas/Windows/OobePresetWindow.xaml.cs
index 017d5d5f6..15f866f98 100644
--- a/Ink Canvas/Windows/OobePresetWindow.xaml.cs
+++ b/Ink Canvas/Windows/OobePresetWindow.xaml.cs
@@ -238,7 +238,6 @@ public static void ApplyLite(Settings settings)
settings.Canvas.DisablePressure = false;
settings.Canvas.HideStrokeWhenSelecting = true;
settings.Canvas.EnablePalmEraser = false;
- settings.Canvas.IsEnableAnnotationDotHint = false;
// 墨迹纠正
settings.InkToShape.IsInkToShapeEnabled = false;
diff --git a/Ink Canvas/Windows/SettingsViews/Helpers/UriSchemeShortcutHelper.cs b/Ink Canvas/Windows/SettingsViews/Helpers/UriSchemeShortcutHelper.cs
new file mode 100644
index 000000000..b090bbad3
--- /dev/null
+++ b/Ink Canvas/Windows/SettingsViews/Helpers/UriSchemeShortcutHelper.cs
@@ -0,0 +1,309 @@
+using iNKORE.UI.WPF.Modern.Common.IconKeys;
+using Ink_Canvas.Helpers;
+using Ink_Canvas.Properties;
+using IWshRuntimeLibrary;
+using System;
+using System.Globalization;
+using System.IO;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using FontIcon = iNKORE.UI.WPF.Modern.Controls.FontIcon;
+
+namespace Ink_Canvas.Windows.SettingsViews.Helpers
+{
+ ///
+ /// 创建指向 icc:// 外部协议命令的桌面快捷方式。
+ /// 快捷方式以主程序为目标并携带 icc:// 参数:已有实例运行时由 App 通过 IPC 转发命令,
+ /// 无实例时启动应用后按启动 URI 参数执行命令。
+ /// 快捷方式图标为按需生成的 .ico:半透明圆角矩形底 + 程序内图标 + 右下角半透明 ICC 文字,
+ /// 与设置页徽章按钮使用同一套绘制参数。
+ ///
+ public static class UriSchemeShortcutHelper
+ {
+ public const string FeatureBoard = "board"; // 白板
+ public const string FeatureBooth = "booth"; // 展台
+ public const string FeatureRandom = "rand"; // 抽选
+ public const string FeatureSettings = "settings"; // 设置
+ public const string FeatureAnnotate = "annotate"; // 批注
+
+ private const string BadgeText = "ICC";
+
+ // 徽章固定配色:近乎不透明的白色圆角矩形(95%)+ 深色图标文字,在任意桌面壁纸上均可辨识
+ private static readonly Color BadgeFillColor = Color.FromArgb(0xF2, 0xFF, 0xFF, 0xFF);
+ private static readonly Color BadgeBorderColor = Color.FromArgb(0x59, 0x00, 0x00, 0x00);
+ private static readonly Color BadgeIconColor = Color.FromArgb(0xD9, 0x00, 0x00, 0x00);
+ private static readonly Color BadgeTextColor = Color.FromArgb(0x80, 0x00, 0x00, 0x00);
+ // 设置页徽章底色:半透明中性灰,跟随明暗主题依然可辨识
+ private static readonly Color BadgeFillColorOnCard = Color.FromArgb(0x2E, 0x80, 0x80, 0x80);
+ private static readonly Color BadgeBorderColorOnCard = Color.FromArgb(0x47, 0x80, 0x80, 0x80);
+
+ private const string ThemeForegroundBrushKey = "SystemControlForegroundBaseHighBrush";
+
+ /// 获取功能对应的 icc:// 命令路径(不含协议头)。
+ public static string GetFeatureUri(string feature)
+ {
+ switch (feature)
+ {
+ case FeatureBoard: return "board";
+ case FeatureBooth: return "booth";
+ case FeatureRandom: return "rand";
+ case FeatureSettings: return "settings";
+ case FeatureAnnotate: return "tool/pen";
+ default: return null;
+ }
+ }
+
+ /// 获取功能的本地化名称(同时用于快捷方式文件名)。
+ public static string GetFeatureLabel(string feature)
+ {
+ switch (feature)
+ {
+ case FeatureBoard: return StartupStrings.ExternalProtocol_Shortcut_Board;
+ case FeatureBooth: return StartupStrings.ExternalProtocol_Shortcut_Booth;
+ case FeatureRandom: return StartupStrings.ExternalProtocol_Shortcut_Random;
+ case FeatureSettings: return StartupStrings.ExternalProtocol_Shortcut_Settings;
+ case FeatureAnnotate: return StartupStrings.ExternalProtocol_Shortcut_Annotate;
+ default: return feature;
+ }
+ }
+
+ /// 获取功能在程序内使用的图标:几何路径字符串或 FontIconData 字形。
+ private static object GetFeatureIcon(string feature)
+ {
+ switch (feature)
+ {
+ case FeatureBoard: return XamlGraphicsIconGeometries.WhiteboardFloatingBarBtnIcon;
+ case FeatureBooth: return FluentSystemIcons.Video_24_Regular;
+ case FeatureRandom: return XamlGraphicsIconGeometries.RandomDrawIconGeometry;
+ case FeatureSettings: return SegoeFluentIcons.Settings;
+ case FeatureAnnotate: return XamlGraphicsIconGeometries.SolidPenIcon;
+ default: return null;
+ }
+ }
+
+ ///
+ /// 构建徽章视觉元素(半透明圆角矩形底 + 程序内图标 + 右下角半透明 ICC/CE 文字)。
+ /// 用于设置页快捷方式按钮内容。
+ ///
+ public static FrameworkElement CreateBadgeElement(string feature, double size)
+ {
+ var grid = new Grid { Width = size, Height = size };
+
+ var border = new Border
+ {
+ CornerRadius = new CornerRadius(size * 0.19),
+ Background = new SolidColorBrush(BadgeFillColorOnCard),
+ BorderThickness = new Thickness(Math.Max(1, size * 0.022)),
+ BorderBrush = new SolidColorBrush(BadgeBorderColorOnCard),
+ };
+ grid.Children.Add(border);
+
+ var icon = CreateIconElement(feature, size * 0.55);
+ if (icon != null) grid.Children.Add(icon);
+
+ var badge = new TextBlock
+ {
+ Text = BadgeText,
+ FontSize = Math.Max(8, size * 0.18),
+ FontWeight = FontWeights.SemiBold,
+ TextAlignment = TextAlignment.Right,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Bottom,
+ Margin = new Thickness(0, 0, size * 0.08, size * 0.05),
+ Opacity = 0.55,
+ };
+ badge.SetResourceReference(TextBlock.ForegroundProperty, ThemeForegroundBrushKey);
+ grid.Children.Add(badge);
+
+ return grid;
+ }
+
+ /// 构建图标元素:字形类使用程序内 FontIcon,几何类使用 Stretch=Uniform 的 Path。
+ private static FrameworkElement CreateIconElement(string feature, double iconSize)
+ {
+ object icon = GetFeatureIcon(feature);
+ if (icon == null) return null;
+
+ if (icon is FontIconData fontIconData)
+ {
+ var fontIcon = new FontIcon
+ {
+ Icon = fontIconData,
+ FontSize = iconSize,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ fontIcon.SetResourceReference(FontIcon.ForegroundProperty, ThemeForegroundBrushKey);
+ return fontIcon;
+ }
+
+ var path = new System.Windows.Shapes.Path
+ {
+ Data = Geometry.Parse((string)icon),
+ Stretch = Stretch.Uniform,
+ Width = iconSize,
+ Height = iconSize,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ };
+ path.SetResourceReference(System.Windows.Shapes.Path.FillProperty, ThemeForegroundBrushKey);
+ return path;
+ }
+
+ /// 创建桌面快捷方式(已存在则覆盖),返回是否成功。
+ public static bool CreateDesktopShortcut(string feature)
+ {
+ try
+ {
+ string uri = GetFeatureUri(feature);
+ if (string.IsNullOrEmpty(uri)) return false;
+
+ string iconPath = EnsureIconFile(feature);
+ if (string.IsNullOrEmpty(iconPath)) return false;
+
+ string label = GetFeatureLabel(feature);
+ string desktop = Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
+ string lnkPath = Path.Combine(desktop, "Ink Canvas " + label + ".lnk");
+
+ var shell = new WshShell();
+ var shortcut = (IWshShortcut)shell.CreateShortcut(lnkPath);
+ shortcut.TargetPath = System.Windows.Forms.Application.ExecutablePath;
+ shortcut.Arguments = "icc://" + uri;
+ shortcut.WorkingDirectory = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
+ shortcut.IconLocation = iconPath + ",0";
+ shortcut.Description = "Ink Canvas " + label + " (icc://" + uri + ")";
+ shortcut.WindowStyle = 1;
+ shortcut.Save();
+
+ LogHelper.WriteLogToFile($"已创建桌面快捷方式: {lnkPath} (icc://{uri})", LogHelper.LogType.Event);
+ return true;
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"创建快捷方式失败: {ex.Message}", LogHelper.LogType.Error);
+ return false;
+ }
+ }
+
+ /// 生成(或覆盖)功能徽章 .ico 文件并返回路径,失败返回 null。
+ /// 每次都重新生成:避免代码更新徽章样式后,旧的缓存 ico 一直被快捷方式复用。
+ private static string EnsureIconFile(string feature)
+ {
+ try
+ {
+ string dir = Path.Combine(AppDomain.CurrentDomain.SetupInformation.ApplicationBase, "ShortcutIcons");
+ string path = Path.Combine(dir, feature + ".ico");
+ Directory.CreateDirectory(dir);
+ WriteIco(path, RenderBadgeToPng(feature, 256), 256);
+ return path;
+ }
+ catch (Exception ex)
+ {
+ LogHelper.WriteLogToFile($"生成快捷方式图标失败: {ex.Message}", LogHelper.LogType.Error);
+ return null;
+ }
+ }
+
+ /// 把徽章渲染为 PNG 字节(DrawingVisual 方式,不依赖控件模板)。
+ private static byte[] RenderBadgeToPng(string feature, int size)
+ {
+ double fillInset = size * 0.01;
+ double cornerRadius = size * 0.19;
+ var rect = new Rect(fillInset, fillInset, size - fillInset * 2, size - fillInset * 2);
+
+ var visual = new DrawingVisual();
+ using (var dc = visual.RenderOpen())
+ {
+ dc.DrawRoundedRectangle(
+ new SolidColorBrush(BadgeFillColor),
+ new Pen(new SolidColorBrush(BadgeBorderColor), Math.Max(2, size * 0.022)),
+ rect, cornerRadius, cornerRadius);
+
+ DrawIcon(dc, feature, size, size * 0.55);
+ DrawBadgeText(dc, size, size * 0.18);
+ }
+
+ var rtb = new RenderTargetBitmap(size, size, 96, 96, PixelFormats.Pbgra32);
+ rtb.Render(visual);
+
+ var encoder = new PngBitmapEncoder();
+ encoder.Frames.Add(BitmapFrame.Create(rtb));
+ using var ms = new MemoryStream();
+ encoder.Save(ms);
+ return ms.ToArray();
+ }
+
+ /// 在 DrawingVisual 上绘制居中的程序内图标(字形或几何路径)。
+ private static void DrawIcon(DrawingContext dc, string feature, double size, double fitBox)
+ {
+ object icon = GetFeatureIcon(feature);
+ if (icon == null) return;
+ var brush = new SolidColorBrush(BadgeIconColor);
+ var center = new Point(size / 2, size / 2);
+
+ if (icon is FontIconData fontIconData)
+ {
+ var typeface = new Typeface(fontIconData.FontFamily, FontStyles.Normal, FontWeights.Normal, FontStretches.Normal);
+ var text = new FormattedText(fontIconData.Glyph, CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight, typeface, fitBox, brush, 1.0);
+ dc.DrawText(text, new Point(center.X - text.Width / 2, center.Y - text.Height / 2));
+ return;
+ }
+
+ var geometry = Geometry.Parse((string)icon);
+ Rect bounds = geometry.Bounds;
+ if (bounds.Width <= 0 || bounds.Height <= 0) return;
+
+ double scale = fitBox / Math.Max(bounds.Width, bounds.Height);
+ dc.PushTransform(new TransformGroup
+ {
+ Children =
+ {
+ new ScaleTransform(scale, scale),
+ new TranslateTransform(
+ center.X - (bounds.X + bounds.Width / 2) * scale,
+ center.Y - (bounds.Y + bounds.Height / 2) * scale)
+ }
+ });
+ dc.DrawGeometry(brush, null, geometry);
+ dc.Pop();
+ }
+
+ /// 在 DrawingVisual 右下角绘制右对齐的半透明 ICC 文字。
+ private static void DrawBadgeText(DrawingContext dc, double size, double fontSize)
+ {
+ var typeface = new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
+ var brush = new SolidColorBrush(BadgeTextColor);
+ double right = size - size * 0.08;
+ double bottom = size - size * 0.05;
+
+ var line = new FormattedText(BadgeText, CultureInfo.InvariantCulture,
+ FlowDirection.LeftToRight, typeface, fontSize, brush, 1.0);
+ dc.DrawText(line, new Point(right - line.Width, bottom - line.Height));
+ }
+
+ /// 把 PNG 字节包装为单帧 ICO 文件(256px PNG 压缩条目)。
+ private static void WriteIco(string path, byte[] png, int size)
+ {
+ using var fs = new FileStream(path, FileMode.Create, FileAccess.Write);
+ using var bw = new BinaryWriter(fs);
+
+ bw.Write((short)0); // 保留字段
+ bw.Write((short)1); // 类型:图标
+ bw.Write((short)1); // 图像数量
+
+ bw.Write((byte)(size >= 256 ? 0 : size)); // 宽(0 表示 256)
+ bw.Write((byte)(size >= 256 ? 0 : size)); // 高
+ bw.Write((byte)0); // 调色板色数
+ bw.Write((byte)0); // 保留
+ bw.Write((short)1); // 颜色平面数
+ bw.Write((short)32); // 位深
+ bw.Write(png.Length);
+ bw.Write(6 + 16); // 图像数据偏移(ICONDIR + ICONDIRENTRY)
+
+ bw.Write(png);
+ }
+ }
+}
diff --git a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml
index 8c668b88a..3595c81be 100644
--- a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml
+++ b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml
@@ -71,14 +71,37 @@
SwitchName="ToggleSwitchPPTOnlyMode"
Toggled="ToggleSwitchPPTOnlyMode_Toggled" />
-
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ /// 为「创建快捷方式」按钮填充程序内图标徽章内容与提示文字。
+ ///
+ private void InitializeShortcutButtons()
+ {
+ InitializeShortcutButton(BtnShortcutBoard, UriSchemeShortcutHelper.FeatureBoard);
+ InitializeShortcutButton(BtnShortcutBooth, UriSchemeShortcutHelper.FeatureBooth);
+ InitializeShortcutButton(BtnShortcutRandom, UriSchemeShortcutHelper.FeatureRandom);
+ InitializeShortcutButton(BtnShortcutSettings, UriSchemeShortcutHelper.FeatureSettings);
+ InitializeShortcutButton(BtnShortcutAnnotate, UriSchemeShortcutHelper.FeatureAnnotate);
+ }
+
+ private void InitializeShortcutButton(Button button, string feature)
+ {
+ button.Content = UriSchemeShortcutHelper.CreateBadgeElement(feature, 40);
+ button.ToolTip = UriSchemeShortcutHelper.GetFeatureLabel(feature);
+ }
+
+ private void BtnShortcut_Click(object sender, RoutedEventArgs e)
+ {
+ if (!_isLoaded) return;
+ if (!(sender is Button button) || !(button.Tag is string feature)) return;
+
+ // 快捷方式以 icc:// 为目标,协议未启用时 HandleUriCommand 会拒绝全部请求,
+ // 因此先确保外部协议已注册并启用,再创建快捷方式。
+ bool success = EnsureUriSchemeEnabledForShortcut() &&
+ UriSchemeShortcutHelper.CreateDesktopShortcut(feature);
+ var mainWindow = Application.Current?.Windows.OfType().FirstOrDefault();
+ if (mainWindow == null) return;
+
+ mainWindow.ShowNotification(success
+ ? string.Format(StartupStrings.ExternalProtocol_Shortcut_Created, UriSchemeShortcutHelper.GetFeatureLabel(feature))
+ : StartupStrings.ExternalProtocol_Shortcut_Failed);
+ }
+
+ ///
+ /// 确保外部协议可用:协议未启用时自动注册并开启设置(与本页开关等效),
+ /// 使新建的 icc:// 快捷方式真正可用,避免“创建成功”但快捷方式无法工作。
+ ///
+ private bool EnsureUriSchemeEnabledForShortcut()
+ {
+ try
+ {
+ if (SettingsManager.Settings.Advanced.IsEnableUriScheme) return true;
+
+ bool registered = UriSchemeHelper.IsUriSchemeRegistered() || UriSchemeHelper.RegisterUriScheme();
+ if (!registered)
+ {
+ LogHelper.WriteLogToFile("创建快捷方式时注册外部协议失败,请检查权限或日志", LogHelper.LogType.Error);
+ return false;
+ }
+
+ SettingsManager.Settings.Advanced.IsEnableUriScheme = true;
+ SettingsManager.SaveSettingsToFile();
+
+ // 同步本页开关显示;用 _isLoaded 挡住 Toggled 事件避免重复注册
+ _isLoaded = false;
+ ToggleSwitchExternalProtocol.IsOn = true;
+ _isLoaded = true;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"启用外部协议时出错: {ex.Message}");
+ return false;
+ }
+ }
+
private void LoadSettings()
{
_isLoaded = false;
diff --git a/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs b/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs
index cf67b8807..565faaf5f 100644
--- a/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs
+++ b/Ink Canvas/Windows/SettingsViews/SettingsWindow.xaml.cs
@@ -789,6 +789,18 @@ private void CollectEntriesFromPage(DependencyObject root, string pageTag)
else if (node is iNKORE.UI.WPF.Modern.Controls.SettingsExpander se)
{
header = se.Header?.ToString();
+
+ // 展开器(SettingsExpander)内部的子设置卡片并不会总被 LogicalTreeHelper
+ // 枚举到(需展开/加载后才生成容器),因此直接遍历其 Items 集合,
+ // 确保嵌套的「创建快捷方式」「批注状态点提示」等子项也能被设置搜索检索到。
+ if (se.Items is System.Collections.IEnumerable seItems)
+ {
+ foreach (var seItem in seItems)
+ {
+ if (seItem is DependencyObject seItemDep)
+ CollectEntriesFromPage(seItemDep, pageTag);
+ }
+ }
}
if (!string.IsNullOrWhiteSpace(header) && target != null)