diff --git a/src/App.xaml.cs b/src/App.xaml.cs
index f854eec..829467b 100644
--- a/src/App.xaml.cs
+++ b/src/App.xaml.cs
@@ -20,6 +20,9 @@ public partial class App : System.Windows.Application
protected override void OnStartup(StartupEventArgs e)
{
+ // 启用现代指针支持以修复触屏拖拽卡顿并支持多点触控
+ AppContext.SetSwitch("Switch.System.Windows.Input.Stylus.EnablePointerSupport", true);
+
const string appName = @"Global\BASpark_SingleInstance_Mutex";
_mutex = new Mutex(true, appName, out bool createdNew);
diff --git a/src/ConfigManager.cs b/src/ConfigManager.cs
index 2401780..2c22556 100644
--- a/src/ConfigManager.cs
+++ b/src/ConfigManager.cs
@@ -44,6 +44,7 @@ public static class ConfigManager
public static string FilterProfiles { get; set; } = "";
public static string ActiveProfileId { get; set; } = "";
public static bool IsTouchscreenMode { get; set; } = false;
+ public static bool EnableMultiTouch { get; set; } = false;
public static int ClickTriggerType { get; set; } = 0; // 0:左, 1:右, 2:左右
public static string EnabledScreenIds { get; set; } = "";
@@ -76,6 +77,7 @@ public static void Load()
HideInFullscreen = Convert.ToBoolean(key.GetValue("HideInFullscreen", true));
ShowEffectOnDesktop = Convert.ToBoolean(key.GetValue("ShowEffectOnDesktop", true));
IsTouchscreenMode = Convert.ToBoolean(key.GetValue("IsTouchscreenMode", false));
+ EnableMultiTouch = Convert.ToBoolean(key.GetValue("EnableMultiTouch", false));
ClickTriggerType = Convert.ToInt32(key.GetValue("ClickTriggerType", 0));
EnabledScreenIds = key.GetValue("EnabledScreenIds", "")?.ToString() ?? "";
@@ -255,6 +257,7 @@ public static void ResetAndClear()
ActiveProfileId = "";
_profiles.Clear();
IsTouchscreenMode = false;
+ EnableMultiTouch = false;
ClickTriggerType = 0;
EnabledScreenIds = "";
}
diff --git a/src/ControlPanelWindow.xaml b/src/ControlPanelWindow.xaml
index f87cbde..46bbdca 100644
--- a/src/ControlPanelWindow.xaml
+++ b/src/ControlPanelWindow.xaml
@@ -233,6 +233,20 @@
+
+
+
+
+
+
+
diff --git a/src/ControlPanelWindow.xaml.cs b/src/ControlPanelWindow.xaml.cs
index 5400e44..6bcaf4f 100644
--- a/src/ControlPanelWindow.xaml.cs
+++ b/src/ControlPanelWindow.xaml.cs
@@ -388,6 +388,7 @@ private void LoadSettings()
CheckShowEffectOnDesktop.IsChecked = ConfigManager.ShowEffectOnDesktop;
CheckRunAsAdmin.IsChecked = ConfigManager.RunAsAdmin;
CheckTouchscreenMode.IsChecked = ConfigManager.IsTouchscreenMode;
+ CheckMultiTouch.IsChecked = ConfigManager.EnableMultiTouch;
int mode = ConfigManager.ClickTriggerType;
if (mode == 1) RadioRightClick.IsChecked = true;
@@ -765,6 +766,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
bool startSilentEnabled = CheckStartSilent.IsChecked ?? false;
bool runAsAdminEnabled = CheckRunAsAdmin.IsChecked ?? false;
bool isTouchscreenEnabled = CheckTouchscreenMode?.IsChecked ?? false;
+ bool isMultiTouchEnabled = CheckMultiTouch?.IsChecked ?? false;
int clickType = 0;
if (RadioRightClick.IsChecked == true) clickType = 1;
@@ -776,6 +778,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
ConfigManager.Save("RunAsAdmin", runAsAdminEnabled);
ConfigManager.Save("IsTouchscreenMode", isTouchscreenEnabled);
+ ConfigManager.Save("EnableMultiTouch", isMultiTouchEnabled);
ConfigManager.Save("IsEffectEnabled", CheckMasterSwitch.IsChecked ?? true);
ConfigManager.Save("AutoStart", autoStartEnabled);
ConfigManager.Save("EnableTelemetry", CheckTelemetry.IsChecked ?? false);
@@ -814,6 +817,7 @@ private void SaveSettings_Click(object sender, RoutedEventArgs e)
App.Overlay?.UpdateTrailRefreshRate(trailRefreshRate);
App.Overlay?.RefreshEnvironmentFilterState();
App.Overlay?.UpdateTouchMode(isTouchscreenEnabled);
+ App.Overlay?.UpdateMultiTouchMode(isMultiTouchEnabled);
if (!enabledScreenIds.SetEquals(selectedIds))
{
App.Overlay?.RefreshScreenSelection();
diff --git a/src/MainWindow.xaml.cs b/src/MainWindow.xaml.cs
index 8f7cd40..67a77ac 100644
--- a/src/MainWindow.xaml.cs
+++ b/src/MainWindow.xaml.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Drawing;
using System.Globalization;
using System.Linq;
@@ -72,6 +72,7 @@ private struct RECT
private IntPtr _hwnd;
private string? _lastReportedInputMode;
private bool? _lastReportedAlwaysTrail;
+ private bool? _lastReportedMultiTouch;
private const string InputModeMouse = "mouse";
private const string InputModeTouch = "touch";
@@ -264,15 +265,23 @@ private static bool IsCursorVisible()
private string BuildInputContextScript(string inputMode)
{
bool alwaysTrailEnabled = ConfigManager.EnableAlwaysTrailEffect;
- if (_lastReportedInputMode == inputMode && _lastReportedAlwaysTrail == alwaysTrailEnabled)
+ bool multiTouchEnabled = ConfigManager.EnableMultiTouch;
+
+ if (_lastReportedInputMode == inputMode &&
+ _lastReportedAlwaysTrail == alwaysTrailEnabled &&
+ _lastReportedMultiTouch == multiTouchEnabled)
{
return string.Empty;
}
_lastReportedInputMode = inputMode;
_lastReportedAlwaysTrail = alwaysTrailEnabled;
+ _lastReportedMultiTouch = multiTouchEnabled;
+
string alwaysTrailLiteral = alwaysTrailEnabled ? "true" : "false";
- return $"if(window.setInputContext) window.setInputContext('{inputMode}', {alwaysTrailLiteral});";
+ string multiTouchLiteral = multiTouchEnabled ? "true" : "false";
+
+ return $"if(window.setInputContext) window.setInputContext('{inputMode}', {alwaysTrailLiteral}, {multiTouchLiteral});";
}
private void SyncInputContext(string inputMode)
@@ -323,6 +332,27 @@ public void EmitUp(bool touchLike)
ExecuteWithInputContext(inputMode, "if(window.externalUp) window.externalUp();");
}
+ public void EmitTouchDown(uint pointerId, int x, int y)
+ {
+ if (!TryConvertScreenToOverlayPoint(x, y, out System.Windows.Point clientPoint)) return;
+ string px = FormatCoordinate(clientPoint.X);
+ string py = FormatCoordinate(clientPoint.Y);
+ ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchDown) window.externalTouchDown({pointerId}, {px}, {py});");
+ }
+
+ public void EmitTouchMove(uint pointerId, int x, int y, bool touchLike)
+ {
+ if (!TryConvertScreenToOverlayPoint(x, y, out System.Windows.Point clientPoint)) return;
+ string px = FormatCoordinate(clientPoint.X);
+ string py = FormatCoordinate(clientPoint.Y);
+ ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchMove) window.externalTouchMove({pointerId}, {px}, {py});");
+ }
+
+ public void EmitTouchUp(uint pointerId, bool touchLike)
+ {
+ ExecuteWithInputContext(InputModeTouch, $"if(window.externalTouchUp) window.externalTouchUp({pointerId});");
+ }
+
private void UpdateOverlayBounds()
{
Rectangle bounds = GetScreenBounds();
diff --git a/src/OverlayManager.cs b/src/OverlayManager.cs
index 52a2a93..9972ecd 100644
--- a/src/OverlayManager.cs
+++ b/src/OverlayManager.cs
@@ -51,9 +51,12 @@ public sealed class OverlayManager : IDisposable
private static extern IntPtr WindowFromPoint(POINT Point);
[DllImport("user32.dll")]
private static extern IntPtr GetAncestor(IntPtr hwnd, uint gaFlags);
+ [DllImport("user32.dll")]
+ private static extern IntPtr GetMessageExtraInfo();
[StructLayout(LayoutKind.Sequential)]
private struct POINT { public int x; public int y; }
+
[StructLayout(LayoutKind.Sequential)]
private struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
@@ -74,10 +77,12 @@ private struct MONITORINFO
private readonly Dictionary _overlays = new(StringComparer.OrdinalIgnoreCase);
private IKeyboardMouseEvents? _globalHook;
+ private TouchInputCapture? _touchCapture;
private MainWindow? _activePointerOverlay;
private long _lastMoveTicks;
private long _lastClickTicks;
- private long _moveIntervalTicks = 250000;
+ private long _moveIntervalTicks = 250000; // 默认 40Hz
+ private long _touchMoveIntervalTicks = 80000; // 触摸专用,约 120Hz,降低拖尾延迟
private bool _isPrimaryPointerDown;
private bool _isTouchLikeInput;
private bool _isSuppressedByEnvironment;
@@ -85,13 +90,87 @@ private struct MONITORINFO
private IntPtr _lastForegroundWindow = IntPtr.Zero;
private bool _disposed;
+ private readonly Dictionary _activePointers = new();
+
+ private class TouchPointState
+ {
+ public uint PointerId;
+ public int LastX, LastY;
+ public bool IsDown;
+ public long LastMoveTicks;
+ public MainWindow? TargetOverlay;
+ }
+
public void Start()
{
RebuildWindows(forceRebuild: true);
SetupGlobalHooks();
+ SetupTouchCapture();
SystemEvents.DisplaySettingsChanged += HandleDisplaySettingsChanged;
}
+ private void SetupTouchCapture()
+ {
+ if (ConfigManager.EnableMultiTouch)
+ {
+ _touchCapture = new TouchInputCapture();
+ _touchCapture.TouchDown += (id, x, y) => EmitTouchDown(id, x, y);
+ _touchCapture.TouchMove += (id, x, y) => EmitTouchMove(id, x, y);
+ _touchCapture.TouchUp += (id) => EmitTouchUp(id);
+ _touchCapture.Start();
+ }
+ }
+
+ private void HandleRawInput(IntPtr hRawInput)
+ {
+ // 已废弃,多点触控改由 TouchInputCapture 处理
+ }
+
+ public void EmitTouchDown(uint pointerId, int x, int y)
+ {
+ if (!CanRenderEffects()) return;
+
+ MainWindow? target = ResolveTargetOverlay(x, y);
+ if (target == null) return;
+
+ _activePointers[pointerId] = new TouchPointState
+ {
+ PointerId = pointerId,
+ LastX = x,
+ LastY = y,
+ IsDown = true,
+ TargetOverlay = target
+ };
+
+ ConfigManager.TotalClicks++;
+ target.EmitTouchDown(pointerId, x, y);
+ }
+
+ public void EmitTouchMove(uint pointerId, int x, int y)
+ {
+ if (!_activePointers.TryGetValue(pointerId, out var state) || !state.IsDown) return;
+ if (state.TargetOverlay == null) return;
+
+ // 每个触控点独立节流,避免多指互相阻塞
+ long currentTicks = DateTime.Now.Ticks;
+ if (currentTicks - state.LastMoveTicks < _touchMoveIntervalTicks) return;
+ state.LastMoveTicks = currentTicks;
+
+ state.LastX = x;
+ state.LastY = y;
+ state.TargetOverlay.EmitTouchMove(pointerId, x, y, true);
+ }
+
+ public void EmitTouchUp(uint pointerId)
+ {
+ if (_activePointers.TryGetValue(pointerId, out var state))
+ {
+ state.IsDown = false;
+ state.TargetOverlay?.EmitTouchUp(pointerId, true);
+ _activePointers.Remove(pointerId);
+ }
+ }
+
public void UpdateColor(string color) => ForEachOverlay(w => w.UpdateColor(color));
public void UpdateEffectSettings(double scale, double opacity, double speed) => ForEachOverlay(w => w.UpdateEffectSettings(scale, opacity, speed));
public void UpdateTrailRefreshRate(int hz)
@@ -101,6 +180,23 @@ public void UpdateTrailRefreshRate(int hz)
ForEachOverlay(w => w.UpdateTrailRefreshRate(hz));
}
public void UpdateTouchMode(bool enabled) => ForEachOverlay(w => w.UpdateTouchMode(enabled));
+ public void UpdateMultiTouchMode(bool enabled)
+ {
+ if (enabled && _touchCapture == null)
+ {
+ _touchCapture = new TouchInputCapture();
+ _touchCapture.TouchDown += (id, x, y) => EmitTouchDown(id, x, y);
+ _touchCapture.TouchMove += (id, x, y) => EmitTouchMove(id, x, y);
+ _touchCapture.TouchUp += (id) => EmitTouchUp(id);
+ _touchCapture.Start();
+ }
+ else if (!enabled && _touchCapture != null)
+ {
+ _touchCapture.Dispose();
+ _touchCapture = null;
+ _activePointers.Clear();
+ }
+ }
public bool IsEffectSuppressedByEnvironment() => ShouldSuppressEffects();
public void RefreshEnvironmentFilterState()
{
@@ -122,10 +218,24 @@ private void SetupGlobalHooks()
_globalHook.MouseUpExt += OnMouseUpExt;
}
+ public void EmitDown(int x, int y)
+ {
+ if (!CanRenderEffects()) return;
+
+ MainWindow? target = ResolveTargetOverlay(x, y);
+ if (target == null) return;
+
+ ConfigManager.TotalClicks++;
+ target.EmitDown(x, y);
+ }
+
private void OnMouseDownExt(object? sender, MouseEventExtArgs e)
{
if (!CanRenderEffects()) return;
+ // 如果当前正在处理原生多点触控,则跳过合成鼠标事件的按下
+ if (_activePointers.Count > 0 && !CursorIsVisible()) return;
+
bool isLeft = e.Button == MouseButtons.Left;
bool isRight = e.Button == MouseButtons.Right;
bool shouldTrigger = ConfigManager.ClickTriggerType switch
@@ -161,11 +271,16 @@ private void OnMouseMoveExt(object? sender, MouseEventExtArgs e)
{
if (!CanRenderEffects()) return;
+ // 如果已有原生触控点在处理,则跳过合成鼠标事件以消除多余延迟
+ if (_activePointers.Count > 0 && !CursorIsVisible()) return;
+
bool cursorVisible = CursorIsVisible();
if (!cursorVisible && !_isPrimaryPointerDown) return;
+ // 触摸/触控笔事件使用更低的节流阈值,修复拖拽卡顿
long currentTicks = DateTime.Now.Ticks;
- if (currentTicks - _lastMoveTicks < _moveIntervalTicks) return;
+ long interval = (!cursorVisible || _isTouchLikeInput) ? _touchMoveIntervalTicks : _moveIntervalTicks;
+ if (currentTicks - _lastMoveTicks < interval) return;
_lastMoveTicks = currentTicks;
if (!TryGetPhysicalCursorPosition(out int cursorX, out int cursorY))
@@ -181,6 +296,8 @@ private void OnMouseMoveExt(object? sender, MouseEventExtArgs e)
private void OnMouseUpExt(object? sender, MouseEventExtArgs e)
{
_ = e;
+ // 如果已有原生触控点在处理,跳过
+ if (_activePointers.Count > 0 && !CursorIsVisible()) return;
if (!_isPrimaryPointerDown)
{
_isTouchLikeInput = false;
@@ -207,7 +324,7 @@ private bool CanRenderEffects()
return false;
}
- if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible())
+ if (!ConfigManager.IsTouchscreenMode && !CursorIsVisible() && _activePointers.Count == 0)
{
ReleasePointerState();
return false;
@@ -543,6 +660,14 @@ public void Dispose()
_globalHook = null;
}
+ if (_touchCapture != null)
+ {
+ _touchCapture.Dispose();
+ _touchCapture = null;
+ }
+
+ _activePointers.Clear();
+
CloseWindows();
}
}
diff --git a/src/TouchInputCapture.cs b/src/TouchInputCapture.cs
new file mode 100644
index 0000000..7ea94d7
--- /dev/null
+++ b/src/TouchInputCapture.cs
@@ -0,0 +1,415 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Runtime.InteropServices;
+using System.Windows.Forms;
+
+namespace BASpark
+{
+ ///
+ /// 使用 Raw Input (WM_INPUT + RIDEV_INPUTSINK) 全局捕获多点触控输入。
+ /// 不需要 UIAccess 权限,可在普通用户模式下工作。
+ ///
+ internal sealed class TouchInputCapture : IDisposable
+ {
+ public event Action? TouchDown; // contactId, screenX, screenY
+ public event Action? TouchMove; // contactId, screenX, screenY
+ public event Action? TouchUp; // contactId
+
+ private RawInputReceiver? _receiver;
+ private bool _disposed;
+
+ public void Start()
+ {
+ _receiver = new RawInputReceiver(this);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ _receiver?.ReleaseHandle();
+ _receiver = null;
+ }
+
+ #region P/Invoke
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern bool RegisterRawInputDevices(RAWINPUTDEVICE[] pRawInputDevices, uint uiNumDevices, uint cbSize);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetRawInputData(IntPtr hRawInput, uint uiCommand, IntPtr pData, ref uint pcbSize, uint cbSizeHeader);
+
+ [DllImport("user32.dll")]
+ private static extern uint GetRawInputDeviceInfo(IntPtr hDevice, uint uiCommand, IntPtr pData, ref uint pcbSize);
+
+ [DllImport("hid.dll")]
+ private static extern int HidP_GetCaps(IntPtr PreparsedData, out HIDP_CAPS Capabilities);
+
+ [DllImport("hid.dll")]
+ private static extern int HidP_GetValueCaps(ushort ReportType, [Out] HIDP_VALUE_CAPS[] ValueCaps, ref ushort ValueCapsLength, IntPtr PreparsedData);
+
+ [DllImport("hid.dll")]
+ private static extern int HidP_GetUsageValue(ushort ReportType, ushort UsagePage, ushort LinkCollection, ushort Usage, out uint UsageValue, IntPtr PreparsedData, byte[] Report, uint ReportLength);
+
+ [DllImport("hid.dll")]
+ private static extern int HidP_GetUsages(ushort ReportType, ushort UsagePage, ushort LinkCollection, [Out] ushort[] UsageList, ref uint UsageLength, IntPtr PreparsedData, byte[] Report, uint ReportLength);
+
+ private const uint RIDEV_INPUTSINK = 0x00000100;
+ private const uint RID_INPUT = 0x10000003;
+ private const uint RIM_TYPEHID = 2;
+ private const uint RIDI_PREPARSEDDATA = 0x20000005;
+ private const int WM_INPUT = 0x00FF;
+ private const ushort HidP_Input = 0;
+
+ // HID Usage 常量
+ private const ushort USAGE_PAGE_DIGITIZER = 0x0D;
+ private const ushort USAGE_PAGE_GENERIC = 0x01;
+ private const ushort USAGE_TOUCH_SCREEN = 0x04;
+ private const ushort USAGE_CONTACT_ID = 0x51;
+ private const ushort USAGE_TIP_SWITCH = 0x42;
+ private const ushort USAGE_X = 0x30;
+ private const ushort USAGE_Y = 0x31;
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWINPUTDEVICE
+ {
+ public ushort usUsagePage;
+ public ushort usUsage;
+ public uint dwFlags;
+ public IntPtr hwndTarget;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct RAWINPUTHEADER
+ {
+ public uint dwType;
+ public uint dwSize;
+ public IntPtr hDevice;
+ public IntPtr wParam;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct HIDP_CAPS
+ {
+ public ushort Usage;
+ public ushort UsagePage;
+ public ushort InputReportByteLength;
+ public ushort OutputReportByteLength;
+ public ushort FeatureReportByteLength;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 17)]
+ public ushort[] Reserved;
+ public ushort NumberLinkCollectionNodes;
+ public ushort NumberInputButtonCaps;
+ public ushort NumberInputValueCaps;
+ public ushort NumberInputDataIndices;
+ public ushort NumberOutputButtonCaps;
+ public ushort NumberOutputValueCaps;
+ public ushort NumberOutputDataIndices;
+ public ushort NumberFeatureButtonCaps;
+ public ushort NumberFeatureValueCaps;
+ public ushort NumberFeatureDataIndices;
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct HIDP_VALUE_CAPS
+ {
+ public ushort UsagePage;
+ public byte ReportID;
+ [MarshalAs(UnmanagedType.U1)] public bool IsAlias;
+ public ushort BitField;
+ public ushort LinkCollection;
+ public ushort LinkUsage;
+ public ushort LinkUsagePage;
+ [MarshalAs(UnmanagedType.U1)] public bool IsRange;
+ [MarshalAs(UnmanagedType.U1)] public bool IsStringRange;
+ [MarshalAs(UnmanagedType.U1)] public bool IsDesignatorRange;
+ [MarshalAs(UnmanagedType.U1)] public bool IsAbsolute;
+ [MarshalAs(UnmanagedType.U1)] public bool HasNull;
+ public byte Reserved;
+ public ushort BitSize;
+ public ushort ReportCount;
+ [MarshalAs(UnmanagedType.ByValArray, SizeConst = 5)]
+ public ushort[] Reserved2;
+ public uint UnitsExp;
+ public uint Units;
+ public int LogicalMin;
+ public int LogicalMax;
+ public int PhysicalMin;
+ public int PhysicalMax;
+ // Union: Range / NotRange — 只取 NotRange.Usage
+ public ushort UsageMin_or_Usage;
+ public ushort UsageMax_or_Reserved;
+ public ushort StringMin;
+ public ushort StringMax;
+ public ushort DesignatorMin;
+ public ushort DesignatorMax;
+ public ushort DataIndexMin;
+ public ushort DataIndexMax;
+ }
+
+ #endregion
+
+ #region Device Info Cache
+
+ private class TouchDeviceInfo
+ {
+ public IntPtr PreparsedData;
+ public int LogicalMaxX;
+ public int LogicalMaxY;
+ public ushort LinkCollectionForContacts; // link collection containing X/Y/ContactID
+ public bool IsValid;
+ }
+
+ private readonly Dictionary _deviceCache = new();
+
+ private TouchDeviceInfo? GetOrCreateDeviceInfo(IntPtr hDevice)
+ {
+ if (_deviceCache.TryGetValue(hDevice, out var cached))
+ return cached.IsValid ? cached : null;
+
+ var info = BuildDeviceInfo(hDevice);
+ _deviceCache[hDevice] = info;
+ return info.IsValid ? info : null;
+ }
+
+ private TouchDeviceInfo BuildDeviceInfo(IntPtr hDevice)
+ {
+ var result = new TouchDeviceInfo();
+ uint size = 0;
+
+ // 获取 PreparsedData 大小
+ GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, IntPtr.Zero, ref size);
+ if (size == 0) return result;
+
+ IntPtr preparsedData = Marshal.AllocHGlobal((int)size);
+ try
+ {
+ if (GetRawInputDeviceInfo(hDevice, RIDI_PREPARSEDDATA, preparsedData, ref size) == unchecked((uint)-1))
+ return result;
+
+ if (HidP_GetCaps(preparsedData, out HIDP_CAPS caps) != 0x00110000) // HIDP_STATUS_SUCCESS
+ return result;
+
+ // 获取 Value Caps 以找出 X/Y 的逻辑范围和 LinkCollection
+ ushort numValueCaps = caps.NumberInputValueCaps;
+ if (numValueCaps == 0) return result;
+
+ var valueCaps = new HIDP_VALUE_CAPS[numValueCaps];
+ if (HidP_GetValueCaps(HidP_Input, valueCaps, ref numValueCaps, preparsedData) != 0x00110000)
+ return result;
+
+ int logMaxX = 0, logMaxY = 0;
+ ushort linkCol = 0;
+ bool foundX = false, foundY = false;
+
+ foreach (var vc in valueCaps)
+ {
+ ushort usage = vc.IsRange ? vc.UsageMin_or_Usage : vc.UsageMin_or_Usage;
+
+ if (vc.UsagePage == USAGE_PAGE_GENERIC && usage == USAGE_X)
+ {
+ logMaxX = vc.LogicalMax;
+ linkCol = vc.LinkCollection;
+ foundX = true;
+ }
+ else if (vc.UsagePage == USAGE_PAGE_GENERIC && usage == USAGE_Y)
+ {
+ logMaxY = vc.LogicalMax;
+ foundY = true;
+ }
+ }
+
+ if (!foundX || !foundY || logMaxX <= 0 || logMaxY <= 0)
+ return result;
+
+ // 不释放 preparsedData,缓存它(后续解析需要)
+ result.PreparsedData = preparsedData;
+ result.LogicalMaxX = logMaxX;
+ result.LogicalMaxY = logMaxY;
+ result.LinkCollectionForContacts = linkCol;
+ result.IsValid = true;
+
+ preparsedData = IntPtr.Zero; // 防止 finally 释放
+ return result;
+ }
+ finally
+ {
+ if (preparsedData != IntPtr.Zero)
+ Marshal.FreeHGlobal(preparsedData);
+ }
+ }
+
+ #endregion
+
+ #region Contact State Tracking
+
+ private readonly Dictionary _contactStates = new(); // contactId -> wasDown
+
+ #endregion
+
+ #region Raw Input Processing
+
+ private void ProcessRawInput(IntPtr hRawInput)
+ {
+ uint headerSize = (uint)Marshal.SizeOf();
+ uint size = 0;
+ GetRawInputData(hRawInput, RID_INPUT, IntPtr.Zero, ref size, headerSize);
+ if (size == 0) return;
+
+ IntPtr pData = Marshal.AllocHGlobal((int)size);
+ try
+ {
+ if (GetRawInputData(hRawInput, RID_INPUT, pData, ref size, headerSize) == unchecked((uint)-1))
+ return;
+
+ var header = Marshal.PtrToStructure(pData);
+ if (header.dwType != RIM_TYPEHID)
+ return;
+
+ var deviceInfo = GetOrCreateDeviceInfo(header.hDevice);
+ if (deviceInfo == null) return;
+
+ // 读取 HID 报文:header 之后是 { dwSizeHid, dwCount, bRawData[] }
+ int hidOffset = (int)headerSize;
+ // 对齐到 IntPtr 边界
+ if (IntPtr.Size == 8)
+ hidOffset = (hidOffset + 7) & ~7;
+
+ uint dwSizeHid = (uint)Marshal.ReadInt32(pData, hidOffset);
+ uint dwCount = (uint)Marshal.ReadInt32(pData, hidOffset + 4);
+ int rawDataOffset = hidOffset + 8;
+
+ if (dwSizeHid == 0 || dwCount == 0) return;
+
+ // 每个 HID report 处理一个触控点
+ for (uint i = 0; i < dwCount; i++)
+ {
+ byte[] report = new byte[dwSizeHid];
+ Marshal.Copy(pData + rawDataOffset + (int)(i * dwSizeHid), report, 0, (int)dwSizeHid);
+
+ ParseSingleContact(report, dwSizeHid, deviceInfo);
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"[TouchInputCapture] ProcessRawInput error: {ex.Message}");
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(pData);
+ }
+ }
+
+ private void ParseSingleContact(byte[] report, uint reportLength, TouchDeviceInfo device)
+ {
+ // 检查 TipSwitch(是否触摸中)
+ ushort[] usages = new ushort[16];
+ uint usageCount = (uint)usages.Length;
+ bool isTouching = false;
+
+ int statusBtn = HidP_GetUsages(HidP_Input, USAGE_PAGE_DIGITIZER, device.LinkCollectionForContacts,
+ usages, ref usageCount, device.PreparsedData, report, reportLength);
+
+ if (statusBtn == 0x00110000) // HIDP_STATUS_SUCCESS
+ {
+ for (int j = 0; j < usageCount; j++)
+ {
+ if (usages[j] == USAGE_TIP_SWITCH)
+ {
+ isTouching = true;
+ break;
+ }
+ }
+ }
+
+ // 获取 ContactID
+ int statusCid = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_DIGITIZER, device.LinkCollectionForContacts,
+ USAGE_CONTACT_ID, out uint contactId, device.PreparsedData, report, reportLength);
+ if (statusCid != 0x00110000) return;
+
+ // 获取 X, Y(逻辑坐标)
+ int statusX = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_GENERIC, device.LinkCollectionForContacts,
+ USAGE_X, out uint logX, device.PreparsedData, report, reportLength);
+ int statusY = HidP_GetUsageValue(HidP_Input, USAGE_PAGE_GENERIC, device.LinkCollectionForContacts,
+ USAGE_Y, out uint logY, device.PreparsedData, report, reportLength);
+
+ if (statusX != 0x00110000 || statusY != 0x00110000) return;
+
+ // 逻辑坐标 → 屏幕像素坐标
+ // 使用虚拟屏幕(所有显示器的合并区域)
+ int virtualLeft = SystemInformation.VirtualScreen.Left;
+ int virtualTop = SystemInformation.VirtualScreen.Top;
+ int virtualWidth = SystemInformation.VirtualScreen.Width;
+ int virtualHeight = SystemInformation.VirtualScreen.Height;
+
+ int screenX = virtualLeft + (int)((long)logX * virtualWidth / device.LogicalMaxX);
+ int screenY = virtualTop + (int)((long)logY * virtualHeight / device.LogicalMaxY);
+
+ // 状态跟踪并发射事件
+ bool wasDown = _contactStates.TryGetValue(contactId, out bool prev) && prev;
+
+ if (isTouching)
+ {
+ if (!wasDown)
+ {
+ _contactStates[contactId] = true;
+ TouchDown?.Invoke(contactId, screenX, screenY);
+ }
+ else
+ {
+ TouchMove?.Invoke(contactId, screenX, screenY);
+ }
+ }
+ else
+ {
+ if (wasDown)
+ {
+ _contactStates[contactId] = false;
+ TouchUp?.Invoke(contactId);
+ }
+ }
+ }
+
+ #endregion
+
+ #region NativeWindow Receiver
+
+ private class RawInputReceiver : NativeWindow
+ {
+ private readonly TouchInputCapture _owner;
+
+ public RawInputReceiver(TouchInputCapture owner)
+ {
+ _owner = owner;
+ CreateHandle(new CreateParams());
+
+ // 注册接收触摸数字化器的原始输入(RIDEV_INPUTSINK 允许后台接收)
+ var rid = new RAWINPUTDEVICE[]
+ {
+ new RAWINPUTDEVICE
+ {
+ usUsagePage = USAGE_PAGE_DIGITIZER,
+ usUsage = USAGE_TOUCH_SCREEN,
+ dwFlags = RIDEV_INPUTSINK,
+ hwndTarget = Handle
+ }
+ };
+
+ bool ok = RegisterRawInputDevices(rid, 1, (uint)Marshal.SizeOf());
+ Debug.WriteLine($"[TouchInputCapture] RegisterRawInputDevices: {ok}");
+ }
+
+ protected override void WndProc(ref Message m)
+ {
+ if (m.Msg == WM_INPUT)
+ {
+ _owner.ProcessRawInput(m.LParam);
+ }
+ base.WndProc(ref m);
+ }
+ }
+
+ #endregion
+ }
+}
diff --git a/src/Web/index.html b/src/Web/index.html
index 84267aa..c33b1f4 100644
--- a/src/Web/index.html
+++ b/src/Web/index.html
@@ -28,6 +28,7 @@
this.waves = [];
this.sparks = [];
this.trail = [];
+ this.touchPointers = new Map(); // 多指追踪: pointerId -> { trail: [], isDown: bool, lastPos: null }
this.isDown = false;
this.lastPos = null;
this.baseFrameMs = 1000 / 60;
@@ -164,65 +165,20 @@
this.lastFrameTime = now;
const frameScale = (deltaMs / this.baseFrameMs) * this.speed;
- if (this.waves.length > 0 || this.sparks.length > 0 || this.trail.length > 0) {
+ if (this.waves.length > 0 || this.sparks.length > 0 || this.trail.length > 0 || this.touchPointers.size > 0) {
this.ctx.clearRect(0, 0, this.c.width, this.c.height);
// 优化:减少全局合成模式的使用
this.ctx.globalCompositeOperation = "lighter";
- for (let i = this.trail.length - 1; i >= 0; i--) {
- let t = this.trail[i];
- if (window.effectiveAlwaysTrail) {
- t.life -= 0.085 * frameScale;
- } else {
- t.life -= (this.isDown ? 0.085 : 0.18) * frameScale;
- }
- if (t.life <= 0) this.trail.splice(i, 1);
- }
-
- if (this.trail.length > 1) {
- // this.ctx.beginPath();
- // this.ctx.moveTo(this.trail[0].x, this.trail[0].y);
- // for (let i = 1; i < this.trail.length; i++) {
- // this.ctx.lineTo(this.trail[i].x, this.trail[i].y);
- // }
- // this.ctx.lineWidth = 8.0;
-
-
- // this.ctx.strokeStyle = `rgba(${this.color},${this.alpha(0.35)})`;
- // this.ctx.stroke();
-
-
- // 逐段渲染:每段的透明度基于数组索引位置,而非空间位置
- // 防止当鼠标锐角转动时,尾迹变淡后突然出现的问题
- this.ctx.lineWidth = 5.0;
- this.ctx.shadowColor = `rgba(${this.color}, 0.6)`;
- this.ctx.shadowBlur = 3;
- this.ctx.shadowOffsetX = 0;
- this.ctx.shadowOffsetY = 0;
-
- const lastIdx = this.trail.length - 1;
- for (let i = 0; i < lastIdx; i++) {
- const alphaStart = i / lastIdx;
- const alphaEnd = (i + 1) / lastIdx;
- const a0 = this.trail[i];
- const a1 = this.trail[i + 1];
-
- // 每段使用独立的两点渐变,只覆盖该段自身
- const segGrad = this.ctx.createLinearGradient(
- a0.x, a0.y, a1.x, a1.y
- );
- segGrad.addColorStop(0, `rgba(${this.color}, ${alphaStart})`);
- segGrad.addColorStop(1, `rgba(${this.color}, ${alphaEnd})`);
+ // 渲染主鼠标拖尾
+ this.renderTrail(this.trail, this.isDown, frameScale);
- this.ctx.beginPath();
- this.ctx.moveTo(a0.x, a0.y);
- this.ctx.lineTo(a1.x, a1.y);
- this.ctx.strokeStyle = segGrad;
- this.ctx.stroke();
+ // 渲染多指拖尾
+ for (let [id, pointer] of this.touchPointers.entries()) {
+ this.renderTrail(pointer.trail, pointer.isDown, frameScale);
+ if (!pointer.isDown && pointer.trail.length === 0) {
+ this.touchPointers.delete(id);
}
-
- this.ctx.shadowColor = 'transparent';
-
}
for (let i = this.waves.length - 1; i >= 0; i--) {
@@ -291,6 +247,46 @@
}
requestAnimationFrame((nextNow) => this.loop(nextNow));
}
+
+ renderTrail(trailArray, isPointerDown, frameScale) {
+ for (let i = trailArray.length - 1; i >= 0; i--) {
+ let t = trailArray[i];
+ if (window.effectiveAlwaysTrail) {
+ t.life -= 0.085 * frameScale;
+ } else {
+ t.life -= (isPointerDown ? 0.085 : 0.18) * frameScale;
+ }
+ if (t.life <= 0) trailArray.splice(i, 1);
+ }
+
+ if (trailArray.length > 1) {
+ this.ctx.lineWidth = 5.0;
+ this.ctx.shadowColor = `rgba(${this.color}, 0.6)`;
+ this.ctx.shadowBlur = 3;
+ this.ctx.shadowOffsetX = 0;
+ this.ctx.shadowOffsetY = 0;
+
+ const lastIdx = trailArray.length - 1;
+ for (let i = 0; i < lastIdx; i++) {
+ const alphaStart = i / lastIdx;
+ const alphaEnd = (i + 1) / lastIdx;
+ const a0 = trailArray[i];
+ const a1 = trailArray[i + 1];
+
+ const segGrad = this.ctx.createLinearGradient(a0.x, a0.y, a1.x, a1.y);
+ segGrad.addColorStop(0, `rgba(${this.color}, ${alphaStart})`);
+ segGrad.addColorStop(1, `rgba(${this.color}, ${alphaEnd})`);
+
+ this.ctx.beginPath();
+ this.ctx.moveTo(a0.x, a0.y);
+ this.ctx.lineTo(a1.x, a1.y);
+ this.ctx.strokeStyle = segGrad;
+ this.ctx.stroke();
+ }
+
+ this.ctx.shadowColor = 'transparent';
+ }
+ }
}
window.spark = new MouseSpark();
@@ -304,14 +300,16 @@
window.currentInputMode = "mouse";
window.enableAlwaysTrailEffect = false;
window.effectiveAlwaysTrail = false;
+window.enableMultiTouch = false;
-window.setInputContext = (mode, alwaysTrailEnabled) => {
+window.setInputContext = (mode, alwaysTrailEnabled, multiTouchEnabled) => {
window.currentInputMode = mode === "touch" ? "touch" : "mouse";
window.enableAlwaysTrailEffect = Boolean(alwaysTrailEnabled);
+ window.enableMultiTouch = Boolean(multiTouchEnabled);
window.effectiveAlwaysTrail = window.currentInputMode === "mouse" && window.enableAlwaysTrailEffect;
};
-window.setInputContext("mouse", false);
+window.setInputContext("mouse", false, false);
// 接收 C# 传来的物理百分比 (0.0 ~ 1.0),由前端根据真实的画布宽度自行计算
window.externalBoom = (percentX, percentY) => {
@@ -338,6 +336,41 @@
window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
};
+// 多点触控接口
+window.externalTouchDown = (pointerId, percentX, percentY) => {
+ if (!window.spark || !window.enableMultiTouch) return;
+ let cx = percentX * window.innerWidth;
+ let cy = percentY * window.innerHeight;
+
+ if (window.spark.touchPointers.size > 5) return; // 限制最多5指
+
+ let pointer = window.spark.touchPointers.get(pointerId) || { trail: [], isDown: false, lastPos: null };
+ pointer.isDown = true;
+ pointer.lastPos = { x: cx, y: cy };
+ window.spark.touchPointers.set(pointerId, pointer);
+
+ window.spark.boom(cx, cy);
+};
+
+window.externalTouchMove = (pointerId, percentX, percentY) => {
+ if (!window.spark || !window.enableMultiTouch) return;
+ let pointer = window.spark.touchPointers.get(pointerId);
+ if (!pointer || !pointer.isDown) return;
+
+ let cx = percentX * window.innerWidth;
+ let cy = percentY * window.innerHeight;
+
+ pointer.trail.push({ x: cx, y: cy, life: 1 });
+ if (pointer.trail.length > window.spark.maxTrail) pointer.trail.shift();
+ pointer.lastPos = { x: cx, y: cy };
+};
+
+window.externalTouchUp = (pointerId) => {
+ if (!window.spark) return;
+ let pointer = window.spark.touchPointers.get(pointerId);
+ if (pointer) pointer.isDown = false;
+};
+
window.updateColor = (rgbString) => {
if (window.spark) window.spark.color = rgbString;
};