From 0d0f1a9ad50642d4226be5f00cc80183ea0587bf Mon Sep 17 00:00:00 2001 From: pylxu Date: Sun, 16 Aug 2026 12:34:10 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20=E6=89=B9?= =?UTF-8?q?=E6=B3=A8=E7=82=B9=E6=8F=90=E7=A4=BA=20=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=88=E5=8A=9F=E8=83=BD=E5=AE=9E=E7=8E=B0=E3=80=81=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E3=80=81=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=94=AF?= =?UTF-8?q?=E6=8C=81=EF=BC=89#628?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/MainWindow.xaml | 79 +++++ .../MainWindow_cs/MW_AnnotationDotHint.cs | 317 ++++++++++++++++++ .../MW_SimulatePressure&InkToShape.cs | 10 + .../Properties/FloatingBarStrings.Designer.cs | 28 ++ .../Properties/FloatingBarStrings.en-US.resx | 42 +++ Ink Canvas/Properties/FloatingBarStrings.resx | 42 +++ .../Properties/FloatingBarStrings.zh-ME.resx | 42 +++ Ink Canvas/Properties/Strings.cs | 14 + Ink Canvas/Resources/Settings.cs | 31 ++ Ink Canvas/Windows/OobePresetWindow.xaml.cs | 2 + .../SettingsViews/Pages/CanvasPage.xaml | 96 ++++++ .../SettingsViews/Pages/CanvasPage.xaml.cs | 235 +++++++++++++ 12 files changed, 938 insertions(+) create mode 100644 Ink Canvas/MainWindow_cs/MW_AnnotationDotHint.cs diff --git a/Ink Canvas/MainWindow.xaml b/Ink Canvas/MainWindow.xaml index 99948c9d9..ffbb79158 100644 --- a/Ink Canvas/MainWindow.xaml +++ b/Ink Canvas/MainWindow.xaml @@ -338,6 +338,85 @@ + + + + + + + + + + + + + + + /// 批注状态点提示:当用户在批注模式下反复点击同一区域时, + /// 在非屏幕边缘区域显示「当前正处于批注状态」的半透明提示, + /// 帮助教师意识到当前处于批注模式而非鼠标模式。 + /// 同时支持点击画布即留下可见点状墨迹。 + /// + /// 实现策略:全部逻辑在 后处理中完成, + /// 不拦截 PreviewMouse 事件,避免干扰 InkCanvas 的墨迹采集与平滑管线。 + /// + /// + public partial class MainWindow + { + /// 最近点击位置队列(画布坐标),用于判断是否在狭小范围内连续点击。 + private readonly Queue _annotationDotPositions = new Queue(); + /// 最近点击位置队列的最大容量。 + private const int AnnotationDotMaxQueueSize = 10; + /// 提示自动隐藏计时器。 + private DispatcherTimer _annotationDotHintTimer; + /// 提示是否正在显示。 + private bool _annotationDotHintVisible; + + /// + /// 在 后调用,检测短墨迹(点击)并判断是否需要显示提示。 + /// 对极短墨迹(单点/包围盒小于阈值)补充可见点状墨迹。 + /// + internal void HandleAnnotationDotAfterStroke(Stroke stroke) + { + try + { + if (stroke == null || stroke.StylusPoints.Count == 0) return; + if (!IsAnnotating) return; + if (currentMode == 1) return; // 白板模式不启用 + if (!Settings?.Canvas?.IsEnableAnnotationDotHint ?? true) return; + + var bounds = stroke.GetBounds(); + double maxDim = Math.Max(bounds.Width, bounds.Height); + double strokeThreshold = Settings.Canvas.AnnotationDotHintStrokeLengthThreshold; + + // 仅对极短墨迹(点击)进行追踪 + if (maxDim > strokeThreshold) return; + + var center = new Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2); + if (double.IsNaN(center.X) || double.IsNaN(center.Y)) return; + + // 对单点 / 极短墨迹补画可见圆点(不影响原始墨迹管线) + EnsureDotVisible(stroke, center); + + TrackAnnotationDotPosition(center); + } + catch (Exception ex) + { + LogHelper.WriteLogToFile($"批注点提示判定失败: {ex.Message}", LogHelper.LogType.Warning); + } + } + + /// + /// 对点击产生的极短墨迹补充可见圆点。 + /// 使用 避免触发 递归。 + /// + private void EnsureDotVisible(Stroke originalStroke, Point center) + { + if (inkCanvas == null) return; + if (IsCurrentPageFrozen) return; + + try + { + // 单点墨迹(StylusPoints.Count == 1)在视觉上不可见,需补点 + // 多点但极短墨迹(如 2px 线段)可能也不明显,同样补点 + bool needsDot = originalStroke.StylusPoints.Count <= 1 + || originalStroke.GetBounds().Width < 3 + || originalStroke.GetBounds().Height < 3; + + if (!needsDot) return; + + var drawingAttrs = originalStroke.DrawingAttributes?.Clone() + ?? (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); + + // 构建一个由 8 个点组成的微小圆(半径 2px),确保视觉可见 + 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))); + } + var dotStroke = new Stroke(points) { DrawingAttributes = drawingAttrs }; + + var previousCommitType = _currentCommitType; + _currentCommitType = CommitReason.CodeInput; + try + { + inkCanvas.Strokes.Add(dotStroke); + timeMachine?.CommitStrokeUserInputHistory(new StrokeCollection { dotStroke }); + } + finally + { + _currentCommitType = previousCommitType; + } + } + catch (Exception ex) + { + LogHelper.WriteLogToFile($"批注点绘制失败: {ex.Message}", LogHelper.LogType.Warning); + } + } + + /// + /// 记录点击位置到追踪队列,并检查是否需要显示提示。 + /// 仅检查最近 N 个点(N = 点击次数阈值),而非队列全部点, + /// 避免跨区域点击导致判定失败。 + /// + private void TrackAnnotationDotPosition(Point position) + { + if (double.IsNaN(position.X) || double.IsNaN(position.Y)) return; + + _annotationDotPositions.Enqueue(position); + while (_annotationDotPositions.Count > AnnotationDotMaxQueueSize) + _annotationDotPositions.Dequeue(); + + int clickCount = Settings.Canvas.AnnotationDotHintClickCount; + double clusterRadius = Settings.Canvas.AnnotationDotHintClusterRadius; + + if (_annotationDotPositions.Count < clickCount) return; + + // 只检查最近 clickCount 个点是否在 clusterRadius 范围内 + // 而非队列中所有点,避免队列中混入旧区域点导致误判 + if (IsRecentClusterWithinRadius(clickCount, clusterRadius)) + { + ShowAnnotationDotHint(position); + } + } + + /// + /// 判断最近 N 个点击位置是否在指定半径内。 + /// + private bool IsRecentClusterWithinRadius(int count, double radius) + { + // 将队列中最近 count 个点取出 + var points = new Point[count]; + var arr = _annotationDotPositions.ToArray(); + int start = arr.Length - count; + for (int i = 0; i < count; i++) + points[i] = arr[start + i]; + + // 计算中心 + double cx = 0, cy = 0; + for (int i = 0; i < count; i++) + { + cx += points[i].X; + cy += points[i].Y; + } + cx /= count; + cy /= count; + + // 检查每个点是否都在半径内 + double radiusSq = radius * radius; + for (int i = 0; i < count; i++) + { + double dx = points[i].X - cx; + double dy = points[i].Y - cy; + if (dx * dx + dy * dy > radiusSq) return false; + } + return true; + } + + /// + /// 显示批注状态提示。使用屏幕坐标绝对定位,边缘点击时对齐锚点而非居中。 + /// + 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); + + // 使用实际 Border 宽度,确保与 XAML 定义一致 + double hintWidth = (AnnotationDotHintBorder?.ActualWidth > 0) ? AnnotationDotHintBorder.ActualWidth : 380; + double hintHeight = 60; + const double margin = 20; + + var workArea = SystemParameters.WorkArea; + double screenW = workArea.Width; + double screenH = workArea.Height; + + double hintLeft, hintTop; + + // 水平:靠近左边缘时对齐左边缘,靠近右边缘时对齐右边缘 + if (clickScreen.X < workArea.Left + screenW / 2) + { + // 左半屏:提示左边缘对齐锚点 + hintLeft = clickScreen.X; + } + else + { + // 右半屏:提示右边缘对齐锚点 + hintLeft = clickScreen.X - hintWidth; + } + + // 垂直:上半屏放锚点下方,下半屏放锚点上方 + if (clickScreen.Y < workArea.Top + screenH / 2) + { + hintTop = clickScreen.Y + 10; + } + else + { + hintTop = clickScreen.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 (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); + } + + 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)) + { + EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } + }; + fadeOut.Completed += (s, e) => + { + if (AnnotationDotHintPopup != null) + AnnotationDotHintPopup.IsOpen = false; + }; + AnnotationDotHintBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); + } + else + { + if (AnnotationDotHintPopup != null) + AnnotationDotHintPopup.IsOpen = false; + } + } + + private void StopAnnotationDotHintTimer() + { + if (_annotationDotHintTimer != null) + { + _annotationDotHintTimer.Stop(); + _annotationDotHintTimer.Tick -= AnnotationDotHintTimer_Tick; + _annotationDotHintTimer = null; + } + } + + private void AnnotationDotHintTimer_Tick(object sender, EventArgs e) + { + HideAnnotationDotHint(); + } + + private void AnnotationDotHintKeep_Click(object sender, RoutedEventArgs e) + { + HideAnnotationDotHint(); + } + + private void AnnotationDotHintExit_Click(object sender, RoutedEventArgs e) + { + HideAnnotationDotHint(); + CursorIcon_Click(null, null); + } + } +} \ No newline at end of file diff --git a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs index 8074ffc1a..03eda66d0 100644 --- a/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs +++ b/Ink Canvas/MainWindow_cs/MW_SimulatePressure&InkToShape.cs @@ -393,6 +393,16 @@ private void ProcessCommittedStroke(Stroke stroke) LogHelper.WriteLogToFile($"边缘扩展提示判定失败: {ex.Message}", LogHelper.LogType.Warning); } + // 批注状态点提示:检测短墨迹(点击)并在连续点击时提醒用户 + try + { + HandleAnnotationDotAfterStroke(e.Stroke); + } + catch (Exception ex) + { + LogHelper.WriteLogToFile($"批注点提示判定失败: {ex.Message}", LogHelper.LogType.Warning); + } + if (Settings.Canvas.EnableInkFade) { // 获取墨迹的起点和终点 diff --git a/Ink Canvas/Properties/FloatingBarStrings.Designer.cs b/Ink Canvas/Properties/FloatingBarStrings.Designer.cs index 506eb3d9c..22bb62a96 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.Designer.cs +++ b/Ink Canvas/Properties/FloatingBarStrings.Designer.cs @@ -470,6 +470,34 @@ public static string GetString(string key) public static string Canvas_EdgeExpandHint_Settings_AutoHideDelayHint => ResourceManager.GetString(nameof(Canvas_EdgeExpandHint_Settings_AutoHideDelayHint), _resourceCulture); + public static string Canvas_AnnotationDotHint_Text => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Text), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Keep => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Keep), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Exit => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Exit), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_Enable => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_Enable), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_EnableHint => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_EnableHint), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_ClusterRadius => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_ClusterRadius), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_StrokeLength => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_StrokeLength), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_ClickCount => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_ClickCount), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_DisplayDuration => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_DisplayDuration), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_Preview => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_Preview), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_PreviewHint => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_PreviewHint), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_PreviewPlaceholder => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_PreviewPlaceholder), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_PreviewLabel => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_PreviewLabel), _resourceCulture); + + public static string Canvas_AnnotationDotHint_Settings_PreviewTriggered => ResourceManager.GetString(nameof(Canvas_AnnotationDotHint_Settings_PreviewTriggered), _resourceCulture); + public static string IdleMiniBar_StartAnnotate => ResourceManager.GetString(nameof(IdleMiniBar_StartAnnotate), _resourceCulture); public static string IdleMiniBar_Annotate => ResourceManager.GetString(nameof(IdleMiniBar_Annotate), _resourceCulture); diff --git a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx index 34e440468..b3b6d4d80 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx @@ -690,6 +690,48 @@ 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 + + + Keep + + + Exit annotation + + + Annotation status dot hint + + + Show a hint when clicking repeatedly in annotation mode to remind you're in annotation mode + + + Cluster radius + + + Stroke length threshold + + + Click count threshold + + + Display duration + + + Trigger area preview & test + + + Click in the area below to leave dots. The central circle represents the trigger range. Clicking within the circle enough times triggers the hint. + + + Click here to draw dots + + + Click {0} times inside the circle to trigger + + + ✓ Hint triggered + Annotate diff --git a/Ink Canvas/Properties/FloatingBarStrings.resx b/Ink Canvas/Properties/FloatingBarStrings.resx index ff7e68776..772641abc 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.resx @@ -690,6 +690,48 @@ 停止书写后,提示按钮保持显示的时间;再次书写会重新计时,悬停按钮时暂停计时。 + + 当前正处于批注状态 + + + 保持 + + + 退出批注 + + + 批注状态点提示 + + + 批注模式下连续点击画布时,显示提示提醒当前处于批注模式 + + + 连续点击范围大小 + + + 单次触发笔迹长度 + + + 点击次数阈值 + + + 提示显示时长 + + + 触发区域预览与测试 + + + 在下方区域点击可留下点,中央圆圈代表触发范围。圈内连点达到阈值时触发提示。 + + + 点击此区域画点 + + + 圈内连点 {0} 次触發提示 + + + ✓ 已触发提示 + 开始批注 diff --git a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx index 07d14e9b7..2707613fb 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx @@ -663,6 +663,48 @@ 停笔之后提示按钮再待多久就消失;接着写会重新计时,鼠标放上去先不数。 + + 现在是在批注中哦 + + + 保持 + + + 退出批注 + + + 批注状态点提示 + + + 在批注中一直点画布,会弹出来提醒你现在是在批注中哦 + + + 连续点击范围大小 + + + 单次触发笔迹长度 + + + 点击次数阈值 + + + 提示显示时长 + + + 触发区域预览与测试 + + + 在下面点点看,中间那个圈就是触发范围。在圈里点够次数就会触发了。 + + + 在这里点点看 + + + 在圈里点 {0} 下就会触发提示 + + + ✓ 已触发提示 + 写点啥 diff --git a/Ink Canvas/Properties/Strings.cs b/Ink Canvas/Properties/Strings.cs index ad9e7f358..82e680eb1 100644 --- a/Ink Canvas/Properties/Strings.cs +++ b/Ink Canvas/Properties/Strings.cs @@ -442,6 +442,20 @@ public static class Strings dict["Canvas_EdgeExpandHint_Settings_WhiteboardOnlyHint"] = ("FloatingBarStrings", "Canvas_EdgeExpandHint_Settings_WhiteboardOnlyHint"); dict["Canvas_EdgeExpandHint_Settings_AutoHideDelay"] = ("FloatingBarStrings", "Canvas_EdgeExpandHint_Settings_AutoHideDelay"); dict["Canvas_EdgeExpandHint_Settings_AutoHideDelayHint"] = ("FloatingBarStrings", "Canvas_EdgeExpandHint_Settings_AutoHideDelayHint"); + dict["Canvas_AnnotationDotHint_Text"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Text"); + dict["Canvas_AnnotationDotHint_Keep"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Keep"); + dict["Canvas_AnnotationDotHint_Exit"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Exit"); + dict["Canvas_AnnotationDotHint_Settings_Enable"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_Enable"); + dict["Canvas_AnnotationDotHint_Settings_EnableHint"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_EnableHint"); + dict["Canvas_AnnotationDotHint_Settings_ClusterRadius"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_ClusterRadius"); + dict["Canvas_AnnotationDotHint_Settings_StrokeLength"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_StrokeLength"); + dict["Canvas_AnnotationDotHint_Settings_ClickCount"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_ClickCount"); + dict["Canvas_AnnotationDotHint_Settings_DisplayDuration"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_DisplayDuration"); + dict["Canvas_AnnotationDotHint_Settings_Preview"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_Preview"); + dict["Canvas_AnnotationDotHint_Settings_PreviewHint"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_PreviewHint"); + dict["Canvas_AnnotationDotHint_Settings_PreviewPlaceholder"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_PreviewPlaceholder"); + dict["Canvas_AnnotationDotHint_Settings_PreviewLabel"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_PreviewLabel"); + dict["Canvas_AnnotationDotHint_Settings_PreviewTriggered"] = ("FloatingBarStrings", "Canvas_AnnotationDotHint_Settings_PreviewTriggered"); dict["IdleMiniBar_StartAnnotate"] = ("FloatingBarStrings", "IdleMiniBar_StartAnnotate"); dict["IdleMiniBar_Annotate"] = ("FloatingBarStrings", "IdleMiniBar_Annotate"); dict["IdleMiniBar_ClearPage"] = ("FloatingBarStrings", "IdleMiniBar_ClearPage"); diff --git a/Ink Canvas/Resources/Settings.cs b/Ink Canvas/Resources/Settings.cs index a318de5fe..a8187fb63 100644 --- a/Ink Canvas/Resources/Settings.cs +++ b/Ink Canvas/Resources/Settings.cs @@ -550,6 +550,37 @@ public class Canvas [JsonProperty("edgeExpandAutoHideMs")] public double EdgeExpandAutoHideMs { get; set; } = 5000; + /// + /// 是否启用批注状态点提示:在批注模式下连续点击画布时,显示提示提醒用户当前处于批注模式。 + /// 默认开启,帮助教师避免忘记关闭批注。 + /// + [JsonProperty("isEnableAnnotationDotHint")] + public bool IsEnableAnnotationDotHint { get; set; } = true; + + /// + /// 批注点提示的连续点击范围阈值(像素)。当连续点击的位置都在此半径范围内时触发提示。 + /// + [JsonProperty("annotationDotHintClusterRadius")] + public double AnnotationDotHintClusterRadius { get; set; } = 50; + + /// + /// 单次触发笔迹长度阈值(像素)。笔迹包围盒最大边长小于此值视为"点击"而非"书写"。 + /// + [JsonProperty("annotationDotHintStrokeLengthThreshold")] + public double AnnotationDotHintStrokeLengthThreshold { get; set; } = 10; + + /// + /// 触发提示的最小连续点击次数。 + /// + [JsonProperty("annotationDotHintClickCount")] + public int AnnotationDotHintClickCount { get; set; } = 3; + + /// + /// 提示显示时长(秒)。超过后自动隐藏。 + /// + [JsonProperty("annotationDotHintDisplayDurationSeconds")] + public double AnnotationDotHintDisplayDurationSeconds { get; set; } = 3; + } public enum OptionalOperation diff --git a/Ink Canvas/Windows/OobePresetWindow.xaml.cs b/Ink Canvas/Windows/OobePresetWindow.xaml.cs index 0d7abec1c..c653ac5e1 100644 --- a/Ink Canvas/Windows/OobePresetWindow.xaml.cs +++ b/Ink Canvas/Windows/OobePresetWindow.xaml.cs @@ -163,6 +163,7 @@ public static void ApplyStandard(Settings settings) settings.Canvas.DisablePressure = false; settings.Canvas.HideStrokeWhenSelecting = false; settings.Canvas.EnablePalmEraser = false; + settings.Canvas.IsEnableAnnotationDotHint = true; // 墨迹纠正 settings.InkToShape.IsInkToShapeEnabled = true; @@ -237,6 +238,7 @@ public static void ApplyLite(Settings settings) settings.Canvas.DisablePressure = false; settings.Canvas.HideStrokeWhenSelecting = true; settings.Canvas.EnablePalmEraser = false; + settings.Canvas.IsEnableAnnotationDotHint = true; // 墨迹纠正 settings.InkToShape.IsInkToShapeEnabled = false; diff --git a/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml b/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml index c6820f13b..9f60d3218 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml +++ b/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml @@ -189,6 +189,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml.cs b/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml.cs index 812f3d328..2d56a2a08 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml.cs +++ b/Ink Canvas/Windows/SettingsViews/Pages/CanvasPage.xaml.cs @@ -3,9 +3,14 @@ using Ink_Canvas.Windows.SettingsViews.Helpers; using Microsoft.Win32; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Windows; using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Animation; +using System.Windows.Shapes; +using System.Windows.Threading; namespace Ink_Canvas.Windows.SettingsViews.Pages { @@ -54,6 +59,19 @@ private void CanvasPage_Loaded(object sender, RoutedEventArgs e) EdgeExpandHintAutoHideDelaySlider.Value = settings.Canvas.EdgeExpandAutoHideMs / 1000.0; EdgeExpandHintAutoHideDelayText.Text = string.Format(CanvasStrings.Canvas_SecondsFormat, EdgeExpandHintAutoHideDelaySlider.Value); + + // 加载批注状态点提示设置 + AnnotationDotHintClusterRadiusSlider.Value = settings.Canvas.AnnotationDotHintClusterRadius; + AnnotationDotHintClusterRadiusText.Text = $"{settings.Canvas.AnnotationDotHintClusterRadius:F0} px"; + AnnotationDotHintStrokeLengthSlider.Value = settings.Canvas.AnnotationDotHintStrokeLengthThreshold; + AnnotationDotHintStrokeLengthText.Text = $"{settings.Canvas.AnnotationDotHintStrokeLengthThreshold:F0} px"; + AnnotationDotHintClickCountSlider.Value = settings.Canvas.AnnotationDotHintClickCount; + AnnotationDotHintClickCountText.Text = $"{settings.Canvas.AnnotationDotHintClickCount} 次"; + AnnotationDotHintDisplayDurationSlider.Value = settings.Canvas.AnnotationDotHintDisplayDurationSeconds; + AnnotationDotHintDisplayDurationText.Text = $"{settings.Canvas.AnnotationDotHintDisplayDurationSeconds:F0} 秒"; + + // 更新触发区域预览 + UpdateAnnotationDotHintPreview(settings.Canvas.AnnotationDotHintClusterRadius); } } catch (Exception ex) @@ -166,6 +184,223 @@ private void EdgeExpandHintAutoHideDelaySlider_ValueChanged(object sender, Route SettingsManager.SaveSettingsToFile(); } + private void AnnotationDotHintClusterRadiusSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (!_isLoaded) return; + double value = Math.Round(e.NewValue); + AnnotationDotHintClusterRadiusText.Text = $"{value:F0} px"; + SettingsManager.Settings.Canvas.AnnotationDotHintClusterRadius = value; + UpdateAnnotationDotHintPreview(value); + SettingsManager.SaveSettingsToFile(); + } + + private void AnnotationDotHintStrokeLengthSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (!_isLoaded) return; + double value = Math.Round(e.NewValue); + AnnotationDotHintStrokeLengthText.Text = $"{value:F0} px"; + SettingsManager.Settings.Canvas.AnnotationDotHintStrokeLengthThreshold = value; + SettingsManager.SaveSettingsToFile(); + } + + private void AnnotationDotHintClickCountSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (!_isLoaded) return; + int value = (int)Math.Round(e.NewValue); + AnnotationDotHintClickCountText.Text = $"{value} 次"; + SettingsManager.Settings.Canvas.AnnotationDotHintClickCount = value; + UpdateAnnotationDotHintPreview(SettingsManager.Settings.Canvas.AnnotationDotHintClusterRadius); + SettingsManager.SaveSettingsToFile(); + } + + private void AnnotationDotHintDisplayDurationSlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (!_isLoaded) return; + double value = Math.Round(e.NewValue); + AnnotationDotHintDisplayDurationText.Text = $"{value:F0} 秒"; + SettingsManager.Settings.Canvas.AnnotationDotHintDisplayDurationSeconds = value; + SettingsManager.SaveSettingsToFile(); + } + + // 预览板:点击画点追踪 + private readonly Queue _previewDotQueue = new Queue(); + private System.Windows.Point _previewMouseDownPoint; + private bool _previewHintShown; + + private void UpdateAnnotationDotHintPreview(double radius) + { + if (AnnotationDotHintPreviewZone == null) return; + + // 预览圆圈大小:半径映射到圆圈直径(最小 16px,最大 120px) + double circleSize = Math.Min(radius * 2, 120); + circleSize = Math.Max(circleSize, 16); + AnnotationDotHintPreviewZone.Width = circleSize; + AnnotationDotHintPreviewZone.Height = circleSize; + + if (AnnotationDotHintPreviewLabel != null) + { + int clickCount = SettingsManager.Settings.Canvas.AnnotationDotHintClickCount; + AnnotationDotHintPreviewLabel.Text = string.Format(Properties.FloatingBarStrings.Canvas_AnnotationDotHint_Settings_PreviewLabel, clickCount); + } + } + + private void AnnotationDotHintPreviewBoard_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + var board = sender as System.Windows.UIElement; + if (board == null) return; + _previewMouseDownPoint = e.GetPosition(board); + } + + private void AnnotationDotHintPreviewBoard_MouseLeftButtonUp(object sender, System.Windows.Input.MouseButtonEventArgs e) + { + var board = sender as System.Windows.UIElement; + if (board == null) return; + + var upPoint = e.GetPosition(board); + double dx = upPoint.X - _previewMouseDownPoint.X; + double dy = upPoint.Y - _previewMouseDownPoint.Y; + double dist = Math.Sqrt(dx * dx + dy * dy); + + // 视为点击(移动 < 5px) + if (dist > 5) return; + + DrawPreviewDot(_previewMouseDownPoint); + TrackPreviewDot(_previewMouseDownPoint); + } + + private void DrawPreviewDot(System.Windows.Point position) + { + if (AnnotationDotHintPreviewCanvas == null) return; + + var dot = new System.Windows.Shapes.Ellipse + { + Width = 6, + Height = 6, + Fill = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromArgb(200, 37, 99, 235)), + IsHitTestVisible = false + }; + System.Windows.Controls.Canvas.SetLeft(dot, position.X - 3); + System.Windows.Controls.Canvas.SetTop(dot, position.Y - 3); + AnnotationDotHintPreviewCanvas.Children.Add(dot); + + // 10 秒后渐隐消失 + var timer = new System.Windows.Threading.DispatcherTimer + { + Interval = TimeSpan.FromSeconds(10) + }; + timer.Tick += (s, args) => + { + timer.Stop(); + var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(500)); + fadeOut.Completed += (_, _) => + { + AnnotationDotHintPreviewCanvas.Children.Remove(dot); + }; + dot.BeginAnimation(System.Windows.UIElement.OpacityProperty, fadeOut); + }; + timer.Start(); + } + + private void TrackPreviewDot(System.Windows.Point position) + { + double boardW = AnnotationDotHintPreviewBoard?.ActualWidth ?? 300; + double boardH = AnnotationDotHintPreviewBoard?.ActualHeight ?? 180; + if (double.IsNaN(boardW) || boardW <= 0) boardW = 300; + if (double.IsNaN(boardH) || boardH <= 0) boardH = 180; + + // 判断点是否在圆圈内 + double cx = boardW / 2; + double cy = boardH / 2; + double circleRadius = (AnnotationDotHintPreviewZone?.Width ?? 60) / 2; + + double distFromCenter = Math.Sqrt((position.X - cx) * (position.X - cx) + (position.Y - cy) * (position.Y - cy)); + if (distFromCenter > circleRadius) return; // 圈外点击不追踪 + + _previewDotQueue.Enqueue(position); + while (_previewDotQueue.Count > 10) + _previewDotQueue.Dequeue(); + + int clickCount = SettingsManager.Settings.Canvas.AnnotationDotHintClickCount; + if (_previewDotQueue.Count < clickCount) return; + + // 检查最近 clickCount 个点是否都在圆圈内 + var arr = _previewDotQueue.ToArray(); + int start = arr.Length - clickCount; + bool allInCircle = true; + for (int i = start; i < arr.Length; i++) + { + double d = Math.Sqrt((arr[i].X - cx) * (arr[i].X - cx) + (arr[i].Y - cy) * (arr[i].Y - cy)); + if (d > circleRadius) + { + allInCircle = false; + break; + } + } + + if (allInCircle && !_previewHintShown) + { + _previewHintShown = true; + ShowPreviewHintFlash(); + } + } + + private void ShowPreviewHintFlash() + { + if (AnnotationDotHintPreviewZone == null) return; + + // 圆圈闪烁表示触发(绿色) + var flash = new System.Windows.Media.Animation.ColorAnimation + { + From = System.Windows.Media.Color.FromArgb(16, 34, 197, 94), + To = System.Windows.Media.Color.FromArgb(160, 34, 197, 94), + Duration = TimeSpan.FromMilliseconds(200), + AutoReverse = true, + RepeatBehavior = new System.Windows.Media.Animation.RepeatBehavior(3) + }; + var brush = AnnotationDotHintPreviewZone.Fill as System.Windows.Media.SolidColorBrush; + if (brush == null) + { + brush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromArgb(16, 34, 197, 94)); + AnnotationDotHintPreviewZone.Fill = brush; + } + brush.BeginAnimation(System.Windows.Media.SolidColorBrush.ColorProperty, flash); + + // 圆边框也变绿 + AnnotationDotHintPreviewZone.Stroke = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(160, 34, 197, 94)); + + // 在提示文字中显示"已触发" + if (AnnotationDotHintPreviewLabel != null) + { + var origText = AnnotationDotHintPreviewLabel.Text; + AnnotationDotHintPreviewLabel.Text = Properties.FloatingBarStrings.Canvas_AnnotationDotHint_Settings_PreviewTriggered; + AnnotationDotHintPreviewLabel.Foreground = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(200, 37, 99, 235)); + + // 3 秒后恢复 + var displaySeconds = SettingsManager.Settings.Canvas.AnnotationDotHintDisplayDurationSeconds; + var timer = new System.Windows.Threading.DispatcherTimer + { + Interval = TimeSpan.FromSeconds(displaySeconds) + }; + timer.Tick += (s, args) => + { + timer.Stop(); + _previewHintShown = false; + _previewDotQueue.Clear(); + AnnotationDotHintPreviewLabel.Text = origText; + AnnotationDotHintPreviewLabel.Foreground = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(96, 0, 0, 0)); + // 恢复圆圈原始颜色 + AnnotationDotHintPreviewZone.Fill = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(16, 37, 99, 235)); + AnnotationDotHintPreviewZone.Stroke = new System.Windows.Media.SolidColorBrush( + System.Windows.Media.Color.FromArgb(128, 37, 99, 235)); + }; + timer.Start(); + } + } + private void UpdateCustomPenCursorPathVisibility() { if (CardCustomPenCursorPath == null) return; From a85fd2425f0d3c7a6310e06a9201065703efbd92 Mon Sep 17 00:00:00 2001 From: PYLXU Date: Thu, 10 Sep 2026 23:03:15 +0800 Subject: [PATCH 2/7] =?UTF-8?q?improve:=20=E6=89=B9=E6=B3=A8=E7=82=B9?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA=201.=20=E5=8A=A0=E5=BC=BA?= =?UTF-8?q?=E9=AB=98=E7=BC=A9=E6=94=BE=E5=B1=8F=E5=B9=95=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=8B=E5=BC=B9=E6=A1=86=E5=AE=9A=E4=BD=8D=202.=20=E7=BC=A9?= =?UTF-8?q?=E5=B0=8F=E7=82=B9=E5=87=BB=E5=A2=A8=E8=BF=B9=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=EF=BC=8C=E5=8A=A0=E5=85=A5=E5=9C=86=E5=BD=A2=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=203.=20=E4=BC=98=E5=8C=96=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=9A=E6=89=B9=E6=B3=A8=E5=90=8E30s?= =?UTF-8?q?=E5=86=85=E6=AC=A1=E6=95=B0=E5=86=85=E9=87=8D=E5=A4=8D=E4=BB=85?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=9C=86=E5=BD=A2=E5=9B=BE=E6=A0=87=EF=BC=8C?= =?UTF-8?q?30s=E5=90=8E=E6=AF=8F=E6=AC=A1=E7=82=B9=E5=87=BB=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=9B=BE=E6=A0=87=EF=BC=8C=E5=B0=8F=E5=8C=BA=E5=9F=9F?= =?UTF-8?q?=E5=86=85=E6=AC=A1=E6=95=B0=E5=86=85=E9=87=8D=E5=A4=8D=E5=86=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/MainWindow.xaml | 31 +++- .../MainWindow_cs/MW_AnnotationDotHint.cs | 151 +++++++++++++----- .../Properties/FloatingBarStrings.en-US.resx | 2 +- Ink Canvas/Properties/FloatingBarStrings.resx | 2 +- .../Properties/FloatingBarStrings.zh-ME.resx | 2 +- 5 files changed, 143 insertions(+), 45 deletions(-) diff --git a/Ink Canvas/MainWindow.xaml b/Ink Canvas/MainWindow.xaml index 2fa18b60f..548c7bb45 100644 --- a/Ink Canvas/MainWindow.xaml +++ b/Ink Canvas/MainWindow.xaml @@ -339,9 +339,11 @@ + 提醒用户当前处于批注模式而非鼠标模式。 + Placement=RelativePoint 相对 inkCanvas,避免 DPI 缩放导致的位置偏差。 --> + + + + + + + _annotationDotPositions = new Queue(); /// 最近点击位置队列的最大容量。 private const int AnnotationDotMaxQueueSize = 10; + /// 最近一次「长笔迹」(真实书写)的提交时间;用于 30 秒未书写的空闲门控。 + private DateTime? _lastLongStrokeTime; + /// 距离上次真实书写多久后,功能按「空闲」状态触发(秒)。 + private const double AnnotationDotIdleSeconds = 30; /// 提示自动隐藏计时器。 private DispatcherTimer _annotationDotHintTimer; /// 提示是否正在显示。 @@ -50,16 +54,32 @@ internal void HandleAnnotationDotAfterStroke(Stroke stroke) double maxDim = Math.Max(bounds.Width, bounds.Height); double strokeThreshold = Settings.Canvas.AnnotationDotHintStrokeLengthThreshold; - // 仅对极短墨迹(点击)进行追踪 - if (maxDim > strokeThreshold) return; + // 长笔迹 = 真实书写:重置 30 秒空闲计时器,并清空点击轨迹。 + if (maxDim > strokeThreshold) + { + _lastLongStrokeTime = DateTime.Now; + _annotationDotPositions.Clear(); + return; + } var center = new Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2); if (double.IsNaN(center.X) || double.IsNaN(center.Y)) return; - // 对单点 / 极短墨迹补画可见圆点(不影响原始墨迹管线) + // 对单点 / 极短墨迹补画可见圆点(按笔的实际粗细,不做加粗) EnsureDotVisible(stroke, center); - TrackAnnotationDotPosition(center); + if (IsAnnotationIdle()) + { + // 30 秒未书写:每次点击都显示指示小圆点 + ShowAnnotationDotIndicator(center); + // 短时内连续点击达到阈值 → 显示「批注中」提示 + TrackAnnotationDotPosition(center, showHint: true); + } + else + { + // 30 秒内有书写:仅连续点击达到阈值时,在末次点击显示一次指示小圆点(不显示批注提示) + TrackAnnotationDotPosition(center, showHint: false); + } } catch (Exception ex) { @@ -90,8 +110,7 @@ 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); + // 按笔的实际粗细渲染,不做加粗处理。 // 构建一个由 8 个点组成的微小圆(半径 2px),确保视觉可见 var points = new StylusPointCollection(); @@ -122,11 +141,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 +163,16 @@ private void TrackAnnotationDotPosition(Point position) // 而非队列中所有点,避免队列中混入旧区域点导致误判 if (IsRecentClusterWithinRadius(clickCount, clusterRadius)) { - ShowAnnotationDotHint(position); + if (showHint) + { + ShowAnnotationDotHint(position); + } + else + { + // 30 秒内连续点击达到阈值:末次点击显示一次指示小圆点,但不显示「批注中」提示 + ShowAnnotationDotIndicator(position); + } + _annotationDotPositions.Clear(); } } @@ -181,7 +210,9 @@ private bool IsRecentClusterWithinRadius(int count, double radius) } /// - /// 显示批注状态提示。使用屏幕坐标绝对定位,边缘点击时对齐锚点而非居中。 + /// 显示批注状态提示。Popup 使用 Placement=RelativePoint 且 PlacementTarget=inkCanvas, + /// 偏移量直接使用画布(逻辑)坐标,避免 DPI 缩放下的屏幕坐标换算偏差。 + /// 靠近画布边缘时对齐锚点而非居中。 /// private void ShowAnnotationDotHint(Point anchor) { @@ -193,51 +224,47 @@ private void ShowAnnotationDotHint(Point anchor) var popup = AnnotationDotHintPopup; if (popup == null) return; - // 直接将画布坐标转为屏幕坐标(考虑 RenderTransform 等) - var clickScreen = inkCanvas.PointToScreen(anchor); - - // 使用实际 Border 宽度,确保与 XAML 定义一致 - double hintWidth = (AnnotationDotHintBorder?.ActualWidth > 0) ? AnnotationDotHintBorder.ActualWidth : 380; - double hintHeight = 60; + // 使用实际 Border 尺寸,与 XAML 定义一致;未布局时回退到 XAML 固定值 + double hintWidth = (AnnotationDotHintBorder?.ActualWidth > 0) ? AnnotationDotHintBorder.ActualWidth : 295; + double hintHeight = (AnnotationDotHintBorder?.ActualHeight > 0) ? AnnotationDotHintBorder.ActualHeight : 60; 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; + // 钳制到画布可见区域内 + 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; popup.HorizontalOffset = hintLeft; popup.VerticalOffset = hintTop; @@ -263,6 +290,50 @@ private void ShowAnnotationDotHint(Point anchor) _annotationDotHintTimer.Start(); } + /// + /// 当前是否满足「30 秒未在白板内书写」的空闲条件。 + /// 从未书写过( 为空)时视为空闲。 + /// + private bool IsAnnotationIdle() + { + return !_lastLongStrokeTime.HasValue + || (DateTime.Now - _lastLongStrokeTime.Value).TotalSeconds >= AnnotationDotIdleSeconds; + } + + /// + /// 显示「处于批注状态」的指示小圆点:带圆角容器,渐显出现、短暂停留后渐隐消失。 + /// Popup 使用 Placement=RelativePoint 且 PlacementTarget=inkCanvas,坐标直接使用画布(逻辑)坐标。 + /// + private void ShowAnnotationDotIndicator(Point anchor) + { + 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; + + // 渐显 → 短暂停留 → 渐隐 + 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); + } + private void HideAnnotationDotHint() { _annotationDotHintVisible = false; diff --git a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx index b3b6d4d80..9c2eb07df 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx @@ -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..3925b82e8 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.resx @@ -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..839204f87 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx @@ -676,7 +676,7 @@ 批注状态点提示 - 在批注中一直点画布,会弹出来提醒你现在是在批注中哦 + 批注中 30 秒没写字时,点画布会闪出状态小圆点;短时间内连点到次数,才会跳出「正在批注」的提示。写长一点的笔迹会重新计时。 连续点击范围大小 From 717947a8a2d125bb05ddba1631e8a59cb56afbcf Mon Sep 17 00:00:00 2001 From: PYLXU Date: Thu, 10 Sep 2026 23:15:37 +0800 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20=E6=89=B9=E6=B3=A8=E7=82=B9?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E7=9A=84=E5=BC=80=E5=85=B3=E8=AE=BE=E4=B8=BA?= =?UTF-8?q?=E5=85=B3=E9=97=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/Windows/OobePresetWindow.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Ink Canvas/Windows/OobePresetWindow.xaml.cs b/Ink Canvas/Windows/OobePresetWindow.xaml.cs index 5f8ec6af1..15f866f98 100644 --- a/Ink Canvas/Windows/OobePresetWindow.xaml.cs +++ b/Ink Canvas/Windows/OobePresetWindow.xaml.cs @@ -163,7 +163,7 @@ public static void ApplyStandard(Settings settings) settings.Canvas.DisablePressure = false; settings.Canvas.HideStrokeWhenSelecting = false; settings.Canvas.EnablePalmEraser = false; - settings.Canvas.IsEnableAnnotationDotHint = true; + settings.Canvas.IsEnableAnnotationDotHint = false; // 墨迹纠正 settings.InkToShape.IsInkToShapeEnabled = true; From 10ee6809a7d8b57f0e0ddec59610cf70c3b2823d Mon Sep 17 00:00:00 2001 From: PYLXU Date: Sat, 12 Sep 2026 00:02:53 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=BC=AB?= =?UTF-8?q?=E6=B8=B8=E7=8A=B6=E6=80=81=E4=B8=8B=E7=9A=84=E5=8F=8C=E6=8C=87?= =?UTF-8?q?=E7=BC=A9=E6=94=BE=E5=92=8C=E6=97=8B=E8=BD=AC=20fix:=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=BC=AB=E6=B8=B8=E7=9A=84=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=8C=89=E9=92=AE=E9=A6=96=E6=AC=A1=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=8F=AF=E8=83=BD=E5=A4=B1=E6=95=88=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Popups/BoardRoamingPopupContent.xaml | 31 ++ .../Popups/BoardRoamingPopupContent.xaml.cs | 3 + Ink Canvas/MainWindow.xaml.cs | 19 +- Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs | 352 ++++++++++++++++-- .../MainWindow_cs/MW_FloatingBarIcons.cs | 2 +- Ink Canvas/MainWindow_cs/MW_TouchEvents.cs | 12 +- Ink Canvas/Resources/Settings.cs | 5 + 7 files changed, 380 insertions(+), 44 deletions(-) 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/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_BoardRoaming.cs b/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs index 054ef17b4..618941900 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,251 @@ private void EndBoardRoaming() inkCanvas.Cursor = IsBoardRoamingMode ? Cursors.Hand : Cursors.Arrow; } + /// + /// 强制结束漫游交互(单指拖动/双指手势),用于切换工具等场景。 + /// + private void CancelBoardRoamingInteraction() + { + ResetBoardRoamingGestureState(); + 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; @@ -144,12 +400,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 +431,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 +619,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 +629,9 @@ private void BeginBoardRoamingPopupDrag() private void EndBoardRoamingPopupDrag() { - if (!_isBoardRoamingPointerDown) return; + if (!_isBoardRoamingPopupDragActive) return; - _isBoardRoamingPointerDown = false; + _isBoardRoamingPopupDragActive = false; CommitBoardRoamingHistory(); CompletePluginCanvasViewportTransform(); RefreshBoardRoamingPopup(); @@ -328,28 +641,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/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; } } // 更新通道枚举 From e64f301fe0e5f8522a40a40748a21c056afbae8b Mon Sep 17 00:00:00 2001 From: PYLXU Date: Sat, 12 Sep 2026 20:02:25 +0800 Subject: [PATCH 5/7] =?UTF-8?q?improve:=20=E6=89=B9=E6=B3=A8=E7=82=B9?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA=EF=BC=88=E7=AC=94=E8=BF=B9?= =?UTF-8?q?=E7=B2=97=E7=BB=86=E5=90=8C=E6=AD=A5=E3=80=81=E6=9B=B4=E6=8D=A2?= =?UTF-8?q?MessageBoxHelper=E3=80=81=E4=BF=AE=E5=A4=8D=E7=B2=97=E7=AC=94?= =?UTF-8?q?=E8=BF=B9=E5=BC=82=E5=B8=B8=EF=BC=89=20change:=20MessageBoxHelp?= =?UTF-8?q?er=20=E6=8E=A5=E5=8F=A3=E5=8A=A0=E5=85=A5=E5=9D=90=E6=A0=87?= =?UTF-8?q?=E4=BC=A0=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/Helpers/MessageBoxHelper.cs | 263 ++++++++++++++++++ Ink Canvas/MainWindow.xaml | 81 ------ .../MainWindow_cs/MW_AnnotationDotHint.cs | 183 ++++++------ .../Properties/FloatingBarStrings.en-US.resx | 2 +- Ink Canvas/Properties/FloatingBarStrings.resx | 2 +- .../Properties/FloatingBarStrings.zh-ME.resx | 2 +- 6 files changed, 356 insertions(+), 177 deletions(-) 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 548c7bb45..f59fdd7ae 100644 --- a/Ink Canvas/MainWindow.xaml +++ b/Ink Canvas/MainWindow.xaml @@ -338,87 +338,6 @@ - - - - - - - - - - - - - - 距离上次真实书写多久后,功能按「空闲」状态触发(秒)。 private const double AnnotationDotIdleSeconds = 30; - /// 提示自动隐藏计时器。 - private DispatcherTimer _annotationDotHintTimer; + /// 上一次点击笔迹处理时的空闲状态;用于检测刚跨越 30 秒空闲边界的时刻。 + private bool _lastClickWasIdle; /// 提示是否正在显示。 private bool _annotationDotHintVisible; + /// 当前显示中的「批注中」提示弹窗(iNKORE MessageBox,非模态),用于重复触发时判断与外部关闭。 + private iNKORE.UI.WPF.Modern.Controls.MessageBox _annotationDotHintBox; /// /// 在 后调用,检测短墨迹(点击)并判断是否需要显示提示。 @@ -50,8 +52,12 @@ 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; // 长笔迹 = 真实书写:重置 30 秒空闲计时器,并清空点击轨迹。 @@ -59,16 +65,25 @@ internal void HandleAnnotationDotAfterStroke(Stroke stroke) { _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); - if (IsAnnotationIdle()) + bool isIdle = IsAnnotationIdle(); + + // 刚跨过 30 秒空闲边界时清空点击轨迹:边界前按「非空闲」规则记录的点击 + // 不参与空闲期的「批注中」提示计数,否则空闲后的首次点击就可能误弹提示。 + if (isIdle && !_lastClickWasIdle) + _annotationDotPositions.Clear(); + _lastClickWasIdle = isIdle; + + if (isIdle) { // 30 秒未书写:每次点击都显示指示小圆点 ShowAnnotationDotIndicator(center); @@ -87,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); + } + /// /// 对点击产生的极短墨迹补充可见圆点。 /// 使用 避免触发 递归。 @@ -100,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; @@ -110,15 +146,18 @@ private void EnsureDotVisible(Stroke originalStroke, Point center) ?? (inkCanvas.DefaultDrawingAttributes?.Clone() ?? new DrawingAttributes { Color = Colors.Black, Width = 2, Height = 2 }); - // 按笔的实际粗细渲染,不做加粗处理。 + // 墨迹的渲染直径 = 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 }; @@ -210,23 +249,20 @@ private bool IsRecentClusterWithinRadius(int count, double radius) } /// - /// 显示批注状态提示。Popup 使用 Placement=RelativePoint 且 PlacementTarget=inkCanvas, - /// 偏移量直接使用画布(逻辑)坐标,避免 DPI 缩放下的屏幕坐标换算偏差。 - /// 靠近画布边缘时对齐锚点而非居中。 + /// 显示「批注中」提示弹框:使用 iNKORE MessageBox(非模态、不抢焦点、自动关闭)替代原 Popup。 + /// 定位策略与原 Popup 版本一致:先在画布坐标系内按锚点所在半屏对齐并钳制到画布内, + /// 再通过 换算为屏幕 DIP 坐标传给弹窗。 /// private void ShowAnnotationDotHint(Point anchor) { _annotationDotPositions.Clear(); if (_annotationDotHintVisible) return; - _annotationDotHintVisible = true; - - var popup = AnnotationDotHintPopup; - if (popup == null) return; + if (inkCanvas == null) return; - // 使用实际 Border 尺寸,与 XAML 定义一致;未布局时回退到 XAML 固定值 - double hintWidth = (AnnotationDotHintBorder?.ActualWidth > 0) ? AnnotationDotHintBorder.ActualWidth : 295; - double hintHeight = (AnnotationDotHintBorder?.ActualHeight > 0) ? AnnotationDotHintBorder.ActualHeight : 60; + // MessageBox 按内容自适应,此处为对齐与钳制用的估算尺寸 + const double hintWidth = 360; + const double hintHeight = 170; const double margin = 20; double canvasW = inkCanvas.ActualWidth; @@ -266,28 +302,39 @@ private void ShowAnnotationDotHint(Point anchor) if (hintTop + hintHeight > canvasH - margin) hintTop = canvasH - hintHeight - margin; - popup.HorizontalOffset = hintLeft; - popup.VerticalOffset = hintTop; - popup.IsOpen = true; + // 画布坐标 → 屏幕 DIP 坐标(DPI 安全换算),失败则放弃本次提示 + var screenPoint = MessageBoxHelper.TranslateToScreen(inkCanvas, new Point(hintLeft, hintTop)); + if (double.IsNaN(screenPoint.X) || double.IsNaN(screenPoint.Y)) return; - if (AnnotationDotHintBorder != null) - { - AnnotationDotHintBorder.Opacity = 0; - var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)) + double displaySeconds = Settings?.Canvas?.AnnotationDotHintDisplayDurationSeconds ?? 3; + + 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.EaseOut } - }; - AnnotationDotHintBorder.BeginAnimation(UIElement.OpacityProperty, fadeIn); - } + _annotationDotHintVisible = false; + _annotationDotHintBox = null; + if (result == MessageBoxResult.No) + { + // 「退出批注」:退出批注模式 + CursorIcon_Click(null, null); + } + }, + autoCloseSeconds: displaySeconds > 0 ? displaySeconds : (double?)null, + configure: b => + { + b.YesButtonText = FloatingBarStrings.Canvas_AnnotationDotHint_Keep; + b.NoButtonText = FloatingBarStrings.Canvas_AnnotationDotHint_Exit; + }); - StopAnnotationDotHintTimer(); - double displaySeconds = Settings?.Canvas?.AnnotationDotHintDisplayDurationSeconds ?? 3; - _annotationDotHintTimer = new DispatcherTimer(DispatcherPriority.Normal, Dispatcher) - { - Interval = TimeSpan.FromSeconds(displaySeconds) - }; - _annotationDotHintTimer.Tick += AnnotationDotHintTimer_Tick; - _annotationDotHintTimer.Start(); + if (box == null) return; + _annotationDotHintVisible = true; + _annotationDotHintBox = box; } /// @@ -334,55 +381,5 @@ private void ShowAnnotationDotIndicator(Point anchor) border.BeginAnimation(UIElement.OpacityProperty, anim); } - private void HideAnnotationDotHint() - { - _annotationDotHintVisible = false; - StopAnnotationDotHintTimer(); - - if (AnnotationDotHintBorder != null) - { - var fadeOut = new DoubleAnimation(1, 0, TimeSpan.FromMilliseconds(200)) - { - EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseIn } - }; - fadeOut.Completed += (s, e) => - { - if (AnnotationDotHintPopup != null) - AnnotationDotHintPopup.IsOpen = false; - }; - AnnotationDotHintBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut); - } - else - { - if (AnnotationDotHintPopup != null) - AnnotationDotHintPopup.IsOpen = false; - } - } - - private void StopAnnotationDotHintTimer() - { - if (_annotationDotHintTimer != null) - { - _annotationDotHintTimer.Stop(); - _annotationDotHintTimer.Tick -= AnnotationDotHintTimer_Tick; - _annotationDotHintTimer = null; - } - } - - private void AnnotationDotHintTimer_Tick(object sender, EventArgs e) - { - HideAnnotationDotHint(); - } - - private void AnnotationDotHintKeep_Click(object sender, RoutedEventArgs e) - { - HideAnnotationDotHint(); - } - - private void AnnotationDotHintExit_Click(object sender, RoutedEventArgs e) - { - HideAnnotationDotHint(); - CursorIcon_Click(null, null); - } } } \ No newline at end of file diff --git a/Ink Canvas/Properties/FloatingBarStrings.en-US.resx b/Ink Canvas/Properties/FloatingBarStrings.en-US.resx index 9c2eb07df..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 diff --git a/Ink Canvas/Properties/FloatingBarStrings.resx b/Ink Canvas/Properties/FloatingBarStrings.resx index 3925b82e8..9b9e8de90 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.resx @@ -691,7 +691,7 @@ 停止书写后,提示按钮保持显示的时间;再次书写会重新计时,悬停按钮时暂停计时。 - 当前正处于批注状态 + 当前正处于屏幕批注 如需与其它应用交互,需要先退出批注状态 保持 diff --git a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx index 839204f87..248321b21 100644 --- a/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx +++ b/Ink Canvas/Properties/FloatingBarStrings.zh-ME.resx @@ -664,7 +664,7 @@ 停笔之后提示按钮再待多久就消失;接着写会重新计时,鼠标放上去先不数。 - 现在是在批注中哦 + 现在是在屏幕批注中哦 要与其它应用交互,需要先退出批注状态 保持 From 9a9e7686433dfcb8e72d3640d4a5bfda167a81ae Mon Sep 17 00:00:00 2001 From: PYLXU Date: Sun, 13 Sep 2026 10:05:08 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E5=9F=BA=E4=BA=8EURI=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E7=9A=84=E5=88=9B=E5=BB=BA=E5=BF=AB=E6=8D=B7=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E5=8A=9F=E8=83=BD=E5=92=8C=E8=AE=BE=E7=BD=AE=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8A=A0=E5=85=A5=E5=AD=90=E9=A1=B9=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=20=E5=BC=95=E5=85=A5=E4=BA=86UriSchemeShortcutHelper=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/MainWindow_cs/MW_UriHandler.cs | 21 +- .../Properties/StartupStrings.Designer.cs | 18 + .../Properties/StartupStrings.en-US.resx | 27 ++ Ink Canvas/Properties/StartupStrings.resx | 27 ++ .../Properties/StartupStrings.zh-ME.resx | 27 ++ .../Helpers/UriSchemeShortcutHelper.cs | 309 ++++++++++++++++++ .../SettingsViews/Pages/StartupPage.xaml | 33 +- .../SettingsViews/Pages/StartupPage.xaml.cs | 34 ++ .../SettingsViews/SettingsWindow.xaml.cs | 12 + 9 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 Ink Canvas/Windows/SettingsViews/Helpers/UriSchemeShortcutHelper.cs 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/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/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; + + bool success = 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); + } + 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) From 9bff6b0f88e06b7f0a3a6163e706b2200098b25f Mon Sep 17 00:00:00 2001 From: PYLXU Date: Sun, 13 Sep 2026 16:47:27 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E6=BC=AB=E6=B8=B8=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E5=BF=AB=E6=8D=B7=E6=96=B9=E5=BC=8F=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs | 22 ++++++++++- .../SettingsViews/Pages/StartupPage.xaml.cs | 38 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs b/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs index 618941900..49353c908 100644 --- a/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs +++ b/Ink Canvas/MainWindow_cs/MW_BoardRoaming.cs @@ -127,8 +127,15 @@ private void EndBoardRoaming() /// private void CancelBoardRoamingInteraction() { + // 双指手势进行中时 _isBoardRoamingPointerDown 已被清除 + //(见 BeginBoardRoamingTwoFingerGesture),直接调 EndBoardRoaming 会 + // 提前返回,导致手势历史不提交、CompletePluginCanvasViewportTransform 被跳过。 + var wasTwoFingerGesture = _isBoardRoamingTwoFingerGesture; ResetBoardRoamingGestureState(); - EndBoardRoaming(); + if (wasTwoFingerGesture) + EndBoardRoamingTwoFingerGesture(); + else + EndBoardRoaming(); } private void ResetBoardRoamingGestureState() @@ -388,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(); diff --git a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml.cs b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml.cs index 2c7682319..64cf86464 100644 --- a/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml.cs +++ b/Ink Canvas/Windows/SettingsViews/Pages/StartupPage.xaml.cs @@ -51,7 +51,10 @@ private void BtnShortcut_Click(object sender, RoutedEventArgs e) if (!_isLoaded) return; if (!(sender is Button button) || !(button.Tag is string feature)) return; - bool success = UriSchemeShortcutHelper.CreateDesktopShortcut(feature); + // 快捷方式以 icc:// 为目标,协议未启用时 HandleUriCommand 会拒绝全部请求, + // 因此先确保外部协议已注册并启用,再创建快捷方式。 + bool success = EnsureUriSchemeEnabledForShortcut() && + UriSchemeShortcutHelper.CreateDesktopShortcut(feature); var mainWindow = Application.Current?.Windows.OfType().FirstOrDefault(); if (mainWindow == null) return; @@ -60,6 +63,39 @@ private void BtnShortcut_Click(object sender, RoutedEventArgs e) : 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;