diff --git a/binding.gyp b/binding.gyp
index 6ae8c08..9486601 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -27,7 +27,25 @@
[
"OS=='win'",
{
- "sources": ["src/binding_windows.cpp", "src/screenshot_windows.cpp"],
+ "sources": [
+ "src/binding_windows.cpp",
+ "src/screenshot/capture_windows.cpp",
+ "src/screenshot/icons_windows.cpp",
+ "src/screenshot/overlay_ui_windows.cpp",
+ "src/screenshot/overlay_paint_windows.cpp",
+ "src/screenshot/overlay_input_windows.cpp",
+ "src/screenshot/annotations_windows.cpp",
+ "src/screenshot/mosaic_windows.cpp",
+ "src/screenshot/output_windows.cpp",
+ "src/screenshot/lc_match_core.cpp",
+ "src/screenshot/lc_stitch_state.cpp",
+ "src/screenshot/lc_frame_io.cpp",
+ "src/screenshot/lc_panel_ui.cpp",
+ "src/screenshot/lc_toolbar_ui.cpp",
+ "src/screenshot/lc_session.cpp",
+ "src/screenshot/wndproc_windows.cpp",
+ "src/screenshot/session_windows.cpp"
+ ],
"defines": ["_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS"],
"libraries": [
"user32.lib",
diff --git a/index.js b/index.js
index b605274..907c586 100644
--- a/index.js
+++ b/index.js
@@ -491,12 +491,20 @@ class ScreenCapture {
* 启动区域截图
* @param {Object|Function} [options] - 截图选项;直接传函数时按旧签名 start(callback) 处理
* @param {boolean} [options.autoConfirm=true] - 选区确定后直接出图,跳过编辑态(工具栏/标注)
+ * @param {Object} [options.longCapture] - 长截图(手动滚动捕获)参数,进入编辑态后点工具栏「长截图」按钮生效。
+ * 点击后进入长截图预览界面:全屏黑色遮罩保留、选区框与底部工具栏保持展示(仅剩完成/取消,
+ * 其他操作禁用);选区内直通底层应用,滚轮滚动页面(鼠标按键被吞,防止误触),
+ * 侧边小地图实时展示拼接长图(外框=已捕获区域,蓝色内框=当前可见区域,随滚动移动);
+ * 向下滚动追加新内容到底部、向上滚动前插到头部;点「完成」出图、「取消」或 ESC 中止
+ * @param {number} [options.longCapture.maxFrames=100] - 最大拼接帧数(1~200,达到上限自动完成)
+ * @param {number} [options.longCapture.interval=250] - 滚轮停止后等待内容稳定的毫秒数(50~2000,采样防抖;
+ * 滚动进行中也会按不低于 min(interval, 250)ms 的节拍主动采样,保证相邻帧有大重叠区域)
* @param {Function} [callback] - 截图完成时的回调函数
* - 参数: { success: boolean, width?: number, height?: number, base64?: string }
* - success: 是否成功截图
- * - width: 截图宽度(成功时)
- * - height: 截图高度(成功时)
- * - base64: 截图 PNG 的 base64(成功时)
+ * - width: 截图宽度(成功时;长截图为拼接后的总宽度)
+ * - height: 截图高度(成功时;长截图为拼接后的总高度)
+ * - base64: 截图 PNG 的 base64(成功时;长截图已同时写入剪贴板)
*
* @example
* // 默认:框选/点选完成即出图,不再二次编辑
@@ -504,6 +512,12 @@ class ScreenCapture {
*
* // 进入编辑态:选区确定后停留在工具栏,可标注/调整
* ScreenCapture.start({ autoConfirm: false }, (result) => { ... });
+ *
+ * // 编辑态 + 长截图:选区确定后点工具栏「长截图」按钮进入手动滚动捕获
+ * ScreenCapture.start({
+ * autoConfirm: false,
+ * longCapture: { maxFrames: 100, interval: 250 }
+ * }, (result) => { ... });
*/
static start(options, callback) {
if (platform === 'darwin') {
@@ -525,6 +539,17 @@ class ScreenCapture {
callback(result);
});
}
+
+ /**
+ * 中止进行中的长截图滚动捕获(Windows)
+ * 滚动捕获会以失败结果(success: false)回调后结束
+ */
+ static abortLongCapture() {
+ if (platform === 'darwin') {
+ throw new Error('ScreenCapture is not yet supported on macOS');
+ }
+ addon.abortLongCapture();
+ }
}
// 应用图标提取类
diff --git a/scripts/gen-icons.js b/scripts/gen-icons.js
index 7818880..95e42d9 100644
--- a/scripts/gen-icons.js
+++ b/scripts/gen-icons.js
@@ -13,7 +13,7 @@
const fs = require('fs');
const path = require('path');
-// 工具栏按钮 -> SVG 文件名映射(顺序与 src/screenshot_windows.cpp 的 ToolButton 枚举一致)
+// 工具栏按钮 -> SVG 文件名映射(顺序与 src/screenshot/internal.h 的 ToolButton 枚举一致)
// 分隔线(Separator)对应 null,不生成图标。
const ICON_MAP = {
Drag: 'drag',
@@ -24,28 +24,30 @@ const ICON_MAP = {
Mosaic: 'mosaic',
Text: 'text',
Translate: 'translate',
+ LongCapture: 'long-capture',
Undo: 'arrow-back-up',
Redo: 'arrow-forward-up',
Save: 'download',
Cancel: 'x',
Confirm: 'check',
+ // —— 长截图工具栏专用图标(不进 kIconSvgs 数组,长截图窗口按名直取常量)——
+ DirectionV: 'direction-vertical',
+ DirectionH: 'direction-horizontal',
+ AutoScrollV: 'auto-scroll-vertical',
+ AutoScrollH: 'auto-scroll-horizontal',
+ CropIcon: 'crop',
+ // —— 长截图裁剪 popover 子操作图标 ——
+ CropDiscardTop: 'crop-discard-top',
+ CropDiscardBottom: 'crop-discard-bottom',
+ CropDiscardLeft: 'crop-discard-left',
+ CropDiscardRight: 'crop-discard-right',
+ CropReset: 'crop-reset',
};
const ASSETS_DIR = path.join(__dirname, '..', 'src', 'assets');
const OUT_DIR = path.join(__dirname, '..', 'src', 'generated');
const OUT_FILE = path.join(OUT_DIR, 'icon_svgs.h');
-// C 字符串字面量转义:处理 " \ 和换行
-function toCLiteral(str) {
- // 把字符串按行拆开,每行用独立字面量拼接,保证可读性且避免超长行
- return str
- .replace(/\\/g, '\\\\')
- .replace(/"/g, '\\"')
- .split(/\r?\n/)
- .map((line) => ` "${line}"`)
- .join('\n');
-}
-
function main() {
if (!fs.existsSync(ASSETS_DIR)) {
console.error(`✖ assets dir not found: ${ASSETS_DIR}`);
@@ -76,9 +78,9 @@ function main() {
// 所有图标文本的数组(按 ToolButton 枚举顺序,分隔线为 nullptr)
lines.push('// 按 ToolButton 枚举顺序的 SVG 文本数组,分隔线为 nullptr。');
lines.push('static const char* const kIconSvgs[] = {');
- // 顺序:Drag Rect Circle Arrow Brush Mosaic Text Translate SEP1 Undo Redo SEP2 Save Cancel Confirm
+ // 顺序:Drag Rect Circle Arrow Brush Mosaic Text Translate LongCapture SEP1 Undo Redo SEP2 Save Cancel Confirm
lines.push(' kIconSvg_Drag, kIconSvg_Rect, kIconSvg_Circle, kIconSvg_Arrow,');
- lines.push(' kIconSvg_Brush, kIconSvg_Mosaic, kIconSvg_Text, kIconSvg_Translate, nullptr,');
+ lines.push(' kIconSvg_Brush, kIconSvg_Mosaic, kIconSvg_Text, kIconSvg_Translate, kIconSvg_LongCapture, nullptr,');
lines.push(' kIconSvg_Undo, kIconSvg_Redo, nullptr,');
lines.push(' kIconSvg_Save, kIconSvg_Cancel, kIconSvg_Confirm');
lines.push('};');
diff --git a/src/assets/auto-scroll-horizontal.svg b/src/assets/auto-scroll-horizontal.svg
new file mode 100644
index 0000000..62d284b
--- /dev/null
+++ b/src/assets/auto-scroll-horizontal.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/auto-scroll-vertical.svg b/src/assets/auto-scroll-vertical.svg
new file mode 100644
index 0000000..7be4a98
--- /dev/null
+++ b/src/assets/auto-scroll-vertical.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop-discard-bottom.svg b/src/assets/crop-discard-bottom.svg
new file mode 100644
index 0000000..7592062
--- /dev/null
+++ b/src/assets/crop-discard-bottom.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop-discard-left.svg b/src/assets/crop-discard-left.svg
new file mode 100644
index 0000000..a76940a
--- /dev/null
+++ b/src/assets/crop-discard-left.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop-discard-right.svg b/src/assets/crop-discard-right.svg
new file mode 100644
index 0000000..3c3035f
--- /dev/null
+++ b/src/assets/crop-discard-right.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop-discard-top.svg b/src/assets/crop-discard-top.svg
new file mode 100644
index 0000000..efdf1a1
--- /dev/null
+++ b/src/assets/crop-discard-top.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop-reset.svg b/src/assets/crop-reset.svg
new file mode 100644
index 0000000..a7ee04a
--- /dev/null
+++ b/src/assets/crop-reset.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/crop.svg b/src/assets/crop.svg
new file mode 100644
index 0000000..5aeec4b
--- /dev/null
+++ b/src/assets/crop.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/direction-horizontal.svg b/src/assets/direction-horizontal.svg
new file mode 100644
index 0000000..ef6f8ad
--- /dev/null
+++ b/src/assets/direction-horizontal.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/direction-vertical.svg b/src/assets/direction-vertical.svg
new file mode 100644
index 0000000..983e9c4
--- /dev/null
+++ b/src/assets/direction-vertical.svg
@@ -0,0 +1 @@
+
diff --git a/src/assets/long-capture.svg b/src/assets/long-capture.svg
new file mode 100644
index 0000000..6a6c61b
--- /dev/null
+++ b/src/assets/long-capture.svg
@@ -0,0 +1 @@
+
diff --git a/src/binding_windows.cpp b/src/binding_windows.cpp
index bcebe92..724d2bc 100644
--- a/src/binding_windows.cpp
+++ b/src/binding_windows.cpp
@@ -40,7 +40,7 @@
#pragma comment(lib, "dwmapi.lib")
#pragma comment(lib, "uiautomationcore.lib")
-#include "screenshot_windows.h"
+#include "screenshot/screenshot_windows.h"
// DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义
#ifndef DWMWA_CLOAKED
@@ -3972,27 +3972,8 @@ static std::unique_ptr CreateBitmapFromIcon(
return bitmap;
}
-// 获取 PNG 编码器 CLSID
-int GetPngEncoderClsid(CLSID* pClsid) {
- UINT num = 0u;
- UINT size = 0u;
- Gdiplus::GetImageEncodersSize(std::addressof(num), std::addressof(size));
- if (size == 0u) return -1;
-
- std::unique_ptr pImageCodecInfo(
- static_cast(static_cast(new BYTE[size])));
- if (pImageCodecInfo == nullptr) return -1;
-
- Gdiplus::GetImageEncoders(num, size, pImageCodecInfo.get());
-
- for (UINT i = 0u; i < num; i++) {
- if (std::wcscmp(pImageCodecInfo.get()[i].MimeType, L"image/png") == 0) {
- *pClsid = pImageCodecInfo.get()[i].Clsid;
- return (int)i;
- }
- }
- return -1;
-}
+// GetPngEncoderClsid 已迁移至 src/screenshot/output_windows.cpp(CR-019),
+// 此处经 screenshot_windows.h 声明继续复用(含进程内 CLSID 缓存)。
// 将 HICON 转换为 PNG 字节数组
static std::vector HIconToPNG(HICON hIcon) {
@@ -6830,6 +6811,8 @@ Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("startRegionCapture", Napi::Function::New(env, StartRegionCapture));
exports.Set("primeScreenshotFrame", Napi::Function::New(env, PrimeScreenshotFrame));
exports.Set("startRegionCaptureWithPrimedFrame", Napi::Function::New(env, StartRegionCaptureWithPrimedFrame));
+ // 中止进行中的长截图滚动捕获
+ exports.Set("abortLongCapture", Napi::Function::New(env, AbortLongCapture));
exports.Set("getClipboardFiles", Napi::Function::New(env, GetClipboardFiles));
exports.Set("setClipboardFiles", Napi::Function::New(env, SetClipboardFiles));
exports.Set("startMouseMonitor", Napi::Function::New(env, StartMouseMonitor));
diff --git a/src/screenshot/annotations_windows.cpp b/src/screenshot/annotations_windows.cpp
new file mode 100644
index 0000000..96e2601
--- /dev/null
+++ b/src/screenshot/annotations_windows.cpp
@@ -0,0 +1,913 @@
+// 截图模块:标注模型(渲染、几何/命中/变换、撤销重做、文本测量)
+#include "internal.h"
+
+// 判断某工具按钮是否为可绘制矢量工具
+
+bool IsVectorTool(int btn) {
+ return btn == TB_Rect || btn == TB_Circle || btn == TB_Arrow || btn == TB_Brush;
+}
+
+bool IsDragTool(int btn) {
+ return btn == TB_Drag;
+}
+
+bool CanShowStylePopupTool(int btn) {
+ return IsVectorTool(btn) || btn == TB_Text;
+}
+
+// ToolButton -> AnnotationType
+
+AnnotationType ToolToAnnotationType(int btn) {
+ switch (btn) {
+ case TB_Rect: return AT_Rect;
+ case TB_Circle: return AT_Circle;
+ case TB_Arrow: return AT_Arrow;
+ case TB_Brush: return AT_Brush;
+ default: return AT_Rect;
+ }
+}
+
+// AnnotationType -> ToolButton(ToolToAnnotationType 的逆映射)。
+// 用途:选中已有标注时,工具栏回显该标注对应的工具按钮(高亮 + 打开粗细/颜色子菜单)。
+// AT_Mosaic 无对应工具按钮(马赛克标注不可选中),返回 -1。
+
+int AnnotationTypeToTool(AnnotationType t) {
+ switch (t) {
+ case AT_Rect: return TB_Rect;
+ case AT_Circle: return TB_Circle;
+ case AT_Arrow: return TB_Arrow;
+ case AT_Brush: return TB_Brush;
+ case AT_Text: return TB_Text;
+ default: return -1; // AT_Mosaic 等无对应工具按钮
+ }
+}
+
+void PushAnnotationHistory(CaptureContext* ctx) {
+ ctx->undoStack.push_back(ctx->annotations);
+ // 限深:仅保留最近 SC_UNDO_MAX_DEPTH 份整份快照,防止长会话下随操作数平方级累积内存;
+ // 超限时裁掉最老历史(pop_front),最近 50 步撤销不受影响。
+ if ((int)ctx->undoStack.size() > SC_UNDO_MAX_DEPTH) {
+ ctx->undoStack.pop_front();
+ }
+ ctx->redoStack.clear();
+}
+
+static void ResetAnnotationInteraction(CaptureContext* ctx) {
+ ctx->selectedTextAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ ctx->draggingTextAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->draggingAnnotation = -1;
+ ctx->resizingAnnotation = -1;
+ ctx->annotationResizeHandle = RH_None;
+ ctx->annotationOpHistoryPushed = false;
+ ctx->hasLastAnnotationBox = false;
+}
+
+bool UndoAnnotations(CaptureContext* ctx) {
+ if (ctx->undoStack.empty()) return false;
+ ctx->redoStack.push_back(ctx->annotations);
+ ctx->annotations = ctx->undoStack.back();
+ ctx->undoStack.pop_back();
+ ResetAnnotationInteraction(ctx);
+ return true;
+}
+
+bool RedoAnnotations(CaptureContext* ctx) {
+ if (ctx->redoStack.empty()) return false;
+ ctx->undoStack.push_back(ctx->annotations);
+ ctx->annotations = ctx->redoStack.back();
+ ctx->redoStack.pop_back();
+ ResetAnnotationInteraction(ctx);
+ return true;
+}
+
+// ==================== 标注绘制(GDI+ 抗锯齿) ====================
+
+// 绘制单条标注(不含 clip)。
+// 标注坐标为绝对虚拟屏幕坐标;ox/oy 为绘制偏移,把绝对坐标换算到目标 DC 的局部坐标:
+// - 覆盖层(backDC):ox/oy = -virtualX/-virtualY(backDC 原点 = 虚拟屏幕左上角)。
+// - 合成(finalDC):ox/oy = -rect.left/-rect.top(finalDC 原点 = 选区左上角)。
+static void DrawOneAnnotation(Gdiplus::Graphics& graphics, const Annotation& a,
+ float ox, float oy) {
+ Gdiplus::Color c(GetRValue(a.color), GetGValue(a.color), GetBValue(a.color));
+ float thick = (float)a.thickness;
+ if (thick < 1.0f) thick = 1.0f;
+ Gdiplus::Pen pen(c, thick);
+ pen.SetLineJoin(Gdiplus::LineJoinRound);
+ pen.SetStartCap(Gdiplus::LineCapRound);
+ pen.SetEndCap(Gdiplus::LineCapRound);
+ Gdiplus::SolidBrush brush(c);
+
+ switch (a.type) {
+ case AT_Rect: {
+ float x = a.x1 + ox;
+ float y = a.y1 + oy;
+ float w = (float)(a.x2 - a.x1);
+ float h = (float)(a.y2 - a.y1);
+ graphics.DrawRectangle(&pen, (std::min)(x, x + w), (std::min)(y, y + h),
+ std::fabs(w), std::fabs(h));
+ break;
+ }
+ case AT_Circle: {
+ float x = a.x1 + ox;
+ float y = a.y1 + oy;
+ float w = (float)(a.x2 - a.x1);
+ float h = (float)(a.y2 - a.y1);
+ graphics.DrawEllipse(&pen, (std::min)(x, x + w), (std::min)(y, y + h),
+ std::fabs(w), std::fabs(h));
+ break;
+ }
+ case AT_Arrow: {
+ float sx = a.x1 + ox;
+ float sy = a.y1 + oy;
+ float ex = a.x2 + ox;
+ float ey = a.y2 + oy;
+ float dx = ex - sx, dy = ey - sy;
+ float len = std::sqrt(dx * dx + dy * dy);
+ if (len < 1.0f) break;
+ // 箭头几何:机翼状——底边内凹(向尖端方向凹入 notch),两翼后掠,
+ // 从箭身末端张开,呈「细尾→锥杆→张开两翼→尖」的箭矢形态。
+ float headLen = thick * 4.0f + 8.0f;
+ float headHalfW = thick * 2.4f + 5.0f;
+ float notch = headLen * 0.4f; // 内凹深度:底边中点向尖端凹入
+ if (headLen > len) headLen = len * 0.6f;
+ float ux = dx / len, uy = dy / len;
+ float nx = -uy, ny = ux;
+ // 箭头底部中心(沿箭头方向后退 headLen)与内凹点
+ float baseX = ex - ux * headLen;
+ float baseY = ey - uy * headLen;
+ float notchX = baseX + ux * notch;
+ float notchY = baseY + uy * notch;
+ // 箭身:起点细、终点粗的锥形。终点延伸至内凹点并略超出(overlap),
+ // 由箭头覆盖重叠区,避免抗锯齿在拼接处留细缝;终点宽 < 两翼宽,
+ // 使两翼从箭身末端明显张开、内凹缺口清晰可见。
+ float startHalfW = (std::max)(thick * 0.5f, 0.75f);
+ float endHalfW = headHalfW * 0.55f;
+ float overlap = 1.5f;
+ float bodyEndX = notchX + ux * overlap;
+ float bodyEndY = notchY + uy * overlap;
+ Gdiplus::PointF body[4] = {
+ Gdiplus::PointF(sx + nx * startHalfW, sy + ny * startHalfW),
+ Gdiplus::PointF(sx - nx * startHalfW, sy - ny * startHalfW),
+ Gdiplus::PointF(bodyEndX - nx * endHalfW, bodyEndY - ny * endHalfW),
+ Gdiplus::PointF(bodyEndX + nx * endHalfW, bodyEndY + ny * endHalfW),
+ };
+ graphics.FillPolygon(&brush, body, 4);
+ // 机翼状箭头:尖 → 右翼 → 内凹点 → 左翼(底边内凹而非平直三角)
+ Gdiplus::PointF head[4] = {
+ Gdiplus::PointF(ex, ey),
+ Gdiplus::PointF(baseX + nx * headHalfW, baseY + ny * headHalfW),
+ Gdiplus::PointF(notchX, notchY),
+ Gdiplus::PointF(baseX - nx * headHalfW, baseY - ny * headHalfW),
+ };
+ graphics.FillPolygon(&brush, head, 4);
+ break;
+ }
+ case AT_Brush: {
+ if (a.pts.size() < 2) break;
+ std::vector pts;
+ pts.reserve(a.pts.size());
+ for (const POINT& p : a.pts) {
+ pts.push_back(Gdiplus::PointF((float)(p.x) + ox, (float)(p.y) + oy));
+ }
+ graphics.DrawLines(&pen, pts.data(), (INT)pts.size());
+ break;
+ }
+ case AT_Text: {
+ if (a.text.empty()) break;
+ // 字号 = thickness;用 GDI+ 字体绘制,顶部对齐到锚点 y1
+ int fontPx = a.thickness;
+ if (fontPx < 8) fontPx = 8;
+ Gdiplus::FontFamily fontFamily(SC_FONT_FACE);
+ Gdiplus::Font font(&fontFamily, (Gdiplus::REAL)fontPx, Gdiplus::FontStyleRegular,
+ Gdiplus::UnitPixel);
+ Gdiplus::SolidBrush textBrush(c);
+ Gdiplus::StringFormat sf;
+ sf.SetAlignment(Gdiplus::StringAlignmentNear);
+ sf.SetLineAlignment(Gdiplus::StringAlignmentNear);
+ // 用 MeasureString 得到合适布局矩形(避免被裁)
+ Gdiplus::RectF layoutRect((Gdiplus::REAL)(a.x1 + ox), (Gdiplus::REAL)(a.y1 + oy),
+ 10000.0f, (Gdiplus::REAL)(fontPx * 2));
+ graphics.DrawString(a.text.c_str(), -1, &font, layoutRect, &sf, &textBrush);
+ break;
+ }
+ }
+}
+
+// 覆盖层渲染矢量/文字标注(不含马赛克,马赛克由 reveal-mask 单独处理)。
+// selRel:选区在 backDC 局部坐标的矩形;标注为绝对虚拟屏幕坐标,偏移 = -virtualX/-virtualY。
+// 用 SetClip 限制到选区内部,避免画到遮罩区。
+
+void DrawAnnotations(HDC hdc, const RECT& selRel, int virtualX, int virtualY,
+ const std::vector& annotations,
+ const Annotation* curDrawing) {
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
+ {
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ // 限制绘制范围在选区内
+ Gdiplus::Rect clipRect(selRel.left, selRel.top,
+ selRel.right - selRel.left,
+ selRel.bottom - selRel.top);
+ // 限制绘制范围在选区内(与选区矩形求交;局部帧时还会与 backDC 裁剪区 dirtyRect 求交,
+ // 因 Graphics(backDC) 继承 GDI 裁剪区,CombineModeIntersect 保证标注不超出 dirtyRect∩选区)。
+ graphics.SetClip(clipRect, Gdiplus::CombineModeIntersect);
+
+ float ox = (float)-virtualX;
+ float oy = (float)-virtualY;
+
+ for (const Annotation& a : annotations) {
+ if (a.type == AT_Mosaic) continue; // 马赛克单独渲染
+ DrawOneAnnotation(graphics, a, ox, oy);
+ }
+ if (curDrawing && curDrawing->type != AT_Mosaic) {
+ DrawOneAnnotation(graphics, *curDrawing, ox, oy);
+ }
+ }
+}
+
+// 合成标注进最终 PNG:finalDC 原点 = 选区左上角,故偏移 = -rect.left/-rect.top。
+// srcDC = memDC(原始屏幕位图,物理像素),用于马赛克像素化取源。
+// mosaicBlockPx:马赛克块大小(与编辑器当前全局块大小保持一致,保证导出与所见一致)。
+
+void CompositeAnnotations(HDC finalDC, HDC srcDC,
+ const std::vector& annotations,
+ const RECT& rect, int virtualX, int virtualY,
+ double dpiScale, int mosaicBlockPx) {
+ if (annotations.empty()) return;
+
+ // 马赛克先渲染到底图上,后续矢量/文字标注保持清晰覆盖在其上方。
+ if (HasMosaicToRender(annotations, nullptr)) {
+ int blockPx = mosaicBlockPx;
+ if (blockPx < 2) blockPx = 2;
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ HDC screenDC = GetDC(NULL);
+ if (screenDC) {
+ HDC baseDC = CreateCompatibleDC(screenDC);
+ HBITMAP baseBmp = baseDC ? CreateCompatibleBitmap(screenDC, w, h) : NULL;
+ if (baseDC && baseBmp) {
+ HGDIOBJ oldBase = SelectObject(baseDC, baseBmp);
+ bool generated = oldBase && oldBase != HGDI_ERROR
+ && MosaicBlitRect(baseDC, srcDC, 0, 0, w, h,
+ rect.left, rect.top, blockPx,
+ virtualX, virtualY, dpiScale);
+ if (generated) {
+ // 揭示蒙版区域(finalDC 原点 = 选区左上角,base 同为选区相对,ox=-rect.left)
+ float ox = (float)-rect.left;
+ float oy = (float)-rect.top;
+ RevealMosaicToTarget(finalDC, baseDC, annotations, nullptr,
+ RECT{0, 0, w, h}, ox, oy);
+ }
+ if (oldBase && oldBase != HGDI_ERROR) SelectObject(baseDC, oldBase);
+ }
+ if (baseBmp) DeleteObject(baseBmp);
+ if (baseDC) DeleteDC(baseDC);
+ ReleaseDC(NULL, screenDC);
+ }
+ }
+
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
+ {
+ Gdiplus::Graphics graphics(finalDC);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ float ox = (float)-rect.left;
+ float oy = (float)-rect.top;
+ for (const Annotation& a : annotations) {
+ if (a.type == AT_Mosaic) continue; // 马赛克单独渲染
+ DrawOneAnnotation(graphics, a, ox, oy);
+ }
+ }
+}
+
+static RECT MeasureArrowAnnotationBounds(const Annotation& a) {
+ float sx = (float)a.x1;
+ float sy = (float)a.y1;
+ float ex = (float)a.x2;
+ float ey = (float)a.y2;
+ float minX = (std::min)(sx, ex);
+ float minY = (std::min)(sy, ey);
+ float maxX = (std::max)(sx, ex);
+ float maxY = (std::max)(sy, ey);
+
+ float thick = (float)a.thickness;
+ if (thick < 1.0f) thick = 1.0f;
+ float dx = ex - sx;
+ float dy = ey - sy;
+ float len = std::sqrt(dx * dx + dy * dy);
+ if (len >= 1.0f) {
+ float headLen = thick * 4.0f + 8.0f;
+ float headHalfW = thick * 2.4f + 5.0f;
+ if (headLen > len) headLen = len * 0.6f;
+ float notch = headLen * 0.4f;
+ float ux = dx / len;
+ float uy = dy / len;
+ float nx = -uy;
+ float ny = ux;
+ float baseX = ex - ux * headLen;
+ float baseY = ey - uy * headLen;
+ float notchX = baseX + ux * notch;
+ float notchY = baseY + uy * notch;
+ float startHalfW = (std::max)(thick * 0.5f, 0.75f);
+ float endHalfW = headHalfW * 0.55f;
+ float overlap = 1.5f;
+ float bodyEndX = notchX + ux * overlap;
+ float bodyEndY = notchY + uy * overlap;
+
+ auto expand = [&](float x, float y) {
+ if (x < minX) minX = x;
+ if (y < minY) minY = y;
+ if (x > maxX) maxX = x;
+ if (y > maxY) maxY = y;
+ };
+ expand(sx + nx * startHalfW, sy + ny * startHalfW);
+ expand(sx - nx * startHalfW, sy - ny * startHalfW);
+ expand(bodyEndX - nx * endHalfW, bodyEndY - ny * endHalfW);
+ expand(bodyEndX + nx * endHalfW, bodyEndY + ny * endHalfW);
+ expand(baseX + nx * headHalfW, baseY + ny * headHalfW);
+ expand(notchX, notchY);
+ expand(baseX - nx * headHalfW, baseY - ny * headHalfW);
+ }
+
+ const float margin = 2.0f;
+ return { (int)floorf(minX - margin), (int)floorf(minY - margin),
+ (int)ceilf(maxX + margin), (int)ceilf(maxY + margin) };
+}
+
+// 计算所有标注内容的包围盒(选区相对逻辑坐标)。
+// 用于限制选区缩放:选区不可缩小到裁掉已添加内容。
+// 返回 false 表示无标注(无约束)。
+// 计算所有标注内容的包围盒(绝对虚拟屏幕坐标)。
+// 用于限制选区缩放:选区不可缩小到裁掉已添加内容。
+// 返回 false 表示无标注(无约束)。hdc 用于测量文字宽高。
+// 非常量引用:AT_Text 分支会经 MeasureTextAnnotation 回填文字测量缓存。
+
+bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc) {
+ if (anns.empty()) return false;
+ int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
+ auto expand = [&](int x, int y) {
+ if (x < minL) minL = x;
+ if (y < minT) minT = y;
+ if (x > maxR) maxR = x;
+ if (y > maxB) maxB = y;
+ };
+ for (Annotation& a : anns) {
+ if (a.type == AT_Brush) {
+ for (const POINT& p : a.pts) expand(p.x, p.y);
+ } else if (a.type == AT_Mosaic) {
+ if (a.mosaicRect) {
+ // 框选模式:矩形角点
+ expand(a.x1, a.y1);
+ expand(a.x2, a.y2);
+ } else {
+ // 涂抹模式:路径包围盒 + 半径
+ int r = a.brushRadius;
+ for (const POINT& p : a.pts) {
+ expand(p.x - r, p.y - r);
+ expand(p.x + r, p.y + r);
+ }
+ }
+ } else if (a.type == AT_Text) {
+ // 复用 MeasureTextAnnotation:与选中时的外边框(含 padding)完全一致,
+ // 保证 resize 选区时文字包围盒约束 = 视觉选中边框,不会被裁掉。
+ RECT r = MeasureTextAnnotation(hdc, a);
+ expand(r.left, r.top);
+ expand(r.right, r.bottom);
+ } else if (a.type == AT_Arrow) {
+ RECT r = MeasureArrowAnnotationBounds(a);
+ expand(r.left, r.top);
+ expand(r.right, r.bottom);
+ } else {
+ expand(a.x1, a.y1);
+ expand(a.x2, a.y2);
+ }
+ }
+ if (minL == INT_MAX) return false;
+ out.left = minL;
+ out.top = minT;
+ out.right = maxR;
+ out.bottom = maxB;
+ return true;
+}
+
+// 测量文字标注的包围盒(绝对虚拟屏幕坐标)
+// 用 GDI+ MeasureString 测量,与提交态 DrawString 渲染同源:
+// - 字形左上角相对锚点 (x1,y1) 有内部偏移 (offX, offY)
+// - 宽高为紧凑字形范围(不含 GDI GetTextExtentPoint32W 的尾部 overhang)
+// - 边框左右 padding 对称,完整包住文字
+// 性能:测量结果只依赖 (text, fontPx),与锚点无关,故缓存"相对锚点的偏移/尺寸"。
+// 命中缓存(textCacheValid && textCacheFontPx == fontPx)时直接复用,跳过 GDI+ 测量;
+// 锚点变化(TransformAnnotationByBox 平移)不影响缓存有效性。
+// 缓存对象(FontFamily/StringFormat/Font)取自会话级 InitGdipResources(经 g_captureCtx)。
+
+RECT MeasureTextAnnotation(HDC hdc, Annotation& a) {
+ RECT rect = { a.x1, a.y1, a.x1, a.y1 };
+ if (a.type != AT_Text || a.text.empty()) return rect;
+
+ int fontPx = a.thickness;
+ if (fontPx < 8) fontPx = 8;
+
+ float offX = 0, offY = 0, w = 0, h = 0;
+ // 缓存命中:直接复用相对锚点的偏移/尺寸(与 fontPx 校验)
+ if (a.textCacheValid && a.textCacheFontPx == fontPx) {
+ offX = a.textCacheOffX;
+ offY = a.textCacheOffY;
+ w = a.textCacheW;
+ h = a.textCacheH;
+ } else if (g_captureCtx && g_captureCtx->gdipInited
+ && g_captureCtx->gdipFontFamily && g_captureCtx->gdipStrFmt) {
+ // 缓存未命中:做一次 GDI+ 测量并回填缓存
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
+ Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
+ Gdiplus::StringFormat* sf = g_captureCtx->gdipStrFmt;
+ Gdiplus::RectF origin(0, 0, 0, 0);
+ Gdiplus::RectF bounds;
+ graphics.MeasureString(a.text.c_str(), (INT)a.text.size(), font, origin, sf, &bounds);
+ offX = bounds.X;
+ offY = bounds.Y;
+ w = bounds.Width;
+ h = bounds.Height;
+ a.textCacheValid = true;
+ a.textCacheFontPx = fontPx;
+ a.textCacheOffX = offX;
+ a.textCacheOffY = offY;
+ a.textCacheW = w;
+ a.textCacheH = h;
+ }
+
+ const int padding = 4;
+ // 字形左上角(绝对坐标)= 锚点 + GDI+ 内部偏移
+ int glyphLeft = a.x1 + (int)floorf(offX);
+ int glyphTop = a.y1 + (int)floorf(offY);
+ int glyphRight = a.x1 + (int)ceilf(offX + w);
+ int glyphBottom = a.y1 + (int)ceilf(offY + h);
+ rect.left = glyphLeft - padding;
+ rect.top = glyphTop - padding;
+ rect.right = glyphRight + padding;
+ rect.bottom = glyphBottom + padding;
+ return rect;
+}
+
+// 命中测试文字标注,返回标注索引(-1 表示未命中)
+// 非常量引用:MeasureTextAnnotation 会回填文字测量缓存。
+
+int HitTestTextAnnotations(std::vector& anns, int x, int y, HDC hdc) {
+ for (int i = (int)anns.size() - 1; i >= 0; i--) {
+ if (anns[i].type == AT_Text) {
+ RECT rect = MeasureTextAnnotation(hdc, anns[i]);
+ if (PointInRect(x, y, rect)) {
+ return i;
+ }
+ }
+ }
+ return -1;
+}
+
+// ==================== 通用标注几何(选中/拖拽/缩放) ====================
+// 以下函数把「仅文字标注」具备的 hover/选中/拖拽/缩放能力推广到所有标注类型。
+// 坐标系与 Annotation 一致:绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同帧)。
+
+// 点 (px,py) 到线段 (ax,ay)-(bx,by) 的最短距离(像素)。
+// 用于画笔/箭头/马赛克涂抹等线条型标注的命中检测。
+
+static double PointToSegmentDist(double px, double py, double ax, double ay, double bx, double by) {
+ double dx = bx - ax, dy = by - ay;
+ double lenSq = dx * dx + dy * dy;
+ double t = 0;
+ if (lenSq > 1e-9) {
+ t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
+ if (t < 0) t = 0;
+ else if (t > 1) t = 1;
+ }
+ double cx = ax + t * dx;
+ double cy = ay + t * dy;
+ double ex = px - cx, ey = py - cy;
+ return std::sqrt(ex * ex + ey * ey);
+}
+
+// 点 (px,py) 到折线 pts 的最短距离(像素)。
+
+static double PointToPolylineDist(double px, double py, const std::vector& pts) {
+ if (pts.empty()) return 1e18;
+ if (pts.size() == 1) {
+ double ex = px - pts[0].x, ey = py - pts[0].y;
+ return std::sqrt(ex * ex + ey * ey);
+ }
+ double best = 1e18;
+ for (size_t i = 0; i + 1 < pts.size(); i++) {
+ double d = PointToSegmentDist(px, py, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
+ if (d < best) best = d;
+ }
+ return best;
+}
+
+// 计算单个标注的包围盒(绝对虚拟屏幕坐标)。
+// 返回的矩形完整包住标注可见区域(含线宽/半径/文字 padding),用于选中框与四角 resize 手柄定位。
+// hdc 仅在 AT_Text 时用于 GDI+ 测量文字宽高。
+// 非常量引用:AT_Text 分支会经 MeasureTextAnnotation 回填文字测量缓存。
+
+RECT MeasureAnnotationBounds(Annotation& a, HDC hdc) {
+ RECT r = { 0, 0, 0, 0 };
+ auto setBox = [&](int x1, int y1, int x2, int y2) {
+ r.left = (std::min)(x1, x2);
+ r.top = (std::min)(y1, y2);
+ r.right = (std::max)(x1, x2);
+ r.bottom = (std::max)(y1, y2);
+ };
+ switch (a.type) {
+ case AT_Rect:
+ case AT_Circle:
+ case AT_Mosaic:
+ if (a.type == AT_Mosaic && !a.mosaicRect) {
+ // 涂抹模式:路径包围盒 + 半径
+ if (a.pts.empty()) { r = { 0,0,0,0 }; return r; }
+ int rad = a.brushRadius;
+ int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
+ for (const POINT& p : a.pts) {
+ if (p.x < minL) minL = p.x;
+ if (p.x > maxR) maxR = p.x;
+ if (p.y < minT) minT = p.y;
+ if (p.y > maxB) maxB = p.y;
+ }
+ r.left = minL - rad; r.top = minT - rad;
+ r.right = maxR + rad; r.bottom = maxB + rad;
+ return r;
+ }
+ // Rect/Circle/MosaicRect:以两个对角点为包围盒
+ setBox(a.x1, a.y1, a.x2, a.y2);
+ return r;
+ case AT_Arrow:
+ return MeasureArrowAnnotationBounds(a);
+ case AT_Brush: {
+ if (a.pts.empty()) { r = { 0,0,0,0 }; return r; }
+ int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
+ for (const POINT& p : a.pts) {
+ if (p.x < minL) minL = p.x;
+ if (p.x > maxR) maxR = p.x;
+ if (p.y < minT) minT = p.y;
+ if (p.y > maxB) maxB = p.y;
+ }
+ r.left = minL; r.top = minT; r.right = maxR; r.bottom = maxB;
+ return r;
+ }
+ case AT_Text:
+ return MeasureTextAnnotation(hdc, a);
+ }
+ return r;
+}
+
+// 命中测试任意标注,返回索引(-1 表示未命中)。
+// 从顶层(数组末尾,绘制最上层)向底层遍历,命中第一个即返回(与视觉 z-order 一致)。
+// 容差按线宽自适应:细线给 6px 余量,粗线给半个线宽,避免细线难以点中。
+// 非常量引用:AT_Text 分支会回填文字测量缓存。
+// AT_Mosaic 在循环顶部 continue 跳过(马赛克不可选中),故 switch 内无 AT_Mosaic case。
+
+int HitTestAnnotation(std::vector& anns, int x, int y, HDC hdc) {
+ for (int i = (int)anns.size() - 1; i >= 0; i--) {
+ Annotation& a = anns[i];
+ // 马赛克区域(框选/涂抹)不可选中、不可拖拽,直接跳过命中测试。
+ if (a.type == AT_Mosaic) continue;
+ double tol = (std::max)(6.0, a.thickness / 2.0 + 2.0);
+ switch (a.type) {
+ case AT_Rect: {
+ // 仅命中矩形四条边轮廓(空心框),内部空白不选中。
+ // 到任一边线段的最短距离 ≤ tol 即命中(容差向外扩散几像素辅助探测)。
+ double d1 = PointToSegmentDist((double)x, (double)y, a.x1, a.y1, a.x2, a.y1);
+ double d2 = PointToSegmentDist((double)x, (double)y, a.x2, a.y2, a.x1, a.y2);
+ double d3 = PointToSegmentDist((double)x, (double)y, a.x1, a.y2, a.x1, a.y1);
+ double d4 = PointToSegmentDist((double)x, (double)y, a.x2, a.y1, a.x2, a.y2);
+ double dm = (std::min)((std::min)(d1, d2), (std::min)(d3, d4));
+ if (dm <= tol) return i;
+ break;
+ }
+ case AT_Circle: {
+ // 椭圆(由包围盒定义)轮廓命中:用归一化径向距离近似。
+ // r = hypot((x-cx)/a, (y-cy)/b),r≈1 即落在椭圆上。
+ double cx = (a.x1 + a.x2) * 0.5;
+ double cy = (a.y1 + a.y2) * 0.5;
+ double aax = std::fabs((double)a.x2 - a.x1) * 0.5;
+ double aay = std::fabs((double)a.y2 - a.y1) * 0.5;
+ if (aax < 0.5 && aay < 0.5) {
+ // 退化为点:直接点距
+ double ex = x - cx, ey = y - cy;
+ if (std::sqrt(ex * ex + ey * ey) <= tol) return i;
+ } else if (aax < 0.5) {
+ // 退化为竖直线段
+ if (PointToSegmentDist((double)x, (double)y, cx, a.y1, cx, a.y2) <= tol) return i;
+ } else if (aay < 0.5) {
+ // 退化为水平线段
+ if (PointToSegmentDist((double)x, (double)y, a.x1, cy, a.x2, cy) <= tol) return i;
+ } else {
+ double r = std::sqrt(((x - cx) / aax) * ((x - cx) / aax)
+ + ((y - cy) / aay) * ((y - cy) / aay));
+ // (r-1)*min(a,b) 把归一化距离换算回像素(用短半轴近似像素半径,保守且足够命中探测)
+ double minAxis = (std::min)(aax, aay);
+ if (std::fabs(r - 1.0) * minAxis <= tol) return i;
+ }
+ break;
+ }
+ case AT_Arrow: {
+ if (PointToSegmentDist((double)x, (double)y, a.x1, a.y1, a.x2, a.y2) <= tol)
+ return i;
+ break;
+ }
+ case AT_Brush: {
+ if (PointToPolylineDist((double)x, (double)y, a.pts) <= tol) return i;
+ break;
+ }
+ case AT_Text: {
+ RECT box = MeasureTextAnnotation(hdc, a);
+ if (PointInRect(x, y, box)) return i;
+ break;
+ }
+ }
+ }
+ return -1;
+}
+
+// 命中测试标注包围盒的 8 个手柄(4 角 + 4 边中点),返回 ResizeHandle 或 RH_None。
+// 容差沿用选区手柄的 handleSize,保证与选区手柄一致的可点击范围。
+
+static int HitTestAnnotationHandle(int x, int y, const RECT& box, int handleSize) {
+ int hs = handleSize;
+ int cx = (box.left + box.right) / 2;
+ int cy = (box.top + box.bottom) / 2;
+ struct { int hx, hy; int handle; } tests[] = {
+ { box.left, box.top, RH_TopLeft },
+ { box.right, box.top, RH_TopRight },
+ { box.left, box.bottom, RH_BottomLeft },
+ { box.right, box.bottom, RH_BottomRight },
+ { cx, box.top, RH_Top },
+ { cx, box.bottom, RH_Bottom },
+ { box.left, cy, RH_Left },
+ { box.right, cy, RH_Right },
+ };
+ for (auto& t : tests) {
+ RECT hitBox = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
+ if (PointInRect(x, y, hitBox)) return t.handle;
+ }
+ return RH_None;
+}
+
+// 命中测试箭头的起点/终点端点手柄,返回 RH_ArrowStart / RH_ArrowEnd / RH_None。
+// 箭头只允许拖拽两个端点(而非四角包围盒缩放),故单独命中 a.x1,y1 / a.x2,y2。
+// 容差沿用 handleSize,与其它标注手柄可点击范围一致。
+
+static int HitTestArrowEndpoints(int x, int y, const Annotation& a, int handleSize) {
+ int hs = handleSize;
+ struct { int hx, hy; int handle; } tests[] = {
+ { a.x1, a.y1, RH_ArrowStart },
+ { a.x2, a.y2, RH_ArrowEnd },
+ };
+ for (auto& t : tests) {
+ RECT hitBox = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
+ if (PointInRect(x, y, hitBox)) return t.handle;
+ }
+ return RH_None;
+}
+
+// 按标注类型返回其缩放手柄命中(统一入口,供 LButtonDown/MouseMove/SETCURSOR 复用):
+// 箭头 = 起点/终点 2 端点;矩形/圆 = 包围盒 8 手柄(4 角 + 4 边中点);
+// 画笔/马赛克 = 无手柄(RH_None,不可缩放,画笔仅可整体拖动)。
+// hdc 仅用于测量文字包围盒(此处文字不会进入,但签名与 HitTestAnnotation 对齐便于扩展)。
+
+int HitTestAnnotationResizeHandle(const Annotation& a, int x, int y, HDC hdc, int handleSize) {
+ switch (a.type) {
+ case AT_Arrow:
+ return HitTestArrowEndpoints(x, y, a, handleSize);
+ case AT_Rect:
+ case AT_Circle: {
+ RECT box = MeasureAnnotationBounds(const_cast(a), hdc);
+ return HitTestAnnotationHandle(x, y, box, handleSize);
+ }
+ default:
+ // AT_Brush / AT_Mosaic / AT_Text 无缩放手柄
+ return RH_None;
+ }
+}
+
+// 按包围盒变换(平移 + 等比/非等比缩放)映射标注所有坐标。
+// oldBox -> newBox:标注内每个点 p 映射为 newBox.left + (p-oldBox.left)*sx 等。
+// 用于四角 resize(画笔/涂抹路径按包围盒整体缩放,保持形状比例)。
+// sx/sy 防 0:旧宽/高为 0 时退化为平移。
+
+void TransformAnnotationByBox(Annotation& a, const RECT& oldBox, const RECT& newBox) {
+ double oldW = oldBox.right - oldBox.left;
+ double oldH = oldBox.bottom - oldBox.top;
+ double newW = newBox.right - newBox.left;
+ double newH = newBox.bottom - newBox.top;
+ double sx = (oldW > 0.5) ? newW / oldW : 1.0;
+ double sy = (oldH > 0.5) ? newH / oldH : 1.0;
+ auto mapX = [&](int v) { return (int)(newBox.left + (v - oldBox.left) * sx + 0.5); };
+ auto mapY = [&](int v) { return (int)(newBox.top + (v - oldBox.top) * sy + 0.5); };
+ switch (a.type) {
+ case AT_Rect:
+ case AT_Circle:
+ case AT_Arrow:
+ case AT_Mosaic:
+ if (a.type == AT_Mosaic && !a.mosaicRect) {
+ for (POINT& p : a.pts) { p.x = mapX(p.x); p.y = mapY(p.y); }
+ } else {
+ a.x1 = mapX(a.x1); a.y1 = mapY(a.y1);
+ a.x2 = mapX(a.x2); a.y2 = mapY(a.y2);
+ }
+ break;
+ case AT_Brush:
+ for (POINT& p : a.pts) { p.x = mapX(p.x); p.y = mapY(p.y); }
+ break;
+ case AT_Text:
+ // 文字不经过此路径(走原 draggingTextAnnotation 平移),此处兜底平移锚点
+ a.x1 = mapX(a.x1); a.y1 = mapY(a.y1);
+ break;
+ }
+}
+
+// ==================== GDI+ 文字测量(与提交态渲染同源) ====================
+// 提交态文字用 GDI+ DrawString 渲染(StringAlignmentNear 顶部左对齐)。
+// 为保证输入态边框/光标/命中与提交态视觉一致,输入态也必须用 GDI+ 测量。
+// 旧实现用 GDI GetTextExtentPoint32W,其宽度含尾部 overhang(右侧多出留白),
+// 且垂直基线与 GDI+ 不同(导致文字偏靠下),故统一改用 GDI+。
+
+// 测量文字的紧凑包围盒:返回 DrawString(StringAlignmentNear/Near) 下字形相对锚点的偏移与尺寸。
+// outOffsetX/outOffsetY:字形左上角相对锚点 (0,0) 的偏移(通常 X≈0,Y 为字体内部顶部 leading)。
+// outW/outH:字形紧凑宽高。
+// 这样边框 = 锚点 + offset ± padding,可左右对称、紧贴字形。
+
+void MeasureTextGdip(HDC hdc, const std::wstring& text, int fontPx,
+ float& outOffsetX, float& outOffsetY, float& outW, float& outH) {
+ outOffsetX = 0; outOffsetY = 0; outW = 0; outH = 0;
+ if (text.empty()) {
+ outH = (float)fontPx;
+ return;
+ }
+ // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
+ if (!g_captureCtx || !g_captureCtx->gdipInited
+ || !g_captureCtx->gdipFontFamily || !g_captureCtx->gdipStrFmt) return;
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
+ Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
+ Gdiplus::StringFormat* sf = g_captureCtx->gdipStrFmt;
+ // MeasureString 返回与 DrawString 一致的布局包围盒(含 GDI+ 的字体内部间距)。
+ Gdiplus::RectF origin(0, 0, 0, 0);
+ Gdiplus::RectF bounds;
+ graphics.MeasureString(text.c_str(), (INT)text.size(), font, origin, sf, &bounds);
+ outOffsetX = bounds.X;
+ outOffsetY = bounds.Y;
+ outW = bounds.Width;
+ outH = bounds.Height;
+}
+
+// 测量逐字符累计宽度(用于光标定位/选中高亮)。
+// widths[i] 表示前 i 个字符(text[0..i-1])的累计紧凑宽度,widths[0]=0。
+// 与 DrawString 渲染进度一致,光标 x = textX + widths[i]。
+// P2 优化:原实现逐前缀循环调 MeasureString(O(n²)),改用 MeasureCharacterRanges
+// 一次测出各前缀边界(O(n))。MeasureCharacterRanges 会通过 SetMeasurableCharacterRanges
+// 修改 StringFormat,故不能就地改会话共享的 ctx->gdipStrFmt,这里栈上复制一份使用。
+
+static void MeasureCharWidthsGdip(HDC hdc, const std::wstring& text, int fontPx,
+ std::vector& widths) {
+ widths.assign(text.size() + 1, 0.0f);
+ if (text.empty()) return;
+ // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
+ if (!g_captureCtx || !g_captureCtx->gdipInited
+ || !g_captureCtx->gdipFontFamily || !g_captureCtx->gdipStrFmt) return;
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
+ Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
+ // 复制会话级 StringFormat(Near/Near),在其上设置可测范围,避免污染共享对象。
+ Gdiplus::StringFormat localSf(g_captureCtx->gdipStrFmt);
+
+ const int n = (int)text.size();
+ // 布局矩形足够大以容纳整串,避免 Near/Near 文本换行或裁剪。
+ Gdiplus::RectF layoutRect(0, 0, 1000000.0f, 1000000.0f);
+
+ // SetMeasurableCharacterRanges 单次可设范围数受 GDI+ 内部缓冲限制,分批处理。
+ const int kBatch = 16;
+ Gdiplus::CharacterRange ranges[kBatch];
+ Gdiplus::Region regions[kBatch];
+ for (int start = 1; start <= n; start += kBatch) {
+ int cnt = (n - start + 1 < kBatch) ? (n - start + 1) : kBatch;
+ for (int j = 0; j < cnt; j++) {
+ int i = start + j; // 前缀长度 i (1..n)
+ ranges[j].First = 0;
+ ranges[j].Length = i;
+ }
+ if (localSf.SetMeasurableCharacterRanges(cnt, ranges) != Gdiplus::Ok) continue;
+ if (graphics.MeasureCharacterRanges(text.c_str(), n, font, layoutRect,
+ &localSf, cnt, regions) != Gdiplus::Ok) continue;
+ for (int j = 0; j < cnt; j++) {
+ int i = start + j;
+ Gdiplus::RectF bounds;
+ // 前 i 个字符区域的右边缘 = 原 MeasureString(prefix) 的 bounds.X + bounds.Width。
+ if (regions[j].GetBounds(&bounds, &graphics) == Gdiplus::Ok) {
+ widths[i] = bounds.GetRight();
+ }
+ }
+ }
+}
+
+// 根据鼠标位置计算光标在文字中的位置(字符索引)
+// 用 GDI+ 逐字符测量,与 DrawString 渲染进度一致。
+
+int CalcCaretPosFromMouse(HDC hdc, const std::wstring& text, int fontPx, int textX, int mouseX) {
+ if (text.empty()) return 0;
+
+ std::vector widths;
+ MeasureCharWidthsGdip(hdc, text, fontPx, widths);
+
+ int bestPos = 0;
+ float bestDist = FLT_MAX;
+ for (size_t i = 0; i <= text.size(); i++) {
+ float x = (float)textX + widths[i];
+ float dist = fabsf(x - (float)mouseX);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestPos = (int)i;
+ }
+ }
+ return bestPos;
+}
+
+// P1 脏区域辅助:使"被拖拽/缩放标注的旧位置 ∪ 新位置"区域无效。
+// 用于 resizingAnnotation/draggingAnnotation/draggingTextAnnotation 的 MOUSEMOVE。
+// curBox 为当前帧标注包围盒(绝对虚拟屏幕坐标,由调用方用 MeasureAnnotationBounds 算);
+// 与上帧缓存的 lastAnnotationBox 求并集后转 backDC 坐标并扩大。lastAnnotationBox 由 WM_PAINT 更新。
+// 扩大量需覆盖选中态的 resize 手柄:手柄圆心贴在包围盒边缘,半径 = handleSize/2,
+// 故 inflate 量取 handleSize/2 + 余量(= handleMetrics.handleMargin),确保旧/新手柄范围都在脏区内,
+// 避免拖拽时残留手柄痕迹。
+
+void InvalidateAnnotationOp(HWND hwnd, CaptureContext* ctx, const RECT& curBox) {
+ RECT b;
+ if (ctx->hasLastAnnotationBox) {
+ b = UnionRectSafe(ctx->lastAnnotationBox, curBox);
+ } else {
+ b = curBox;
+ }
+ b.left -= ctx->virtualX; b.top -= ctx->virtualY;
+ b.right -= ctx->virtualX; b.bottom -= ctx->virtualY;
+ const int handleMargin = ctx->handleMetrics.handleMargin; // 手柄半径 + 描边/抗锯齿余量
+ InvalidateRect(hwnd, &InflateRectBy(b, handleMargin), FALSE);
+}
+
+// 选中/取消选中覆盖物时的精确脏区计算。
+// 根据当前 selectedAnnotation / selectedTextAnnotation 的包围盒算出需重绘的脏区
+// (含 resize 手柄半径余量),可选合并工具栏 + popup 旧位置(切换不同类型工具时)。
+// 调用时机:必须在调用方清空 selected*/hovered* 之前调用,否则读不到旧选中项的包围盒。
+// 坐标基准:返回 backDC/客户区坐标(已减 virtualX/Y),可直接用于 InvalidateRect(hwnd, &r, FALSE)。
+// 参数 includeToolbar:true 时并集工具栏矩形和 popup 矩形(覆盖 activeTool 切换导致的高亮按钮
+// 位移 + popup 打开/关闭/切换),用于「切换到不同类型工具」场景。
+// 返回值可能为无效矩形({0,0,0,0}):表示当前无任何选中项,调用方据此决定是否全屏重绘兜底。
+
+RECT CalcSelectionDirty(CaptureContext* ctx, bool includeToolbar) {
+ RECT dirty = {0, 0, 0, 0};
+ const int handleMargin = ctx->handleMetrics.handleMargin; // 与 InvalidateAnnotationOp 一致的手柄半径余量
+
+ // 非文字标注选中项的包围盒
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ RECT r = MeasureAnnotationBounds(ctx->annotations[ctx->selectedAnnotation], ctx->backDC);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
+ }
+ // 文字标注选中项的包围盒(MeasureTextAnnotation 含文字 padding)
+ if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ctx->selectedTextAnnotation]);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
+ }
+ // 切换不同类型工具:activeTool 变化后高亮按钮位移,popup 可能打开/关闭/切换类型,
+ // 将整条工具栏和当前 popup 都纳入脏区(宁大勿小,避免按钮高亮残影)。
+ if (includeToolbar) {
+ if (IsValidRect(ctx->toolbarRect)) {
+ dirty = UnionRectSafe(dirty, InflateRectBy(ctx->toolbarRect, 4));
+ }
+ if (IsValidRect(ctx->popupRect)) {
+ dirty = UnionRectSafe(dirty, InflateRectBy(ctx->popupRect, 4));
+ }
+ }
+ return dirty;
+}
+
+// P1 脏区域辅助:使文字行区域无效(用于文字编辑态的键盘输入/删除/光标移动)。
+// 文字行 y 范围取自上帧光标高度(lastCaretRect),x 范围覆盖整个选区宽度
+// (文字必然在选区内,选区宽度是文字行可能宽度的安全上界)。lastCaret 无效时全屏。
+// 坐标基准:backDC 坐标 = 客户区坐标(已减 virtualX/Y),与 InvalidateRect 一致。
+
+void InvalidateTextLine(HWND hwnd, CaptureContext* ctx) {
+ if (!ctx->hasLastCaret) {
+ InvalidateRect(hwnd, NULL, FALSE);
+ return;
+ }
+ // 文字行矩形:选区宽度 × 光标行高(backDC 坐标)
+ RECT line = {
+ ctx->selection.left - ctx->virtualX,
+ ctx->lastCaretRect.top,
+ ctx->selection.right - ctx->virtualX,
+ ctx->lastCaretRect.bottom
+ };
+ InvalidateRect(hwnd, &InflateRectBy(line, 4), FALSE);
+}
diff --git a/src/screenshot/capture_windows.cpp b/src/screenshot/capture_windows.cpp
new file mode 100644
index 0000000..f123f0d
--- /dev/null
+++ b/src/screenshot/capture_windows.cpp
@@ -0,0 +1,340 @@
+// 截图模块:屏幕捕获管线(预截屏、GDI 采集、DPI/显示器/窗口枚举)
+#include "internal.h"
+
+static void ReleasePrimedScreenshotFrameLocked() {
+ if (g_primedScreenshotFrame.bitmap) {
+ DeleteObject(g_primedScreenshotFrame.bitmap);
+ g_primedScreenshotFrame.bitmap = NULL;
+ }
+ g_primedScreenshotFrame.vx = 0;
+ g_primedScreenshotFrame.vy = 0;
+ g_primedScreenshotFrame.vw = 0;
+ g_primedScreenshotFrame.vh = 0;
+ g_primedScreenshotFrame.dpiScale = 1.0;
+ g_primedScreenshotFrame.capturedAt = std::chrono::steady_clock::time_point{};
+ g_primedScreenshotFrame.valid = false;
+}
+
+// ---- 工具函数 ----
+
+// 获取 DPI 缩放因子
+double GetDpiScaleFactor() {
+ typedef UINT (WINAPI *GetDpiForSystemProc)();
+ HMODULE user32 = GetModuleHandleW(L"user32.dll");
+ if (user32) {
+ auto proc = (GetDpiForSystemProc)GetProcAddress(user32, "GetDpiForSystem");
+ if (proc) {
+ UINT dpi = proc();
+ double scale = dpi / 96.0;
+ if (scale < 0.5) scale = 0.5;
+ if (scale > 4.0) scale = 4.0;
+ return scale;
+ }
+ }
+ return 1.0;
+}
+
+// 显示器枚举回调
+
+BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData) {
+ MonitorEnumData* data = reinterpret_cast(dwData);
+
+ // 获取显示器的物理尺寸
+ MONITORINFOEXW mi;
+ mi.cbSize = sizeof(MONITORINFOEXW);
+ if (GetMonitorInfoW(hMonitor, &mi)) {
+ // 在 DPI 感知模式下,rcMonitor 已经是物理像素坐标
+ data->minLeft = (std::min)(data->minLeft, mi.rcMonitor.left);
+ data->minTop = (std::min)(data->minTop, mi.rcMonitor.top);
+ data->maxRight = (std::max)(data->maxRight, mi.rcMonitor.right);
+ data->maxBottom = (std::max)(data->maxBottom, mi.rcMonitor.bottom);
+ data->monitorCount++;
+
+ // 获取显示器 DPI(shcore.dll 由外层 CaptureVirtualScreen 一次性 LoadLibrary,
+ // 此处只 GetProcAddress+调用,避免每次回调重复 Load/Free)
+ if (data->shcore) {
+ typedef HRESULT(WINAPI* GetDpiForMonitorProc)(HMONITOR, int, UINT*, UINT*);
+ auto getDpiForMonitor = (GetDpiForMonitorProc)GetProcAddress(data->shcore, "GetDpiForMonitor");
+ if (getDpiForMonitor) {
+ UINT dpiX, dpiY;
+ if (SUCCEEDED(getDpiForMonitor(hMonitor, 0/*MDT_EFFECTIVE_DPI*/, &dpiX, &dpiY))) {
+ data->totalDpiScale = (std::max)(data->totalDpiScale, dpiX / 96.0);
+ }
+ }
+ }
+ }
+ return TRUE;
+}
+
+// 截取整个虚拟屏幕到物理尺寸位图
+
+static bool CaptureVirtualScreen(HDC& outMemDC, HBITMAP& outBitmap,
+ int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
+ // 获取逻辑坐标的虚拟屏幕尺寸
+ vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
+ vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
+ vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
+ vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+
+ // 枚举所有显示器获取物理像素边界(shcore.dll 一次性 Load,回调内复用避免重复 Load)
+ HMODULE shcore = LoadLibraryW(L"shcore.dll");
+ MonitorEnumData enumData = { INT_MAX, INT_MAX, INT_MIN, INT_MIN, 1.0, 0, shcore };
+ EnumDisplayMonitors(NULL, NULL, MonitorEnumProc, reinterpret_cast(&enumData));
+ if (shcore) FreeLibrary(shcore);
+
+ // 计算物理尺寸(使用枚举得到的实际物理像素边界)
+ int physVx = enumData.minLeft;
+ int physVy = enumData.minTop;
+ int physVw = enumData.maxRight - enumData.minLeft;
+ int physVh = enumData.maxBottom - enumData.minTop;
+
+ // 如果枚举失败,回退到 DPI 缩放计算
+ if (physVw <= 0 || physVh <= 0 || enumData.monitorCount == 0) {
+ physVx = (int)(vx * dpiScale);
+ physVy = (int)(vy * dpiScale);
+ physVw = (int)(vw * dpiScale + 0.5);
+ physVh = (int)(vh * dpiScale + 0.5);
+ }
+
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return false;
+
+ outMemDC = CreateCompatibleDC(screenDC);
+ if (!outMemDC) { ReleaseDC(NULL, screenDC); return false; }
+
+ outBitmap = CreateCompatibleBitmap(screenDC, physVw, physVh);
+ if (!outBitmap) { DeleteDC(outMemDC); ReleaseDC(NULL, screenDC); return false; }
+
+ SelectObject(outMemDC, outBitmap);
+
+ // 直接 BitBlt 物理像素(在 DPI 感知模式下,屏幕 DC 和坐标都是物理像素级别)。
+ // UAC 安全桌面激活/锁屏瞬断等场景该调用会失败,CompatibleBitmap 内容此时未初始化
+ // (GDI 不保证清零),照常返回成功会把整幅黑图报给上层;失败时清理全部 GDI 对象并
+ // 返回 false,由调用方回退链承接(AcquireScreenshotBase 失败分支 / 预抓取降级重采)。
+ if (!BitBlt(outMemDC, 0, 0, physVw, physVh, screenDC, physVx, physVy, SRCCOPY | CAPTUREBLT)) {
+ DeleteDC(outMemDC); // 先解除位图选中,再删除位图
+ outMemDC = NULL;
+ DeleteObject(outBitmap);
+ outBitmap = NULL;
+ ReleaseDC(NULL, screenDC);
+ return false;
+ }
+
+ // 更新返回的 dpiScale 为实际的物理/逻辑比例
+ // 这样后续的坐标转换才能正确
+ if (vw > 0 && vh > 0) {
+ dpiScale = (double)physVw / vw;
+ }
+
+ ReleaseDC(NULL, screenDC);
+ return true;
+}
+
+// 创建双缓冲
+
+bool CreateBackBuffer(HDC& outDC, HBITMAP& outBmp, int w, int h) {
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return false;
+ outDC = CreateCompatibleDC(screenDC);
+ if (!outDC) { ReleaseDC(NULL, screenDC); return false; }
+ outBmp = CreateCompatibleBitmap(screenDC, w, h);
+ if (!outBmp) { DeleteDC(outDC); ReleaseDC(NULL, screenDC); return false; }
+ SelectObject(outDC, outBmp);
+ ReleaseDC(NULL, screenDC);
+ return true;
+}
+
+// 立即抓取当前虚拟屏幕首帧,供后续截图流程复用。
+
+bool PrimeScreenshotFrameNow() {
+ HDC memDC = NULL;
+ HBITMAP bitmap = NULL;
+ int vx = 0, vy = 0, vw = 0, vh = 0;
+ double dpiScale = 1.0;
+ if (!CaptureVirtualScreen(memDC, bitmap, vx, vy, vw, vh, dpiScale)) {
+ return false;
+ }
+
+ if (memDC) {
+ DeleteDC(memDC);
+ }
+
+ std::lock_guard lock(g_primedScreenshotFrameMutex);
+ ReleasePrimedScreenshotFrameLocked();
+ g_primedScreenshotFrame.bitmap = bitmap;
+ g_primedScreenshotFrame.vx = vx;
+ g_primedScreenshotFrame.vy = vy;
+ g_primedScreenshotFrame.vw = vw;
+ g_primedScreenshotFrame.vh = vh;
+ g_primedScreenshotFrame.dpiScale = dpiScale;
+ g_primedScreenshotFrame.capturedAt = std::chrono::steady_clock::now();
+ g_primedScreenshotFrame.valid = true;
+ return true;
+}
+
+static bool ConsumePrimedScreenshotFrame(HDC& outMemDC, HBITMAP& outBitmap,
+ int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
+ std::lock_guard lock(g_primedScreenshotFrameMutex);
+ if (!g_primedScreenshotFrame.valid || !g_primedScreenshotFrame.bitmap) {
+ return false;
+ }
+
+ const auto now = std::chrono::steady_clock::now();
+ if (now - g_primedScreenshotFrame.capturedAt > SC_PRIMED_FRAME_TTL) {
+ ReleasePrimedScreenshotFrameLocked();
+ return false;
+ }
+
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) {
+ ReleasePrimedScreenshotFrameLocked();
+ return false;
+ }
+
+ outMemDC = CreateCompatibleDC(screenDC);
+ ReleaseDC(NULL, screenDC);
+ if (!outMemDC) {
+ ReleasePrimedScreenshotFrameLocked();
+ return false;
+ }
+
+ SelectObject(outMemDC, g_primedScreenshotFrame.bitmap);
+ outBitmap = g_primedScreenshotFrame.bitmap;
+ vx = g_primedScreenshotFrame.vx;
+ vy = g_primedScreenshotFrame.vy;
+ vw = g_primedScreenshotFrame.vw;
+ vh = g_primedScreenshotFrame.vh;
+ dpiScale = g_primedScreenshotFrame.dpiScale;
+ g_primedScreenshotFrame.bitmap = NULL;
+ g_primedScreenshotFrame.valid = false;
+ return true;
+}
+
+bool AcquireScreenshotBase(HDC& outMemDC, HBITMAP& outBitmap,
+ int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
+ if (ConsumePrimedScreenshotFrame(outMemDC, outBitmap, vx, vy, vw, vh, dpiScale)) {
+ return true;
+ }
+ return CaptureVirtualScreen(outMemDC, outBitmap, vx, vy, vw, vh, dpiScale);
+}
+
+// 从预截屏位图读取像素颜色(逻辑坐标)
+
+COLORREF GetPixelColorFromBitmap(HDC memDC, int x, int y, int vx, int vy, double dpiScale) {
+ int lx = x - vx;
+ int ly = y - vy;
+ int px = (int)(lx * dpiScale + 0.5);
+ int py = (int)(ly * dpiScale + 0.5);
+ return GetPixel(memDC, px, py);
+}
+
+// COLORREF 转 HEX/RGB 字符串
+
+void ColorrefToStrings(COLORREF color, char* hexBuf, char* rgbBuf) {
+ int r = color & 0xFF;
+ int g = (color >> 8) & 0xFF;
+ int b = (color >> 16) & 0xFF;
+ sprintf_s(hexBuf, 32, "#%02X%02X%02X", r, g, b);
+ sprintf_s(rgbBuf, 32, "%d, %d, %d", r, g, b);
+}
+
+// 枚举窗口回调
+
+static BOOL CALLBACK SCEnumWindowsProc(HWND hwnd, LPARAM lParam) {
+ auto* windows = reinterpret_cast*>(lParam);
+
+ if (!IsWindowVisible(hwnd)) return TRUE;
+
+ LONG_PTR exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
+ if (exStyle & WS_EX_TOOLWINDOW) return TRUE;
+
+ LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
+ if (style == 0) return TRUE;
+
+ // 检查是否为幽灵窗口(cloaked window)
+ // 幽灵窗口虽然 IsWindowVisible 返回 true,但实际上不可见
+ BOOL isCloaked = FALSE;
+ HRESULT hrCloaked = DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &isCloaked, sizeof(isCloaked));
+ if (SUCCEEDED(hrCloaked) && isCloaked) {
+ return TRUE; // 跳过幽灵窗口
+ }
+
+ // 获取窗口类名以进行额外过滤
+ const int MAX_CLASS_NAME = 256;
+ WCHAR className[MAX_CLASS_NAME] = {0};
+ int classNameLen = GetClassNameW(hwnd, className, MAX_CLASS_NAME);
+
+ // 过滤某些特殊的系统窗口类
+ if (classNameLen > 0) {
+ // Windows 输入法相关窗口(如 Microsoft Text Input Application)
+ if (wcscmp(className, L"Windows.UI.Core.CoreWindow") == 0) {
+ // 对于 CoreWindow,再次确认是否真的可见(通过检查是否有有效的可视化区域)
+ RECT clientRect;
+ if (!GetClientRect(hwnd, &clientRect)) return TRUE;
+
+ // 如果客户区太小,很可能是输入法等后台窗口
+ int clientW = clientRect.right - clientRect.left;
+ int clientH = clientRect.bottom - clientRect.top;
+ if (clientW < 100 || clientH < 100) return TRUE;
+ }
+
+ // 过滤 ApplicationFrameWindow 的空壳窗口
+ // UWP 应用在未激活时可能留下空的 ApplicationFrameWindow
+ if (wcscmp(className, L"ApplicationFrameWindow") == 0) {
+ // 检查窗口是否被最小化或隐藏
+ if (IsIconic(hwnd)) return TRUE;
+
+ // 检查是否真的有内容(通过检查窗口透明度或其他属性)
+ BYTE opacity = 255;
+ DWORD cloakedReason = 0;
+ DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloakedReason, sizeof(cloakedReason));
+ if (cloakedReason != 0) return TRUE;
+ }
+ }
+
+ int titleLen = GetWindowTextLengthW(hwnd);
+ if (titleLen == 0) return TRUE;
+
+ std::wstring title(titleLen + 1, L'\0');
+ GetWindowTextW(hwnd, &title[0], titleLen + 1);
+ title.resize(titleLen);
+
+ if (hwnd == GetDesktopWindow()) return TRUE;
+
+ // 使用 DWM 获取精确边界
+ RECT rect = {};
+ HRESULT hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &rect, sizeof(rect));
+ if (FAILED(hr)) {
+ if (!GetWindowRect(hwnd, &rect)) return TRUE;
+ }
+
+ int w = rect.right - rect.left;
+ int h = rect.bottom - rect.top;
+ if (w < 50 || h < 50) return TRUE;
+
+ SCWindowInfo info;
+ info.hwnd = hwnd;
+ info.rect = rect;
+ info.title = title;
+ windows->push_back(info);
+ return TRUE;
+}
+
+// 枚举窗口
+
+std::vector EnumWindowsForCapture() {
+ std::vector windows;
+ EnumWindows(SCEnumWindowsProc, reinterpret_cast(&windows));
+ return windows;
+}
+
+// 查找鼠标下方的窗口
+
+int FindWindowAtPoint(const std::vector& windows, int x, int y) {
+ for (size_t i = 0; i < windows.size(); i++) {
+ const RECT& r = windows[i].rect;
+ if (x >= r.left && x < r.right && y >= r.top && y < r.bottom)
+ return (int)i;
+ }
+ return -1;
+}
diff --git a/src/screenshot/icons_windows.cpp b/src/screenshot/icons_windows.cpp
new file mode 100644
index 0000000..04777fa
--- /dev/null
+++ b/src/screenshot/icons_windows.cpp
@@ -0,0 +1,134 @@
+// 截图模块:SVG 图标光栅化与缓存(nanosvg 唯一实例化编译单元)
+#include "internal.h"
+
+// ---- nanosvg:SVG 光栅化(单文件库,宏实例化)----
+// 两个 .h 必须在同一编译单元用宏实例化一次;本文件(icons_windows.cpp)为唯一实例化编译单元。
+#define NANOSVG_IMPLEMENTATION
+#include "../third_party/nanosvg.h"
+#define NANOSVGRAST_IMPLEMENTATION
+#include "../third_party/nanosvgrast.h"
+
+// ---- 截图工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 从 src/assets 生成)----
+#include "../generated/icon_svgs.h"
+
+// ---- SVG 图标光栅化 + 缓存 ----
+
+// 将单个 SVG 文本光栅化为 32bpp 预乘 ARGB HBITMAP,尺寸 px×px。
+// 把 SVG 中的 currentColor 替换为目标 color(normal/active 两色复用同一文本)。
+
+HBITMAP RenderSvgToBitmap(const char* svgText, COLORREF color, int px) {
+ if (!svgText || px <= 0) return NULL;
+
+ // 1. 替换 currentColor -> #RRGGBB(nanosvg 解析会改写 buffer,需可写副本)
+ char colorHex[8];
+ sprintf_s(colorHex, sizeof(colorHex), "#%02X%02X%02X",
+ color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF);
+ std::string svg(svgText);
+ const std::string token = "currentColor";
+ size_t pos = 0;
+ while ((pos = svg.find(token, pos)) != std::string::npos) {
+ svg.replace(pos, token.size(), colorHex);
+ pos += 6;
+ }
+
+ // 2. 解析(nsvgParse 会就地修改传入字符串)
+ NSVGimage* image = nsvgParse(&svg[0], "px", 96.0f);
+ if (!image) return NULL;
+
+ // 3. 光栅化到 RGBA 缓冲(非预乘)
+ std::vector rgba(px * px * 4, 0);
+ NSVGrasterizer* rast = nsvgCreateRasterizer();
+ if (!rast) { nsvgDelete(image); return NULL; }
+ // 内容缩放到 72% 并居中,四周各留 14% 内边距,避免图标顶满按钮。
+ // nanosvg 解析后 shape 已在 image->width 坐标系(viewBox 已折算),
+ // 故 scale = px * 0.72 / image->width,偏移 tx = ty = px * 0.14。
+ const float contentScale = 0.72f;
+ const float pad = (1.0f - contentScale) * 0.5f;
+ float refSize = (image->width > 0) ? image->width
+ : (image->height > 0) ? image->height : 24.0f;
+ float scale = (float)px * contentScale / refSize;
+ float tx = px * pad;
+ float ty = px * pad;
+ nsvgRasterize(rast, image, tx, ty, scale, rgba.data(), px, px, px * 4);
+ nsvgDeleteRasterizer(rast);
+ nsvgDelete(image);
+
+ // 4. RGBA -> 预乘 BGRA(用于 AlphaBlend,避免黑边)
+ // nanosvg 输出 RGBA 字节序 [R,G,B,A];而 32bpp BI_RGB DIB 内存布局为
+ // BGRA(像素值 0xAARRGGBB 在小端内存里是 B,G,R,A)。故预乘时需把 R、B
+ // 对调写入,否则 memcpy 后通道会反,蓝色被画成黄色(灰度色看不出来)。
+ for (int i = 0; i < px * px; i++) {
+ unsigned char r = rgba[i * 4 + 0];
+ unsigned char g = rgba[i * 4 + 1];
+ unsigned char b = rgba[i * 4 + 2];
+ unsigned char a = rgba[i * 4 + 3];
+ // 预乘后按 DIB 的 BGRA 字节序写入
+ rgba[i * 4 + 0] = (unsigned char)((b * a + 127) / 255); // B
+ rgba[i * 4 + 1] = (unsigned char)((g * a + 127) / 255); // G
+ rgba[i * 4 + 2] = (unsigned char)((r * a + 127) / 255); // R
+ rgba[i * 4 + 3] = a; // A
+ }
+
+ // 5. 创建 32bpp ARGB HBITMAP
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return NULL;
+ HDC memDC = CreateCompatibleDC(screenDC);
+ if (!memDC) { ReleaseDC(NULL, screenDC); return NULL; }
+
+ BITMAPINFO bmi = {};
+ bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bmi.bmiHeader.biWidth = px;
+ bmi.bmiHeader.biHeight = -px; // 自上而下,避免垂直翻转
+ bmi.bmiHeader.biPlanes = 1;
+ bmi.bmiHeader.biBitCount = 32;
+ bmi.bmiHeader.biCompression = BI_RGB;
+
+ void* bits = nullptr;
+ HBITMAP bmp = CreateDIBSection(memDC, &bmi, DIB_RGB_COLORS, &bits, NULL, 0);
+ if (bmp && bits) {
+ memcpy(bits, rgba.data(), px * px * 4);
+ }
+ DeleteDC(memDC);
+ ReleaseDC(NULL, screenDC);
+ return bmp;
+}
+
+// 计算工具栏宽度(拖拽把手格 + 所有按钮 + 间距 + 左右内边距 + 边框),按 metrics 缩放。
+// 最左为「6 点拖拽把手」单元格(与按钮同宽),各工具按钮自第 1 格起排布,
+// 与 HitTestToolbar / DrawToolbar 的单元格映射严格一致。
+
+int CalcToolbarWidth(const SCToolbarMetrics& m) {
+ return (TB_Count + 1) * (m.btn + m.gap) - m.gap + m.pad * 2 + m.border * 2;
+}
+
+// SCIconCache::Init/Cleanup 实体(声明在 internal.h;仅本文件可见 nanosvg 光栅化依赖)
+
+void SCIconCache::Init(int physicalIconSize) {
+ if (inited) Cleanup();
+ iconSize = physicalIconSize;
+ COLORREF darkColor = RGB(60, 60, 60);
+ // 选中态图标用主题蓝 #3B8BF2(SC_THEME_TOOLBAR_BLUE),与浅蓝高亮底搭配
+ COLORREF activeColor = SC_THEME_TOOLBAR_BLUE;
+ for (int i = 0; i < TB_Count; i++) {
+ // 分隔线(TB_Separator1/2)及未映射的项:kIconSvgs[i] 为 nullptr,跳过
+ if (kIconSvgs[i]) {
+ dark[i] = RenderSvgToBitmap(kIconSvgs[i], darkColor, iconSize);
+ active[i] = RenderSvgToBitmap(kIconSvgs[i], activeColor, iconSize);
+ }
+ }
+ inited = true;
+}
+
+void SCIconCache::Cleanup() {
+ for (int i = 0; i < TB_Count; i++) {
+ if (dark[i]) { DeleteObject(dark[i]); dark[i] = NULL; }
+ if (active[i]) { DeleteObject(active[i]); active[i] = NULL; }
+ }
+ inited = false;
+}
+
+// CR-018: 防常量漂移。kIconSvgs(generated/icon_svgs.h,按 ToolButton 枚举顺序)
+// 元素数必须等于 TB_Count:Init/Cleanup 均以 TB_Count 为循环上界索引 kIconSvgs[i],
+// 漂移将越界读 nullptr 或漏绘图标。
+static_assert(sizeof(kIconSvgs) / sizeof(kIconSvgs[0]) == TB_Count,
+ "kIconSvgs element count must equal TB_Count (ToolButton enum)");
diff --git a/src/screenshot/internal.h b/src/screenshot/internal.h
new file mode 100644
index 0000000..70fc9d7
--- /dev/null
+++ b/src/screenshot/internal.h
@@ -0,0 +1,1162 @@
+// 截图模块共享内部头:类型、常量、全局变量与跨文件函数声明(由 screenshot_windows.cpp 拆分而来)
+#pragma once
+
+#include
+#include
+#include
+#include // For GET_X_LPARAM, GET_Y_LPARAM
+#include
+// 冷门头 // 不在此集中包含(本头类型声明均不依赖其符号),
+// 仅由唯一使用方 .cpp 自行引入:imm.h → overlay_input_windows.cpp(IME 输入法)、
+// commdlg.h → output_windows.cpp(GetSaveFileNameW 保存对话框)、
+// shlobj.h → output_windows.cpp(SHGetKnownFolderPath 已知文件夹路径)。
+#include
+#include
+#include // For std::min, std::max
+#include
+#include
+#include
+#include // For std::sqrt, std::fabs
+#include
+#include
+#include // For INT64_MIN / INT64_MAX(长截图裁剪内容坐标哨兵)
+#include // For std::move(最近帧历史的匹配数据转移)
+
+// DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义
+#ifndef DWMWA_CLOAKED
+#define DWMWA_CLOAKED 14
+#endif
+
+// GDI+ 需要 min/max
+namespace Gdiplus {
+ using std::min;
+ using std::max;
+}
+#include
+
+
+#include "screenshot_windows.h"
+
+static const auto SC_PRIMED_FRAME_TTL = std::chrono::seconds(2);
+
+struct PrimedScreenshotFrame {
+ HBITMAP bitmap = NULL;
+ int vx = 0;
+ int vy = 0;
+ int vw = 0;
+ int vh = 0;
+ double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释(CR-023)
+ std::chrono::steady_clock::time_point capturedAt{};
+ bool valid = false;
+};
+
+// 截图常量
+
+// ==================== 区域截图功能(预截屏 + 双缓冲架构) ====================
+
+static const int SC_PANEL_WIDTH = 140;
+
+static const int SC_PANEL_HEIGHT = 140;
+
+static const int SC_MAGNIFIER_HEIGHT = 74;
+
+static const int SC_PANEL_MARGIN = 15;
+
+static const int SC_PANEL_CORNER_RADIUS = 8;
+
+static const int SC_ZOOM_FACTOR = 4;
+
+// 取值 0~255,数值越大越暗(0 = 无遮罩,255 = 全黑)
+
+// 选区外遮罩:微信风格,选区内部保持清晰,外部覆盖半透明黑色
+
+static const BYTE SC_MASK_ALPHA = 120;
+
+// 截图状态枚举
+
+enum CaptureState {
+ CS_Idle, // 等待选择(hover 窗口/拖拽开始)
+ CS_Selecting, // 正在拖拽框选
+ CS_Confirmed, // 已确认选区,可调整/拖动/打开工具栏
+ CS_Resizing, // 正在拖拽手柄调整选区
+ CS_Moving, // 正在整体拖动选区
+ CS_Drawing, // 正在绘制标注(矩形/圆/箭头/画笔)
+ CS_TextEditing, // 正在输入文字
+ CS_LongCapturing, // 长截图滚动捕获进行中(独立于编辑态,由 RunLongCapture 驱动)
+ CS_Done,
+ CS_Cancelled
+};
+
+// 选区调整手柄(8 个方向)
+
+enum ResizeHandle {
+ RH_None = -1,
+ RH_Left = 0,
+ RH_Right = 1,
+ RH_Top = 2,
+ RH_Bottom = 3,
+ RH_TopLeft = 4,
+ RH_TopRight = 5,
+ RH_BottomLeft = 6,
+ RH_BottomRight = 7,
+ RH_ArrowStart = 8, // 箭头起点端点手柄(仅箭头用,拖动改起点)
+ RH_ArrowEnd = 9, // 箭头终点端点手柄(仅箭头用,拖动改终点)
+ RH_CornerRadiusTL = 10, // 选区左上角内倒角手柄(拖动改选区圆角半径,不改变选区矩形)
+ RH_CornerRadiusTR = 11, // 选区右上角内倒角手柄
+ RH_CornerRadiusBL = 12, // 选区左下角内倒角手柄
+ RH_CornerRadiusBR = 13 // 选区右下角内倒角手柄
+};
+
+// 工具栏最左「6 点拖拽把手」单元格的命中返回值:取负值与按钮索引区分,
+// 既有调用方以 >=0 判定按钮,天然排除把手(把手按住 = 拖动工具栏,非工具按钮)。
+static const int SC_TB_GRIP = -2;
+
+// 工具栏按钮
+
+enum ToolButton {
+ TB_Drag = 0, // 拖拽
+ TB_Rect, // 矩形
+ TB_Circle, // 圆形(含椭圆)
+ TB_Arrow, // 箭头
+ TB_Brush, // 画笔
+ TB_Mosaic, // 马赛克
+ TB_Text, // 文字
+ TB_Translate, // 翻译
+ TB_LongCapture, // 长截图(滚动捕获入口,复用当前选区)
+ TB_Separator1, // 分隔线
+ TB_Undo, // 撤销
+ TB_Redo, // 重做
+ TB_Separator2, // 分隔线
+ TB_Save, // 保存到本地
+ TB_Cancel, // 取消
+ TB_Confirm, // 确定
+ TB_Count
+};
+
+// ==================== 标注绘制(矩形/圆/箭头/画笔) ====================
+
+// 所有标注统一用「绝对虚拟屏幕坐标」存储(与 ctx->mouseX/selection 同坐标系):
+// - 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动
+// - 实时渲染时:backDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -virtualX/-virtualY
+// - 合成进 PNG 时:finalDC 局部坐标 = 绝对坐标 + ox/oy,ox/oy = -rect.left/-rect.top
+
+enum AnnotationType {
+ AT_Rect,
+ AT_Circle,
+ AT_Arrow,
+ AT_Brush,
+ AT_Text,
+ AT_Mosaic // 马赛克(框选区域 或 鼠标涂抹)
+};
+
+struct Annotation {
+ AnnotationType type;
+ COLORREF color;
+ int thickness; // 逻辑像素(矢量=线宽;文字=字号)
+ // 绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同坐标系)。
+ // 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动。
+ int x1, y1, x2, y2; // Rect / Circle / Arrow 的起止(绝对坐标);AT_Text 的 x1/y1 为文字锚点;
+ // AT_Mosaic 框选模式的矩形起止(绝对坐标)
+ std::vector pts; // Brush 自由路径(绝对坐标);AT_Mosaic 涂抹模式的路径(绝对坐标)
+ std::wstring text; // AT_Text 的文字内容
+ // ---- AT_Mosaic 专用 ----
+ bool mosaicRect; // true=框选区域马赛克;false=鼠标涂抹马赛克
+ int mosaicSize; // 马赛克块大小(逻辑像素)
+ int brushRadius; // 涂抹半径(逻辑像素,仅涂抹模式有效)
+
+ // ---- 文字测量缓存(仅 AT_Text 有效)----
+ // 缓存"相对锚点的字形偏移与尺寸"(与 GDI+ MeasureString 同源)。
+ // 有效性条件 = (text, fontPx) 未变;锚点(x1,y1)变化不影响缓存值(外部加偏移即可),
+ // 故 TransformAnnotationByBox 的 AT_Text 分支(仅平移锚点)无需失效缓存。
+ // textCacheValid=false 表示未计算或已失效,下次 MeasureTextAnnotation 会重算并回填。
+ bool textCacheValid;
+ int textCacheFontPx; // 生成缓存时的 fontPx(= thickness),用于校验
+ float textCacheOffX, textCacheOffY; // 字形左上角相对锚点的偏移
+ float textCacheW, textCacheH; // 字形紧凑宽高
+};
+
+// 粗细预设(逻辑像素,实际绘制粗细,渲染时乘 dpiScale)
+// inline constexpr 数组:跨 TU 唯一实例(替代 static const 每 TU 一份的拷贝),值与原一致。
+
+inline constexpr int SC_THICK_PRESETS[] = { 1, 2, 4 };
+
+inline constexpr int SC_THICK_COUNT = sizeof(SC_THICK_PRESETS) / sizeof(SC_THICK_PRESETS[0]);
+
+static const int SC_DEFAULT_THICK_IDX = 1; // 默认中粗
+
+// 子菜单圆点预览直径(逻辑像素,仅用于界面显示,与实际绘制粗细解耦)
+
+inline constexpr int SC_THICK_DOT_SIZES[] = { 5, 10, 16 };
+
+inline constexpr int SC_THICK_DOT_COUNT = sizeof(SC_THICK_DOT_SIZES) / sizeof(SC_THICK_DOT_SIZES[0]);
+
+// 文字字号预设(逻辑像素),文字工具激活时子菜单第一组显示
+
+inline constexpr int SC_FONT_SIZES[] = { 16, 24, 36 };
+
+inline constexpr int SC_FONT_COUNT = sizeof(SC_FONT_SIZES) / sizeof(SC_FONT_SIZES[0]);
+
+// 粗细档数与字号档数必须一致:子菜单第一组共用单元格,绘制/命中按 isTextTool 在两套预设
+// 间二选一(overlay_ui/overlay_input 均以 SC_THICK_COUNT/SC_FONT_COUNT 为循环上界)。
+static_assert(SC_THICK_COUNT == SC_FONT_COUNT,
+ "SC_THICK_COUNT must match SC_FONT_COUNT: submenu 第一组共享单元格");
+
+static const int SC_DEFAULT_FONT_IDX = 1; // 默认中号
+
+static const wchar_t* SC_FONT_FACE = L"微软雅黑";
+
+// 马赛克块大小预设(逻辑像素),马赛克工具子菜单显示
+
+inline constexpr int SC_MOSAIC_SIZES[] = { 6, 10, 16 };
+
+inline constexpr int SC_MOSAIC_COUNT = sizeof(SC_MOSAIC_SIZES) / sizeof(SC_MOSAIC_SIZES[0]);
+
+static const int SC_DEFAULT_MOSAIC_IDX = 1; // 默认中等块
+
+// 这里单独定义便于扩展。半径越大涂抹范围越宽。
+
+// 涂抹半径预设(逻辑像素),马赛克涂抹模式使用,与画笔粗细预设共用同一组子菜单第二组无效,
+
+inline constexpr int SC_MOSAIC_RADIUS[] = { 12, 22, 36 };
+
+inline constexpr int SC_MOSAIC_RADIUS_COUNT = sizeof(SC_MOSAIC_RADIUS) / sizeof(SC_MOSAIC_RADIUS[0]);
+
+static const int SC_DEFAULT_MOSAIC_RADIUS_IDX = 1; // 默认中等半径
+
+// 颜色预设
+
+inline constexpr COLORREF SC_COLOR_PRESETS[] = {
+ RGB(0xE5, 0x39, 0x35), // 红
+ RGB(0xFB, 0x8C, 0x00), // 橙
+ RGB(0xFD, 0xD8, 0x35), // 黄
+ RGB(0x43, 0xA0, 0x47), // 绿
+ RGB(0x00, 0xAC, 0xC1), // 青
+ RGB(0x1E, 0x88, 0xE5), // 蓝
+ RGB(0xFF, 0xFF, 0xFF), // 白
+ RGB(0x33, 0x33, 0x33), // 黑
+};
+
+inline constexpr int SC_COLOR_COUNT = sizeof(SC_COLOR_PRESETS) / sizeof(SC_COLOR_PRESETS[0]);
+
+static const int SC_DEFAULT_COLOR_IDX = 0; // 默认红
+
+// ==================== 主题色常量 ====================
+// 多个绘制文件共用的固定色值集中于此(此前以 RGB 字面量散落 overlay_ui / icons /
+// long_capture 各处,易各自漂移);各值与抽常量前逐字节一致。
+// GDI 侧直接传 COLORREF;GDI+ 侧经下方 ScOpaqueColor 展开通道(透明度各异的
+// 使用点自行以 GetR/G/BValue 构造)。
+
+static const COLORREF SC_THEME_ACCENT_BLUE = RGB(0x00, 0x88, 0xFF); // 强调蓝:选区/标注边框、resize 手柄、放大镜准星
+static const COLORREF SC_THEME_TOOLBAR_BLUE = RGB(0x3B, 0x8B, 0xF2); // 工具栏选中态图标蓝 #3B8BF2(配浅蓝高亮底)
+static const COLORREF SC_THEME_ICON_DARK = RGB(0x33, 0x33, 0x33); // 子菜单图标默认深灰 #333333(与预设「黑」同值)
+static const COLORREF SC_THEME_SEL_BG = RGB(225, 237, 253); // 选中态浅蓝高亮底(工具栏主题蓝叠白底 ~15% 预混合色)
+static const COLORREF SC_THEME_HOVER_BG = RGB(235, 243, 255); // hover 态极浅蓝底
+
+// 不透明封装:COLORREF 主题色 -> alpha=255 的 Gdiplus::Color(通道按位展开,值不变)。
+
+static inline Gdiplus::Color ScOpaqueColor(COLORREF c) {
+ return Gdiplus::Color(255, GetRValue(c), GetGValue(c), GetBValue(c));
+}
+
+// 撤销栈最大深度(快照份数)。撤销历史是整份标注的深拷贝,不限深会随操作数平方级累积内存
+// (每笔操作全量复制一次),超出后由 PushAnnotationHistory 裁掉最老快照。
+
+static const int SC_UNDO_MAX_DEPTH = 50;
+
+// 手柄/工具栏几何常量
+
+static const int SC_HANDLE_SIZE = 10; // 调整手柄边长(100% DPI 基准,运行时按 dpiScale 缩放)
+
+static const int SC_TOOLBAR_BTN = 32; // 按钮尺寸(正方形)
+
+static const int SC_TOOLBAR_PAD = 6; // 按钮↔工具栏边缘内边距(四边一致)
+
+static const int SC_TOOLBAR_H = SC_TOOLBAR_BTN + SC_TOOLBAR_PAD * 2; // 工具栏高度 = 按钮 + 上下内边距
+
+static const int SC_TOOLBAR_GAP = 1; // 按钮间距
+
+static const int SC_TOOLBAR_RADIUS = 8; // 工具栏圆角
+
+static const int SC_TOOLBAR_MARGIN = 6; // 选区到工具栏间距
+
+static const int SC_TOOLBAR_BORDER = 1; // 工具栏边框
+
+static const int SC_MIN_SELECTION = 10; // 最小选区尺寸
+
+static const int SC_CORNER_KNOB_INSET = 18; // 圆角拖拽手柄距选区角的内缩距离(100% DPI 基准,运行时按 dpiScale 缩放)
+
+static const int SC_CORNER_PROXIMITY = 14; // 倒角手柄"靠近"感应余量:在命中框外再扩此距离即显示该角手柄(100% DPI 基准)
+
+// 手柄几何(DPI 缩放后)。选区/标注 resize 手柄与圆角手柄共用同一套尺寸,
+// 保证 1080p → 4K 下手柄与工具栏/图标同步放大,避免高 DPI 下手柄过小。
+struct SCHandleMetrics {
+ int handleSize; // 手柄边长(绘制 + 命中框半宽基准)
+ int cornerKnobInset; // 圆角手柄距选区角的内缩距离
+ int handleMargin; // 脏区扩张余量 = handleSize/2 + 4(覆盖手柄半径 + 描边/抗锯齿)
+ int cornerProximity; // 倒角手柄靠近感应余量(命中框外扩展距离,鼠标进入即显示该角手柄)
+};
+
+// 子菜单几何常量(100% DPI 基准值,运行时按 dpiScale 缩放)
+// 单行布局:[粗细圆点×3] | [分隔线] | [颜色圆点×8],无文案。
+// 单元格(点击区 + 选中背景区)大小与工具栏按钮一致,便于视觉对齐。
+static const int SC_POPUP_CELL = SC_TOOLBAR_BTN; // 单元格尺寸(= 工具栏按钮大小)
+
+static const int SC_POPUP_PAD = 4; // 内边距
+
+static const int SC_POPUP_RADIUS = 8; // 圆角
+
+static const int SC_POPUP_COLOR_DOT = 18; // 颜色圆点直径(图标本身)
+
+static const int SC_POPUP_SEP_GAP = 6; // 分隔线两侧间距
+
+static const int SC_POPUP_SEP_H = 20; // 分隔线高度
+
+static const int SC_POPUP_BORDER = 1; // 边框
+
+static const int SC_POPUP_MARGIN = 4; // 工具栏与子菜单间距
+
+// 子菜单几何(DPI 缩放后)
+
+struct SCPopupMetrics {
+ int pad;
+ int radius;
+ int cell; // 单元格尺寸(点击区 + 选中背景区,= 工具栏按钮大小)
+ int colorDot; // 颜色圆点直径(图标本身)
+ int sepGap; // 分隔线两侧间距
+ int sepH; // 分隔线高度
+ int border;
+ int margin;
+};
+
+// ---- 信息面板 DPI 缩放几何 ----
+
+struct SCPanelMetrics {
+ int w;
+ int h;
+ int magnifierH;
+ int margin;
+ int radius;
+ int fontPx;
+ int crosshair;
+ int borderPad;
+ int labelPad;
+ int sizeLabelPadX;
+ int sizeLabelPadY;
+ int sizeLabelGap;
+};
+
+// 窗口信息
+
+struct SCWindowInfo {
+ HWND hwnd;
+ RECT rect;
+ std::wstring title;
+};
+
+// 截图结果结构
+
+struct ScreenshotResult {
+ bool success;
+ int x;
+ int y;
+ int x2;
+ int y2;
+ int width;
+ int height;
+ std::string base64;
+};
+
+// GDI 资源缓存
+
+struct SCGdiResources {
+ HBRUSH bgBrush = NULL;
+ HPEN borderPen = NULL;
+ HPEN crosshairPen = NULL;
+ HPEN selectionPen = NULL;
+ HPEN highlightPen = NULL;
+ HFONT smallFont = NULL;
+ int smallFontPx = 0;
+ int crosshairWidth = 0;
+ // 选区外遮罩缓冲(虚拟屏幕大小,纯黑 + 常量 alpha),用于 AlphaBlend
+ HDC maskDC = NULL;
+ HBITMAP maskBitmap = NULL;
+ // ---- P2 性能优化:固定样式 Pen/Brush 会话级缓存,避免每帧 Create/Delete ----
+ // 工具栏分隔线笔(DrawToolbar)。
+ HPEN toolbarSepPen = NULL; // PS_SOLID, 1, RGB(230,230,230)
+ // 文字选择高亮画刷(AlphaBlend 半透明选区底色)。
+ HBRUSH textSelBrush = NULL; // RGB(51,153,255)
+ // 悬停/选中标注边框:蓝色虚线笔(悬停文字/非文字标注 + 选中非文字标注共用)。
+ HPEN annHoverPen = NULL; // PS_DASH, 1, RGB(0,136,255)
+ // 选中文字标注边框:蓝色实线粗笔(与 selectionPen 的宽度 1 区别)。
+ HPEN annTextSelPen = NULL; // PS_SOLID, 2, RGB(0,136,255)
+
+ // 创建/释放方法体下沉到 session_windows.cpp(CR-022:唯一使用方在会话层,
+ // 从头文件内联定义改为外部定义,调用方签名/语义不变)。
+ void Init(int fontPx = 12, int crosshairPx = 1);
+ // 创建遮罩缓冲(纯黑位图,配合常量 alpha 实现 40%+ 半透明遮罩)
+ // 须在 CaptureContext 虚拟屏幕尺寸确定后调用
+ void InitMask(int virtualW, int virtualH);
+ void Cleanup();
+};
+
+// ---- 工具栏 DPI 缩放几何 ----
+// 基础逻辑尺寸(100% DPI)按 dpiScale 放大,保证 1080p → 4K 下工具栏尺寸与图标同步。
+// 基础值与原 SC_TOOLBAR_* 常量保持一致,便于回归。
+struct SCToolbarMetrics {
+ int btn; // 按钮边长
+ int h; // 工具栏高度
+ int gap; // 按钮间距
+ int pad; // 按钮↔工具栏边缘内边距(四边一致)
+ int radius; // 圆角半径
+ int margin; // 选区到工具栏间距
+ int border; // 工具栏边框宽度
+ int iconSize; // 图标光栅化尺寸(物理像素)
+};
+
+// 工具栏图标位图缓存:按当前 DPI 渲染一次,dark/white 两色版本。
+// dark = normal/hover 图标色,white = active(蓝底)图标色。
+struct SCIconCache {
+ bool inited;
+ int iconSize;
+ HBITMAP dark[TB_Count]; // 普通态:深灰图标
+ HBITMAP active[TB_Count]; // 选中态:主题蓝图标(搭配浅蓝高亮底)
+
+ SCIconCache() : inited(false), iconSize(0) {
+ for (int i = 0; i < TB_Count; i++) { dark[i] = NULL; active[i] = NULL; }
+ }
+
+ // Init/Cleanup 依赖 nanosvg 光栅化(kIconSvgs/RenderSvgToBitmap),实现在 icons_windows.cpp
+ void Init(int physicalIconSize);
+ void Cleanup();
+
+ // 取按钮位图:isActive 时用主题蓝版本,其余用深灰
+ HBITMAP Get(int btn, bool isActive) const {
+ if (btn < 0 || btn >= TB_Count) return NULL;
+ return isActive ? active[btn] : dark[btn];
+ }
+};
+
+// 截图上下文
+
+struct CaptureContext {
+ CaptureState state = CS_Idle;
+ // 自动确认模式:选区确定后直接提取并完成截图,不进入编辑态(工具栏/标注)。
+ // 仅在 WM_LBUTTONUP 的 CS_Selecting 分支生效。
+ bool autoConfirm = false;
+ int virtualX = 0, virtualY = 0, virtualW = 0, virtualH = 0;
+ int startX = 0, startY = 0, endX = 0, endY = 0;
+ int mouseX = 0, mouseY = 0;
+ COLORREF currentColor = 0;
+ std::vector windows;
+ int hoveredWindow = -1; // -1 = none
+ // 预截屏
+ HBITMAP screenBitmap = NULL;
+ HDC memDC = NULL;
+ // 双缓冲
+ HDC backDC = NULL;
+ HBITMAP backBitmap = NULL;
+ // 脏区域追踪
+ RECT lastPanelRect = {};
+ RECT lastSelectionRect = {};
+ RECT lastLabelRect = {};
+ RECT lastHighlightRect = {};
+ RECT lastToolbarRect = {};
+ RECT lastPopupRect = {};
+ // P1 局部刷新用:上帧光标/被操作标注/正在绘制标注的包围盒(供 InvalidateRect 计算旧位置)
+ RECT lastCaretRect = {}; // 上帧文字光标矩形(backDC 坐标),hasLastCaret=false 表示无效
+ bool hasLastCaret = false;
+ RECT lastAnnotationBox = {}; // 上帧被拖拽/缩放标注的包围盒(绝对虚拟屏幕坐标)
+ bool hasLastAnnotationBox = false;
+ RECT lastDrawingBox = {}; // 上帧 curDrawing 包围盒(绝对虚拟屏幕坐标)
+ bool hasLastDrawingBox = false;
+ bool needFullRedraw = false;
+ // DPI 缩放因子(逻辑像素 → 物理像素 = 乘以 dpiScale;物理 → 逻辑 = 除以)。
+ // 【已知限制 —— 单一 scale 模型】CaptureVirtualScreen 把所有显示器的物理并集
+ // BitBlt 进一张连续物理位图,再令 dpiScale = physVw / vw(物理并集宽 / 逻辑并集宽)。
+ // 单显示器(含系统级统一 DPI 缩放)下该值精确;混合 DPI 多显示器下它是各屏 scale
+ // 的加权混合值,无法还原为任一具体显示器——选区跨越不同 DPI 的屏幕时,按此单一
+ // scale 做逻辑↔物理换算会产生系统性像素偏移(越界采样、坐标错位)。
+ // 正确修复需升级为 per-monitor 模型:每个逻辑↔物理换算点按鼠标/选区所在显示器
+ // 用 MonitorFromPoint+GetMonitorInfo+GetDpiForMonitor 取各自 scale,并改造
+ // CaptureVirtualScreen 的整屏 BitBlt 与位图布局以保留各屏原始 DPI 采样(而非混合
+ // 进单一连续网格)。该改动触及捕获/存储/渲染全链路且必须经真实多屏异 DPI 环境验证,
+ // 当前任务禁运行时测试约束下无法安全实施,故仅标注已知限制 + 统一各换算点的写法。
+ // 见 docs/CODE-REVIEW-ROADMAP.md CR-023。
+ double dpiScale = 1.0;
+ // GDI 资源
+ SCGdiResources gdi;
+ SCPanelMetrics panelMetrics;
+
+ // ---- 确认态:可调整选区 ----
+ // 已确认的选区(绝对屏幕坐标)
+ RECT selection = {};
+ // 当前正在拖拽的手柄(CS_Resizing 时有效),CS_Confirmed 下表示 hover 手柄
+ int resizeHandle = RH_None;
+ // 整体拖动/调整起点(绝对屏幕坐标)
+ int dragStartX = 0, dragStartY = 0;
+ RECT dragStartSelection = {};
+ // 选区圆角半径(0=直角;上限=min(w,h)/2,由 ClampCornerRadius 保证)
+ int selectionCornerRadius = 0;
+ // 圆角手柄拖拽起始半径(RH_CornerRadiusTL/TR/BL/BR 拖拽用,增量映射)
+ int dragStartRadius = 0;
+ // 当前"靠近/拖拽"的倒角手柄角(RH_CornerRadiusTL/TR/BL/BR 之一;RH_None=未靠近)。
+ // 仅鼠标靠近某角或正拖拽某角时显示该角一个倒角手柄,其余时刻隐藏。
+ int hoveredCornerHandle = RH_None;
+ // 键盘方向键微调累计位移(CS_Resizing 时叠加到鼠标位移上,松开时一并固化)
+ int kbDX = 0;
+ int kbDY = 0;
+
+ // ---- 悬浮工具栏 ----
+ // 工具栏矩形(相对虚拟屏幕坐标,绘制用)
+ RECT toolbarRect = {};
+ // 用户按住最左「6 点把手」拖动过后置位:此后 OnPaint 直接沿用 toolbarRect,
+ // 不再随选区自动重算(会话内拖动/缩放选区时工具栏保持用户放置的位置)
+ bool toolbarPlaced = false;
+ // 正在拖动工具栏(把手左键按下未松开):MOUSEMOVE 平移 toolbarRect 并局部刷新
+ bool toolbarDragging = false;
+ int toolbarDragStartX = 0, toolbarDragStartY = 0; // 按下时鼠标位置(绝对屏幕坐标)
+ RECT toolbarDragStartRect = {}; // 按下时的工具栏矩形(相对坐标)
+ // 工具栏 hover 按钮,-1 = none(SC_TB_GRIP = 悬停在拖拽把手上)
+ int hoverToolbarBtn = -1;
+ // ---- 工具栏 title 式 tooltip(网页 title 同款:悬停停顿出现深色圆角气泡)----
+ // 由会话空闲循环轮询维护(TickToolbarTooltip),气泡画进 backDC(DrawToolbarTooltip)
+ int tipBtn = -1; // 当前停顿目标按钮(-1 = 无;分隔线无 tooltip)
+ DWORD tipDwellSince = 0; // 光标进入目标按钮的起始时刻(毫秒)
+ bool tipShown = false; // 气泡当前是否在屏(负责自身矩形的失效重绘)
+ RECT tipBubbleRect = {}; // 气泡矩形(backDC 相对坐标)
+ std::wstring tipText; // 气泡文本
+ // 当前激活的工具(高亮显示,仅界面)
+ int activeTool = -1;
+ // 当前子菜单/参数面板对应的工具来源;拖拽工具下选中覆盖物时可继续回显其参数。
+ int popupTool = -1;
+ // 工具栏图标位图缓存(按 DPI 预渲染,dark/white 双色)
+ SCIconCache iconCache;
+ // 当前 DPI 下的工具栏几何(缓存,避免每次绘制重算)
+ SCToolbarMetrics toolbarMetrics;
+ // 当前 DPI 下的手柄几何(缓存:选区/标注 resize 手柄 + 圆角手柄)
+ SCHandleMetrics handleMetrics;
+
+ // ---- 标注绘制 ----
+ std::vector annotations; // 已提交标注
+ // 撤销/重做快照栈(队首=最老)。undoStack 由 PushAnnotationHistory 写入并限深 SC_UNDO_MAX_DEPTH;
+ // 重做路径(UndoAnnotations→redoStack / RedoAnnotations→undoStack)每次入栈前必有一次对应的
+ // 出栈,数学上不会超过同一上限。
+ std::deque> undoStack;
+ std::deque> redoStack;
+ Annotation curDrawing; // CS_Drawing 中正在绘制的标注
+ bool hasCurDrawing = false; // curDrawing 是否有效
+ int drawColorIdx = 0; // 当前选中颜色索引
+ int drawThickIdx = 0; // 当前选中粗细索引(矢量工具)
+ int fontSizeIdx = 0; // 当前选中字号索引(文字工具)
+ // 马赛克工具属性
+ int mosaicSizeIdx = 0; // 当前选中马赛克块大小索引
+ int mosaicRadiusIdx = 0; // 当前选中涂抹半径索引
+ bool mosaicRectMode = false; // true=框选区域模式;false=涂抹模式
+ // 涂抹模式光标:用系统光标机制(SetCursor)显示半径圆,由 OS 跟随鼠标,
+ // 无 WM_PAINT 重绘延迟(之前的 overlay 圆走 MOUSEMOVE→InvalidateRect→WM_PAINT 链路,
+ // 全屏重绘开销大导致不跟手)。按半径预设预生成彩色光标并缓存。
+ HCURSOR mosaicBrushCursors[SC_MOSAIC_RADIUS_COUNT] = {}; // 对应 SC_MOSAIC_RADIUS_COUNT 个半径预设的光标
+ bool mosaicBrushCursorsInited = false;
+ // ---- 马赛克渲染(reveal-mask 模型,消除不连续感)----
+ // 预先把整张截图按当前块大小马赛克化得到 mosaicBase(逻辑像素,与 backDC 同尺寸)。
+ // 马赛克标注只是「蒙版」:涂抹=路径圆形区域、框选=矩形区域,揭示其背后的 mosaicBase。
+ // 这样任意区域、任意顺序叠加都连续无缝;切换块大小时只需重建 base,已揭示区域自动更新。
+ // mosaicBase 覆盖整虚拟屏幕(绝对坐标),与选区无关,resize/move 无需重建。
+ HDC mosaicBaseDC = NULL;
+ HBITMAP mosaicBaseBitmap = NULL;
+ int mosaicBaseW = 0, mosaicBaseH = 0; // base 尺寸(= 虚拟屏幕逻辑尺寸)
+ int mosaicBaseBlockPx = 0; // 生成 base 时的块大小(检测变更触发重建)
+ // 涂抹模式增量绘制:记录上一帧最后绘制的路径点索引(reveal 模型下未使用,保留扩展)。
+ int mosaicDrawLastIdx = 0;
+ // 粗细/颜色子菜单
+ bool popupOpen = false;
+ RECT popupRect = {};
+ SCPopupMetrics popupMetrics;
+
+ // ---- 文字输入(CS_TextEditing)----
+ std::wstring textBuf; // 正在输入的文字缓冲
+ int textAnchorX = 0, textAnchorY = 0; // 文字锚点(绝对虚拟屏幕坐标)
+ int textCaretPos = 0; // 插入符在 textBuf 中的 wchar 位置
+ bool textCaretVisible = false; // 光标是否可见(闪烁控制)
+ DWORD textCaretLastBlink = 0; // 上次光标闪烁时间(毫秒)
+ int textSelStart = -1; // 文字选择起始位置(-1 表示无选择)
+ int textSelEnd = -1; // 文字选择结束位置
+ bool textDraggingSelection = false; // 是否正在拖动选择文字
+ int hoveredTextAnnotation = -1; // 悬浮命中的文字标注索引(-1 表示无,仅用于光标/即时反馈)
+ int selectedTextAnnotation = -1; // 已选中的文字标注索引(-1 表示无,持久保持直到点空白)
+ int draggingTextAnnotation = -1; // 正在拖动的文字标注索引(-1 表示无)
+ int textDragStartX = 0, textDragStartY = 0; // 文字拖动起始位置
+ // ---- 非文字标注的选中/拖拽/缩放(与文字机制互斥:选中非文字时清文字选中,反之亦然)----
+ int hoveredAnnotation = -1; // 悬浮命中的非文字标注索引(-1=无,用于虚线框/光标即时反馈)
+ int selectedAnnotation = -1; // 已选中的非文字标注索引(-1=无,持久保持直到点空白/进入其他操作)
+ int draggingAnnotation = -1; // 正在拖拽的非文字标注索引(-1=无)
+ int resizingAnnotation = -1; // 正在缩放的非文字标注索引(-1=无)
+ int annotationResizeHandle = RH_None; // 当前缩放手柄(RH_None=无;CS_Resizing 时为四角之一)
+ int annotationDragStartX = 0, annotationDragStartY = 0; // 鼠标按下位置(绝对坐标,拖拽/缩放共用)
+ Annotation dragStartAnnotation; // 按下时标注快照(拖拽时还原+平移)
+ bool annotationOpHistoryPushed = false;
+ RECT annotationResizeStartBox = {}; // 按下时包围盒(缩放时基准)
+
+ // ---- GDI+ 会话级资源(性能优化:会话内单次 Startup/Shutdown)----
+ // 原实现每个绘制/测量函数各自 GdiplusStartup/Shutdown,每帧 WM_PAINT 触发 6~10 次昂贵的
+ // GDI+ 初始化,是拖拽卡顿的主因。由于所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内,
+ // 改为会话开始 Startup 一次、结束 Shutdown 一次。FontFamily(SC_FONT_FACE) 与
+ // StringFormat(总是 Near/Near) 为常量;Font 仅依赖 fontPx(文字字号仅 SC_FONT_SIZES 三档),
+ // 均缓存复用。Graphics 仍每次按 hdc 新建(必须,因为绑定不同 DC)。
+ ULONG_PTR gdipToken = 0; // GDI+ 启动令牌(0 = 未初始化)
+ Gdiplus::GdiplusStartupInput gdipStartupInput;
+ bool gdipInited = false; // GDI+ 是否已 Startup
+ Gdiplus::FontFamily* gdipFontFamily = nullptr; // SC_FONT_FACE,会话内唯一
+ Gdiplus::StringFormat* gdipStrFmt = nullptr; // Near/Near,会话内唯一
+ Gdiplus::Font* gdipFonts[3] = {}; // 按 SC_FONT_SIZES 预建的 Font(索引对齐 SC_FONT_COUNT)
+};
+
+// ==================== 长截图(手动滚动捕获) ====================
+
+// 进入方式:在编辑态(CS_Confirmed)点工具栏「长截图」按钮 → BeginLongCapture
+// 隐式切换到 CS_LongCapturing,通过 PostMessage(WM_LONGCAPTURE_RUN) 在覆盖层
+// 窗口过程中执行 RunLongCapture。
+//
+// 快照选区 → 隐藏全屏覆盖层 → 创建侧边预览面板(缩略小地图 + 完成/取消)→
+// 滚轮停稳后采样一帧并增量拼接到小地图;向下滚追加到底部,向上滚前插到头部。
+//
+// 交互形态为手动模式:用户自己在选区上滚动鼠标滚轮(Raw Input 被动观察,不拦截输入),
+//
+// 独立于 CaptureContext 编辑态状态机的轻量上下文。复用截图会话的线程与
+// threadsafe 回调(g_screenshotTsfn),但滚动/拼接逻辑全部在此处实现,
+// 不触碰 CaptureContext 的标注/工具栏机制。
+
+#define WM_LONGCAPTURE_RUN (WM_APP + 200)
+
+// 行细节量 = 行内相邻采样列的灰度跳变数——空白/均匀区细节为 0,其上的“匹配”不可信
+// (在任何位移都能“匹配”,只会污染评分),以行权重(细节量截断到 0~4)参与评分,
+// 空白行天然不计分;整帧细节总量过低则无对齐依据。
+//
+// 匹配用帧数据:量化灰度行(h × cols)+ 每行匹配权重。
+
+// 多 ROI 独立验证的列带数量(左/中/右)。列带把采样列均分为三段,
+// 每段独立产出 Top-N 候选后做跨 ROI 加权聚类,防单个区域误匹配决定拼接结果。
+static const int LC_ROI_BANDS = 3;
+
+// 多尺度垂直结构 profile 的列桶数:每行按采样列均分为若干桶取平均量化灰度,
+// 再做 4/8 行滑动聚合,供候选位移的结构一致性辅助验证(LongCaptureProfileCorrelation)。
+static const int LC_PROFILE_BUCKETS = 8;
+
+struct LongMatchData {
+ std::vector gray; // 4bit 量化灰度,行距 cols
+ std::vector weight; // 每行匹配权重 0~4(= min(4, 行细节量))
+ std::vector bandWeight[LC_ROI_BANDS]; // 每行各列 ROI(左/中/右)权重 0~4
+ std::vector edge; // 每行一维垂直结构强度:相邻采样列量化灰度差绝对值之和
+ //(Weak 档匹配的边缘结构特征,见 LongCaptureEdgeCorrelation)
+ std::vector profile4; // 4 行聚合垂直结构 profile:h 行 × LC_PROFILE_BUCKETS 列桶,
+ // 桶值 = 窗口内行桶均值的平均(多尺度结构一致性验证用)
+ std::vector profile8; // 8 行聚合 profile(同上,更粗尺度)
+ int cols = 0;
+ int bandStart[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 起始采样列
+ int bandCols[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 采样列数
+ int h = 0;
+ int detailSum = 0; // 全帧行细节量总和(整帧近乎均匀时对齐不可信)
+};
+
+// 匹配模式(双模式识别):按候选位移的重叠量分档,overlap 越小要求证据越强。
+enum class LongCaptureMatchMode {
+ Normal, // 常规匹配:overlap >= max(24, 15% 视口高),沿用现有阈值与多数派 ROI 聚合
+ WeakOverlap // 弱重叠/大跳变匹配:overlap >= max(32, 5% 视口高),以显著更严格的
+ // 交叉验证(3/3 ROI 全票 + 边缘结构相关 + 动态高置信度 + 延迟确认)换取可识别性
+};
+
+// 匹配状态(三值结论):只有 SUCCESS 允许进入拼接提交。
+// LOW_CONFIDENCE / FAILED 一律按“本帧被拒绝”处理(重试/计失败),
+// 绝不允许用兜底 offset、上一帧推导 offset 或整帧插入的方式“继续流程”。
+enum LongMatchStatus {
+ LC_MATCH_FAILED = 0, // 无可信对齐(无候选峰 / ROI 相互矛盾)
+ LC_MATCH_LOW_CONFIDENCE, // 有候选但置信度不足(宽松档/单 ROI/置信度低于阈值)
+ LC_MATCH_SUCCESS // 可信对齐(严格档 + 多 ROI 一致 + 置信度达标)
+};
+
+// 识别失败的细分类别(可观测性增强:失败不再统一记为 FAILED,归类见 DetectPass /
+// TryStitch 各拒绝点)。只用于失败归因、调试日志与重试节奏选择(瞬态 vs 结构性),
+// 不参与任何验收阈值判定;成功路径一律保持 None。
+enum class LCFailReason {
+ None = 0,
+ NoCandidate, // NO_CANDIDATE:全部 ROI 无候选 / 整帧无对齐依据
+ CandidateWeak, // CANDIDATE_WEAK:有候选但整体匹配或综合置信度不足
+ PeakAmbiguous, // PEAK_AMBIGUOUS:峰值分离度不足(周期性/重复内容歧义)
+ GlobalMismatch, // GLOBAL_MISMATCH:全宽富验证不可评(重叠区近乎全空白)
+ SeamMismatch, // SEAM_MISMATCH:接缝窗证据不足
+ SpatialMismatch, // SPATIAL_MISMATCH:top/middle/bottom 空间一致性崩塌
+ ContinuityMismatch, // CONTINUITY_MISMATCH:匹配分布碎片化(连续性不足)
+ ProfileMismatch, // PROFILE_MISMATCH:多尺度垂直结构不一致
+ RoiInconsistent, // ROI_INCONSISTENT:跨 ROI 候选冲突 / 聚类支持不足
+ OffsetImplausible, // OFFSET_IMPLAUSIBLE:offset 被历史合理性校验拒绝
+ DirectionConflict, // DIRECTION_CONFLICT:最佳候选与最近滚动方向相反
+ FrameUnstable // FRAME_UNSTABLE:稳定性检测未过(本帧未进入正式匹配)
+};
+
+// 识别阶段输出(纯检测结果):未经 offset 合理性校验前不得用于提交。
+struct LongMatchOutcome {
+ LongMatchStatus status = LC_MATCH_FAILED;
+ LongCaptureMatchMode mode = LongCaptureMatchMode::Normal; // 本次候选的匹配档位
+ int offset = 0; // 候选位移 d(>0 向下滚,<0 向上滚;|d|≤1 视为未滚动)
+ int overlap = 0; // 候选位移对应的重叠行数(= h − |d|)
+ float overall = 0.0f; // 全宽重叠区加权匹配率
+ float seam = 0.0f; // 接缝窗加权匹配率
+ float top = 0.0f; // 重叠区上 1/3 加权匹配率(该段无有效行时记 0)
+ float middle = 0.0f; // 重叠区中 1/3 加权匹配率
+ float bottom = 0.0f; // 重叠区下 1/3 加权匹配率
+ float spatial = 0.0f; // top/middle/bottom 空间一致性综合分(局部假匹配在此崩塌)
+ float continuity = 0.0f; // 匹配分布连续性(最长连续段占比 + 匹配率 − 断点罚)
+ float profileScore = 0.0f; // 4/8 行聚合 profile 多尺度垂直结构一致度
+ float edgeCorrelation = 0.0f; // 重叠区行边缘结构强度的归一化相关度(Weak 档强证据)
+ float peakGap = 0.0f; // 与次优候选的综合分差(归一化;1 = 无竞争峰)
+ float roiWeighted = 0.0f; // 跨 ROI 加权证据(按 ROI 信息量加权融合的支持度)
+ float confidence = 0.0f; // 综合置信度(全宽匹配 + 空间/连续性 + 多尺度结构 +
+ // ROI 加权证据 + 峰值分离度的加权组合,见 DetectPass)
+ LCFailReason reason = LCFailReason::None; // 拒绝原因分类(成功时保持 None;仅归因/日志/重试节奏用)
+ float textureRatio = 0.0f; // 重叠区有效纹理行占比(证据量计价,进入综合置信度)
+ int bandOffsets[LC_ROI_BANDS] = {0, 0, 0}; // 各 ROI 独立求得的位移
+ bool bandValid[LC_ROI_BANDS] = {false, false, false}; // 该 ROI 是否产出可信候选
+ int validBandCount = 0; // 产出候选的 ROI 数
+ int agreeCount = 0; // 最终候选所在聚类的支持 ROI 数(加权融合后)
+};
+
+// 单帧采样结论(识别→校验→提交管线的对外语义)。
+enum class LCSampleOutcome {
+ Stitched, // 已安全提交拼接且新增了拼接行(唯一扩展累计拼接内容的结局)
+ Repositioned,// 匹配成功但新视口完全落在已捕获内容范围内(反向回滚未越过捕获边界):
+ // 只推进当前视口基准(lastFrame/committedContentTop)并移动小地图当前
+ // 区域标注,不新增行、不计帧数——已捕获内容的重复帧绝不再次拼接
+ NoChange, // 匹配成功但内容未滚动(d=0);与“匹配失败”“滚动到底”均独立
+ WeakPending, // Weak 候选首次成立:只登记待复核候选(pendingMatch),不提交、不改任何累计状态
+ WeakRejected,// Weak 候选被复核否决(第二次候选不一致/置信度跌破门槛/offset 异常):
+ // 与“完全无候选”的硬失败不同,不计入普通失败计数,走独立重试预算
+ Unstable, // 稳定性检测未过(页面仍处滚动/重绘/懒加载过渡):本帧不进入正式匹配,
+ // 不提交、不修改任何累计状态(含跟踪/历史/Weak 候选),由采样主循环
+ // 短延迟后重新采样,等待页面稳定
+ Failed // 硬失败(无候选/验证崩塌/Normal offset 异常);累计状态保持原样
+};
+
+// Weak Match 的“延迟确认提交”候选(防污染核心机制):首次弱重叠可信候选只登记、
+// 不提交;下一次稳定采样必须独立复现一致候选(|Δoffset| ≤ LC_WEAK_CONFIRM_OFFSET_TOL
+// 且置信度不跌破动态门槛)才允许 Commit。候选被否决/放弃时只清除本结构,
+// 绝不触碰累计拼接状态(body/headRev/lastFrame/lastMatch/offsetHistory)。
+struct LongCapturePendingMatch {
+ bool valid = false;
+ int offset = 0;
+ float confidence = 0.0f;
+ LongCaptureMatchMode mode = LongCaptureMatchMode::Normal;
+};
+
+// 最近帧历史条目(多跳匹配恢复的基准池)。内容坐标 = 以首帧视口顶为原点、不随头部
+// 前插平移的滚动空间坐标(拼接图内位置 = contentY + headRows)。
+// 只保存匹配所需数据(量化灰度/行权重/ROI 权重/profile 等,约 1MB 级/帧),
+// 不保存完整位图;环形容量见 LC_HISTORY_FRAMES(long_capture_windows.cpp)。
+// contentY 语义:已提交帧精确(可作跨帧提交链锚点);未提交帧为 tentative 估计。
+struct LongCaptureFrameHistory {
+ int frameId = -1; // 采样序号(识别 lastFrame 直连条目 / 日志关联)
+ LongMatchData match; // 该帧匹配数据(LongCaptureBuildMatchData 全量产物)
+ int64_t contentY = 0; // 视口顶内容坐标(已提交=精确;未提交=估计)
+ bool committed = false; // 已提交帧:位置精确、可作提交链锚点
+ bool validForMatching = true; // 近乎空白的帧不可作多跳匹配基准
+};
+
+// 长截图工具栏二级菜单类型:方向与裁剪均为图标 popover(同一套展开/绘制/命中机制,
+// long_capture_windows.cpp),popover 悬停各 cell 有 title 式 tooltip
+enum LCMenuKind {
+ LCM_None = 0, // 无展开菜单
+ LCM_Direction, // 方向 popover:纵向/横向图标 cell,悬停/点击方向按钮展开(已拼接多帧后锁定)
+ LCM_Crop // 裁剪 popover:悬停/点击裁剪按钮展开的图标浮层(丢弃上方/下方内容,随方向切换左右变体;已裁剪时含重置)
+};
+
+struct LongCaptureContext {
+ // 选项(由 start() 的 options.longCapture 注入,会话开始时拷贝)
+ int maxFrames = 100; // 最大拼接帧数(防无限增长,默认与 CaptureContext.lcMaxFrames 一致)
+ int interval = 250; // 滚轮停止后等待内容稳定的毫秒数(采样防抖)
+
+ // 虚拟屏幕(逻辑坐标)与 DPI
+ int vx = 0, vy = 0, vw = 0, vh = 0;
+ double dpiScale = 1.0; // 单一 scale 模型的已知限制见 CaptureContext.dpiScale 注释(CR-023)
+
+ // 选区(逻辑虚拟屏幕坐标,已规范化)
+ RECT selection = {};
+
+ // 采样裁剪矩形(逻辑虚拟屏幕坐标):选区每边内缩 LC_CROP_INSET_LOGI,
+ // 避开选区框描边与边缘抗锯齿像素污染拼接结果;滚轮过滤与结果 rect 均以此为准。
+ RECT cropRect = {};
+
+ // 选区物理像素裁剪参数(相对虚拟屏幕原点的物理偏移 + 尺寸)。
+ // 纵向模式 physW/physH 即帧尺寸;横向模式(horizontal=true)帧缓冲为屏幕采样的
+ // 转置(physW=capH、physH=capW),下游匹配/拼接/缩略图管线与纵向完全同构。
+ int physX = 0, physY = 0, physW = 0, physH = 0;
+ // 屏幕采样 DIB 尺寸(未转置的物理宽高;方向切换只交换 physW/physH,DIB 不变)
+ int capW = 0, capH = 0;
+ // 虚拟屏幕物理原点(屏幕 DC 坐标,DPI 感知下为物理像素),直接区域采样时作 BitBlt 源偏移
+ int physOriginX = 0, physOriginY = 0;
+ // 专用 DIB 段:每帧采样的可复用目标(避免反复新建位图 + GetDIBits 回读)
+ HDC dibDC = NULL;
+ HBITMAP dibBmp = NULL;
+ void* dibBits = nullptr;
+ int dibW = 0, dibH = 0;
+
+ // 预览面板放置在选区上方(空间兜底时的退化形态):向下生长受选区顶边约束,
+ // 避免面板长大后重新覆盖选区导致自身入画
+ bool panelAbove = false;
+
+ // 拼接结果:物理像素 BGRA,宽度恒为 physW;高度 stitchH 增长。
+ // 分两段存储以让拼接保持 O(新增行):向上滚新增行存 headRev(倒序,reverse 后为显示顺序),
+ // 向下滚新增行存 body(正序);实际图像 = reverse(headRev) + body。
+ std::vector headRev;
+ std::vector body;
+ int headRows = 0, bodyRows = 0;
+ int stitchH = 0;
+ // 最近一次采样的视口帧(相邻帧重叠检测基准,缓冲随拼接迭代复用)
+ std::vector lastFrame;
+ // lastFrame 的模糊对齐数据(两帧采样之间 lastFrame 不变,重叠检测直接复用,
+ // 只在拼接成功后随 lastFrame 旋转更新)
+ LongMatchData lastMatch;
+
+ // 面板两级缩略图:先按固定列宽(面板预览宽)增量缩列,绘制时再合并缩行。
+ // 分段与拼接缓冲同构(thumbHeadRev 倒序 / thumbBody 正序),thumbMerged 为绘制用临时合并缓冲。
+ std::vector thumbHeadRev;
+ std::vector thumbBody;
+ std::vector thumbMerged;
+ int thumbW = 0; // 缩略图列宽(≤ physW)
+ int thumbHeadH = 0; // 头部段行数(合并后显示于顶部)
+ int thumbH = 0; // 缩略图总行数
+ bool thumbDirty = false;
+
+ // 滚轮观察状态(面板 WM_INPUT 经本线程消息泵分发,与主循环同线程,无需原子)
+ bool wheelPending = false;
+ DWORD lastWheelTick = 0;
+ DWORD lastSampleTick = 0; // 最近一次采样轮触发时刻(滚动中主动采样节拍用;首帧后初始化)
+ int lastDir = 0; // +1 最近一次向下滚(追加底部);-1 向上滚(前插头部)
+
+ // —— 拼接失败恢复与 offset 合理性校验状态 ——
+ // 这些字段只在「成功提交拼接」后更新;识别失败/被拒绝的帧绝不写入,
+ // 保证单帧误识别无法通过污染匹配基准或位移历史把错误扩散到后续帧。
+ std::vector offsetHistory; // 最近若干次成功拼接的 |d|(px,长度上限见 LC_OFFSET_HISTORY_LEN)
+ int noChangeCount = 0; // 连续「内容未变化」采样数(仅匹配成功且 d=0 时递增;与失败完全独立)
+ bool reachedBottom = false; // 滚动到底(由连续多次内容未变化确认;匹配失败绝不置位)
+
+ // —— Weak(低重叠大跳变)延迟确认与独立重试状态 ——
+ // pendingMatch 只在本结构内暂存候选,任何未确认路径都不修改累计拼接状态;
+ // weakTries 计连续未决的 Weak 采样轮数,达 LC_WEAK_MAX_TRIES 即放弃当前候选链重新观察。
+ LongCapturePendingMatch pendingMatch; // 待复核的 Weak 候选(有效时驱动无滚轮的继续采样)
+ int weakTries = 0; // 连续未决的 Weak 候选采样轮数
+ int sampleIndex = 0; // 采样序号(LC_DEBUG_LOG 调试日志用)
+
+ // —— Tentative 视觉跟踪状态(与正式拼接状态完全解耦的"预计当前位置"层)——
+ // 正式拼接位置(committed)= committedContentTop,仅 LongCaptureCommitStitch 推进;
+ // 预计当前位置(tentative)= tentativeContentTop,由视觉验证(多跳匹配/零位移对齐/
+ // 全同帧)直接设定或轻量预测推进。FAILED / LOW_CONFIDENCE 帧只允许触碰本组字段,
+ // 绝不修改 body/headRev/stitchH、lastFrame/lastMatch、offsetHistory 与拼接缓冲。
+ // 防漂移约束:相对 committed 漂移上限(2×视口高)+ 连续无视觉依据冻结
+ //(见 LongCaptureTrackingSetVisual / LongCaptureTrackingAdvancePredicted)。
+ int64_t committedContentTop = 0; // lastFrame 视口顶(内容坐标;CommitStitch 内 += d)
+ int64_t tentativeContentTop = 0; // 预计当前视口顶(内容坐标;SUCCESS 后与 committed 对齐)
+ bool tentativeValid = false; // 是否已建立可信跟踪(小地图据此显示虚线预计框)
+ float tentativeConfidence = 0.0f; // tentative 置信度(视觉验证来源高,预测链逐帧衰减)
+ int trackUnreliableStreak = 0; // 连续无视觉依据采样数(达阈值冻结预测推进)
+ int trackingRevision = 0; // 跟踪状态变更计数(RunLongCapture 据此刷新小地图)
+ int lastCommittedFrameId = 0; // 最新已提交帧序号(多跳回溯时跳过 hop1 直连基准)
+ std::vector frameHistory; // 最近帧环形历史(容量 LC_HISTORY_FRAMES)
+ std::vector weakCandidateOffsets; // 最近弱重叠候选位移(Weak 时间一致性样本)
+
+ // —— 帧稳定性检测(进入正式 DetectMatch 前的准入闸门,纯诊断层)——
+ // stableRef* = 上一次抓帧的 4bit 灰度稀疏采样,供与本次抓帧对比判断页面是否仍在
+ // 快速变化(滚动动画/重绘/懒加载过渡)。每次抓帧后滚动更新;只在稳定性闸门启用
+ // 且参考帧新鲜(间隔 ≤ LC_STABLE_REF_MAX_GAP)时参与判定。绝不参与 offset 评分、
+ // 不触碰任何累计拼接状态(含跟踪/历史/Weak 候选)。
+ std::vector stableRefGray;
+ int stableRefCols = 0;
+ int stableRefH = 0;
+ DWORD stableRefTick = 0;
+ bool stableRefValid = false;
+
+ // —— 失败可观测性:最近一次拒绝的原因分类与最佳候选证据快照 ——
+ // 仅调试日志与采样主循环的重试节奏选择(瞬态 vs 结构性)使用,不参与任何判定。
+ LCFailReason lastFailReason = LCFailReason::None;
+ LongMatchOutcome lastReject;
+
+ // —— 滚轮 delta 软先验(仅用于候选排序加分,绝不作为期望区间硬约束) ——
+ // wheelAccumDelta 在面板 WM_INPUT 累计,成功提交时折算 px/notch 并 EMA 平滑;
+ // 失败采样绝不更新估计。Windows/浏览器/平滑滚动/触控板都使位移与 notch 非线性相关,
+ // 因此先验只轻微影响同档候选的排序先后(LC_WHEEL_PRIOR_BONUS),不改变任何阈值。
+ int wheelAccumDelta = 0; // 自上次成功提交以来累计的滚轮增量(带符号,WHEEL_DELTA=120)
+ float pixelsPerWheelNotch = 0.0f; // 「成功像素位移 ↔ 滚轮 notch」在线估计(0 = 尚无估计)
+
+ // 控制信号(跨线程:abortLongCapture 由 JS 线程设置)
+ std::atomic abortFlag{false};
+ std::atomic finishFlag{false}; // 用户点「完成并复制」
+ std::atomic frameCount{0};
+
+ // —— 选区底部工具栏(宽高标签 / 方向 / 自动滚动 / 裁剪 / 保存 / 取消 / 完成并复制)——
+ bool horizontal = false; // 长截图方向:false=纵向(默认);true=横向(帧缓冲转置复用纵向管线)
+ bool autoScroll = false; // 自动滚动(默认关闭):开启时光标一次性移到选区中心,此后定时器只注入滚轮
+ //(SendInput 按光标下方窗口路由;单拍不动鼠标,见 LongCaptureAutoScrollTick)
+ std::atomic saveFlag{false}; // 用户点「保存到本地」:主循环内弹保存对话框并直接落盘
+ // —— 裁剪状态(全部用「内容坐标」表达:拼接图行 = 内容坐标 + headRows)——
+ // 内容坐标不随头部前插/主体追加平移,裁剪线因此永远锚定在页面的同一位置,
+ // 不会因继续滚动导致窗口错位(旧的绝对拼接行号在前插后会整体漂移)。
+ // 裁剪动作只收紧输出行窗口并登记「待剔除区间」,绝不立即修改拼接缓冲与匹配基准;
+ // 待剔除区间在第一次朝该方向的成功提交时物理删除(见 CommitStitch 入口的延迟剔除),
+ // 删除后该侧边界重新开放——继续滚动的新增内容直接续接在裁剪线之后继续拼图。
+ int64_t cropTopY = INT64_MIN; // 输出窗口上界(内容坐标);INT64_MIN = 该侧开放(未裁剪)
+ int64_t cropBottomY = INT64_MAX; // 输出窗口下界(内容坐标);INT64_MAX = 该侧开放(未裁剪)
+ bool cropPendTop = false; // 待剔除「上方已捕获内容」(丢弃上方登记):下一次 d<0 提交时删除
+ int64_t cropPendTopLo = 0, cropPendTopHi = 0; // 待删内容区间 [lo, hi)
+ bool cropPendBottom = false; // 待剔除「下方已捕获内容」(丢弃下方登记):下一次 d>0 提交时删除
+ int64_t cropPendBottomLo = 0, cropPendBottomHi = 0;
+ bool cropped = false; // 是否存在任何生效的裁剪约束(重置菜单项/图标徽标显示用;
+ // 含尚未触发的待剔除区间,触发或重置后自动回收)
+ int tbHover = -1; // 工具栏 hover 项(LongToolbarItem,-1=无)
+ int tbPressItem = -1; // 工具栏按下项(WM_LBUTTONDOWN 命中,-1=无):UP 必须命中与
+ // DOWN 相同的目标才触发点击——编辑工具栏「长截图」按钮在按下
+ // 瞬间创建本工具栏,残留的松开事件绝不能误触恰好同位的按钮
+ int tbPressMenuRow = -1; // 二级菜单 popover 按下的 cell(-1=无;语义同 tbPressItem)
+ bool tbDragging = false; // 正在拖动工具栏窗口(按住最左 6 点把手,鼠标捕获中)
+ int tbDragGrabDX = 0, tbDragGrabDY = 0; // 按下点相对工具栏窗口左上角的偏移(物理像素)
+ LCMenuKind menuKind = LCM_None; // 展开中的二级菜单类型(方向/裁剪均为图标 popover)
+ int menuHover = -1; // 二级菜单 popover hover 的图标 cell(-1=无)
+ bool menuBelow = false; // 二级菜单绘制在工具栏下方(朝避让选区的一侧展开,见 LongCaptureSetMenu)
+ LCMenuKind popHoverDisarm = LCM_None; // 「悬停展开」被解除武装的菜单锚点(LCM_None=全部武装):
+ // 点击收起其 popover 后置为该菜单,光标移出锚点前不再
+ // 因悬停重开,防止点击收起与悬停展开互相打架(方向/裁剪通用)
+ std::vector thumbDisplay; // 横向模式显示用回转缩略图(纵向直接用 thumbMerged)
+ int thumbDisplayW = 0, thumbDisplayH = 0;
+ bool thumbDisplayDirty = false;
+ int autoFailStreak = 0; // 自动滚动中连续硬失败采样轮数(达上限自动停止,防丢内容)
+
+ // 输出结果(物理像素)
+ int outWidth = 0, outHeight = 0;
+ std::string base64;
+ bool success = false;
+};
+
+// 显示器枚举回调数据
+
+struct MonitorEnumData {
+ LONG minLeft, minTop, maxRight, maxBottom;
+ double totalDpiScale;
+ int monitorCount;
+ HMODULE shcore; // 外层一次 LoadLibraryW("shcore.dll") 的句柄,回调内复用 GetDpiForMonitor(避免每显示器重复 Load)
+};
+
+// 采样裁剪相对选区每边内缩(逻辑像素):避开选区框描边与边缘抗锯齿,防止污染拼接内容。
+
+static const int LC_CROP_INSET_LOGI = 2;
+
+// ---- 可变全局变量(定义见各归属 .cpp)----
+// 访问规则(CR-022:此前线程归属散落在各字段注释,现集中声明):
+// 线程模型——本模块仅两条线程访问这些全局:JS 线程(NAPI 导出 start/abort 等)与
+// 截图捕获线程(ScreenshotCaptureThread,串行创建所有覆盖层窗口并派发其消息,
+// 各窗口 wndproc 与绘制函数均在该线程内执行,互斥天然串行)。
+
+// 覆盖层主窗口句柄:捕获线程独占。start() 时在捕获线程创建,消息循环内读写,
+// WM_DESTROY 末尾由捕获线程置 NULL(wndproc_windows.cpp)。JS 线程不直接访问。
+extern HWND g_screenshotOverlayWindow;
+
+// 截图进行中标志(atomic):跨线程。捕获线程 start 入口置 true、各退出路径置 false;
+// JS 线程 start() 入口读它拒绝重入(已进行中则直接返回)。无锁,依赖 atomic 可见性。
+extern std::atomic g_isCapturing;
+
+// 截图结果回传 JS 的 threadsafe function:跨线程但写时序确定。JS 线程在
+// StartRegionCaptureWithPrimedFrame 创建并初始化引用计数;此后由捕获线程独占
+// 调用/释放(EmitScreenshotResult 唯一调用点 + ReleaseScreenshotTsfn 唯一释放点,
+// 均在捕获线程内),JS 线程不再触碰。CR-002 收口后访问点唯一。
+extern napi_threadsafe_function g_screenshotTsfn;
+
+// 截图捕获线程对象:JS 线程独占。start() 创建后立即 detach,此后不再访问
+// (依赖 g_isCapturing 而非 join 判定会话状态,故无需 join;线程自然退出)。
+extern std::thread g_screenshotThread;
+
+// 自动确认模式(atomic):跨线程。JS 线程 start() 写入;捕获线程在会话开始时
+// 一次性 load 进 CaptureContext.autoConfirm,会话期内不再读此全局。
+extern std::atomic g_autoConfirm;
+
+// 预截首帧(及保护其读写的互斥锁):跨线程,受 g_primedScreenshotFrameMutex 保护。
+// JS 线程(PrimeScreenshotFrameNow/PrimeScreenshotFrame)与捕获线程(会话开始消费)
+// 均在锁内读写 bitmap 与 valid 等字段;bitmap 句柄所有权在锁内转移(写方创建、
+// 读方消费后置 invalid)。锁外不得解引用 bitmap。
+extern PrimedScreenshotFrame g_primedScreenshotFrame;
+extern std::mutex g_primedScreenshotFrameMutex;
+
+// 截图上下文指针:捕获线程独占。会话开始置 &ctx(栈上局部变量)、结束置 nullptr;
+// 窗口过程/绘制函数读它取 GDI+ 会话级资源(仅捕获线程内执行)。会话期外恒为 nullptr,
+// 各读点均带 nullptr 守卫。JS 线程不访问。
+extern CaptureContext* g_captureCtx;
+
+// 长截图上下文指针(atomic):跨线程,受 g_longCtxMutex 保护指针生命周期。
+extern std::atomic g_longCtx;
+// 保护 g_longCtx 指针的生命周期:JS 线程 LongCaptureAbort 的 load/abortFlag 写,
+// 与捕获线程 WM_LONGCAPTURE_RUN 清理段的 store(nullptr)/delete 互斥(定义见
+// session_windows.cpp)。锁内只做指针读写与原子标志写,禁止 SendMessage 等
+// 可能死锁的调用;abortFlag 本身保持 atomic,由锁保证写入时对象仍存活。
+extern std::mutex g_longCtxMutex;
+
+// 长截图会话窗口句柄(控制面板/工具栏/蒙版):捕获线程独占。BeginLongCapture
+// 时由捕获线程创建,wndproc 清理段(捕获线程)销毁并置 NULL。JS 线程不访问。
+extern HWND g_longControlWindow;
+extern HWND g_longToolbarWindow;
+extern HWND g_longMaskWindow;
+
+// 长截图参数:跨线程,但时序天然串行——JS 线程 start() 写入(先按默认重置再按 JS
+// 覆盖,消除跨会话粘滞),捕获线程 BeginLongCapture 一次性读入 LongCaptureContext。
+// start() 写入发生在 g_screenshotThread 创建之前,捕获线程读在创建之后,happens-before
+// 由线程创建建立,无需额外同步。
+extern int g_lcMaxFrames;
+extern int g_lcInterval;
+
+// ---- 跨文件函数声明 ----
+bool IsCornerRadiusHandle(int h);
+bool IsVectorTool(int btn);
+bool IsDragTool(int btn);
+bool CanShowStylePopupTool(int btn);
+AnnotationType ToolToAnnotationType(int btn);
+int AnnotationTypeToTool(AnnotationType t);
+SCPanelMetrics CalcPanelMetrics(double dpiScale);
+SCToolbarMetrics CalcToolbarMetrics(double dpiScale);
+SCHandleMetrics CalcHandleMetrics(double dpiScale);
+HBITMAP RenderSvgToBitmap(const char* svgText, COLORREF color, int px);
+int CalcToolbarWidth(const SCToolbarMetrics& m);
+SCPopupMetrics CalcPopupMetrics(double dpiScale);
+// 多屏异分辨率布局基准:取包含参考矩形(绝对坐标)的显示器物理边界。
+// 工具栏/子菜单的上下翻转判定必须用所在显示器自身的边界——整个虚拟屏幕包围盒
+// 会被高分屏拉大,低分屏上选区已触本屏底边仍会被误判为"下方放得下"而不翻转。
+bool GetMonitorBoundsForRect(const RECT& refAbs, RECT& out);
+void CalcPopupPlacement(const RECT& toolbarRect, int virtualX, int virtualY,
+ int virtualW, int virtualH, const SCPopupMetrics& m, int pw, int ph, RECT& out);
+void CalcPopupPosition(const RECT& toolbarRect, int virtualX, int virtualY,
+ int virtualW, int virtualH, const SCPopupMetrics& m, RECT& out);
+void CalcMosaicPopupSize(const SCPopupMetrics& m, int& outW, int& outH);
+int HitTestMosaicPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m);
+void DrawMosaicPopup(HDC hdc, const RECT& popupRect, int modeIdx, int sizeIdx, int radiusIdx, const SCPopupMetrics& m);
+int HitTestPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m);
+void DrawPopup(HDC hdc, const RECT& popupRect, int colorIdx, int firstIdx, bool isTextTool, const SCPopupMetrics& m);
+void PushAnnotationHistory(CaptureContext* ctx);
+bool UndoAnnotations(CaptureContext* ctx);
+bool RedoAnnotations(CaptureContext* ctx);
+bool InitGdipResources(CaptureContext* ctx);
+Gdiplus::Font* GetGdipFont(CaptureContext* ctx, int fontPx);
+double GetDpiScaleFactor();
+BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData);
+bool CreateBackBuffer(HDC& outDC, HBITMAP& outBmp, int w, int h);
+bool PrimeScreenshotFrameNow();
+bool AcquireScreenshotBase(HDC& outMemDC, HBITMAP& outBitmap, int& vx, int& vy, int& vw, int& vh, double& dpiScale);
+COLORREF GetPixelColorFromBitmap(HDC memDC, int x, int y, int vx, int vy, double dpiScale);
+void ColorrefToStrings(COLORREF color, char* hexBuf, char* rgbBuf);
+std::vector EnumWindowsForCapture();
+int FindWindowAtPoint(const std::vector& windows, int x, int y);
+void CalcPanelPosition(int mx, int my, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py);
+void CalcResizePanelPosition(int handle, const RECT& sel, int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py);
+void RestoreDirtyRegion(HDC backDC, HDC memDC, const RECT& dirty, double dpiScale);
+RECT InflateRectBy(const RECT& r, int margin);
+bool IsValidRect(const RECT& r);
+RECT UnionRectSafe(const RECT& a, const RECT& b);
+void DrawInfoPanel(HDC hdc, int panelX, int panelY, COLORREF color, HDC memDC, int vx, int vy, int mx, int my, double dpiScale, const SCGdiResources& gdi, const SCPanelMetrics& m, int virtualW, int virtualH);
+RECT DrawSizeLabel(HDC hdc, int width, int height, int refLeft, int refTop, int refRight, int refBottom, int virtualW, int virtualH, const SCGdiResources& gdi, const SCPanelMetrics& m);
+RECT DrawSelection(HDC hdc, int x1, int y1, int x2, int y2, int vx, int vy, int vw, int vh, const SCGdiResources& gdi, const SCPanelMetrics& m);
+void DrawWindowHighlight(HDC hdc, const RECT& rect, int vx, int vy, const SCGdiResources& gdi);
+void DrawDimMask(HDC backDC, const SCGdiResources& gdi, int selLeft, int selTop, int selRight, int selBottom, int virtualW, int virtualH, int radius);
+RECT NormalizeRect(const RECT& r);
+bool PointInRect(int x, int y, const RECT& r);
+int HitTestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius);
+int FindNearestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius, int proximityMargin);
+RECT CornerHandleDirtyRect(const CaptureContext* ctx, int corner);
+int HitTestHandle(int x, int y, const RECT& sel, int handleSize);
+LPCWSTR HandleCursor(int handle);
+void CalcToolbarPosition(const RECT& selRel, int virtualX, int virtualY,
+ int virtualW, int virtualH, const SCToolbarMetrics& m, RECT& out);
+int HitTestToolbar(int x, int y, const RECT& toolbarRect, const SCToolbarMetrics& m);
+void DrawResizeHandles(HDC hdc, const RECT& selRel, int handleSize);
+void DrawCornerRadiusHandle(HDC hdc, const RECT& selRel, int handleSize, int inset, int radius, int corner);
+void DrawConfirmedBorder(HDC hdc, const RECT& selRel, const SCGdiResources& gdi, int radius);
+void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius);
+void DrawToolbar(HDC hdc, const RECT& toolbarRect, int hoverBtn, int activeTool, const SCGdiResources& gdi, const SCToolbarMetrics& m, const SCIconCache& icons);
+// 工具栏 title 式 tooltip:Tick 由会话空闲循环轮询(维护停顿/显示状态并失效气泡矩形),
+// Draw 在 OnPaint 工具栏之后调用(气泡画进 backDC,盖在工具栏/子菜单之上)。
+void TickToolbarTooltip(CaptureContext* ctx, HWND overlayWnd);
+void DrawToolbarTooltip(HDC hdc, CaptureContext* ctx);
+bool MosaicBlitRect(HDC targetDC, HDC srcDC, int dstX0, int dstY0, int dstW, int dstH, int srcAbsX0, int srcAbsY0, int blockPx, int virtualX, int virtualY, double dpiScale);
+void FreeMosaicBase(CaptureContext* ctx);
+void InitMosaicBrushCursors(CaptureContext* ctx);
+void FreeMosaicBrushCursors(CaptureContext* ctx);
+bool RebuildMosaicBase(CaptureContext* ctx);
+bool MosaicBaseNeedsRebuild(const CaptureContext* ctx);
+bool HasMosaicToRender(const std::vector& annotations, const Annotation* curDrawing);
+void RevealMosaicToTarget(HDC targetDC, HDC mosaicBase, const std::vector& annotations, const Annotation* curDrawing, const RECT& contentBounds, float ox, float oy);
+void DrawAnnotations(HDC hdc, const RECT& selRel, int virtualX, int virtualY, const std::vector& annotations, const Annotation* curDrawing);
+void CompositeAnnotations(HDC finalDC, HDC srcDC, const std::vector& annotations, const RECT& rect, int virtualX, int virtualY, double dpiScale, int mosaicBlockPx);
+std::string BitmapToBase64Png(HBITMAP hBitmap);
+bool SaveBitmapToClipboard(HBITMAP hBitmap);
+// 将 HBITMAP 编码为 PNG:可选产出 base64 / 原始字节,可选直接写入文件(单次编码)。
+bool EncodeHBitmapPng(HBITMAP hBitmap, std::string* base64Out, std::string* rawOut,
+ const wchar_t* filePath);
+// 将 HALFTONE 缩放模式设置到目标 DC(output_windows.cpp / long_capture_windows.cpp 共用)。
+// HALFTONE 在做下采样缩放时比默认 COLORONCOLOR 质量更好,但 BrushOrg 会被 StretchBlt 用到,
+// 因此同时把画刷原点复位到 (0,0) 避免抖动(MSDN 推荐配套调用)。
+void SetHalftoneStretchMode(HDC dc);
+
+ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect, int vx, int vy,
+ double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx);
+// 统一的 ScreenshotResult 发射口(CR-017):字段参数化构造结果并经截图会话 TSFN 回传 JS。
+// 内部统一处理守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时,
+// 自行 delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。
+// 仅允许在截图线程内调用。各发射点(确认/取消/保存/ESC/长截图完成等)统一走此函数。
+// success=false 时坐标/尺寸/base64 全置 0/空(与取消路径语义一致)。
+void EmitScreenshotResult(bool success, int x = 0, int y = 0, int x2 = 0, int y2 = 0,
+ int width = 0, int height = 0, const std::string& base64 = "");
+// 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS,
+// 唤醒 await 方避免永久挂起(CR-002 早退路径统一收口点)。
+void FailFast();
+void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd);
+bool RunLongCapture(LongCaptureContext* c);
+// 单帧「识别→offset 校验→(Weak 档)延迟确认→提交」管线(RunLongCapture 采样流程与单元测试共用入口):
+// Normal 档识别 SUCCESS 且 offset 通过历史合理性校验即提交;Weak 档(低重叠大跳变)首次可信候选
+// 只登记 pendingMatch(WeakPending),下一次稳定采样独立复现一致候选才提交(Stitched)。
+// 任何拒绝(Failed/WeakPending/WeakRejected/Unstable)都不修改累计拼接状态。
+// allowStabilityGate:进入正式 DetectMatch 前启用轻量帧稳定性闸门(采样主循环传入
+// true;默认 false 保持单元测试/合成帧直连注入的旧行为完全不变)。闸门未过时返回
+// Unstable——本帧不匹配、不提交、不改任何状态,调用方短延迟后重新采样。
+LCSampleOutcome LongCaptureTryStitch(LongCaptureContext* c, std::vector& curr, int dir,
+ bool allowStabilityGate = false);
+void LongCaptureAbort();
+std::wstring PromptSaveFilePath(HWND hwndOwner);
+bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy, double dpiScale,
+ const std::vector& anns, const std::wstring& filePath, int radius, int mosaicSizeIdx);
+void ClampCornerRadius(CaptureContext* ctx);
+bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc);
+RECT MeasureTextAnnotation(HDC hdc, Annotation& a);
+int HitTestTextAnnotations(std::vector& anns, int x, int y, HDC hdc);
+RECT MeasureAnnotationBounds(Annotation& a, HDC hdc);
+int HitTestAnnotation(std::vector& anns, int x, int y, HDC hdc);
+int HitTestAnnotationResizeHandle(const Annotation& a, int x, int y, HDC hdc, int handleSize);
+void TransformAnnotationByBox(Annotation& a, const RECT& oldBox, const RECT& newBox);
+void MeasureTextGdip(HDC hdc, const std::wstring& text, int fontPx, float& outOffsetX, float& outOffsetY, float& outW, float& outH);
+int CalcCaretPosFromMouse(HDC hdc, const std::wstring& text, int fontPx, int textX, int mouseX);
+void InvalidateAnnotationOp(HWND hwnd, CaptureContext* ctx, const RECT& curBox);
+RECT CalcSelectionDirty(CaptureContext* ctx, bool includeToolbar);
+void InvalidateTextLine(HWND hwnd, CaptureContext* ctx);
+
+// ---- 选区状态机辅助(实现在 wndproc_windows.cpp,消息处理共用)----
+void EnterConfirmed(CaptureContext* ctx, const RECT& sel);
+RECT ResizeSelectionFromHandle(const RECT& startSelection, int handle, int dx, int dy,
+ const RECT& virtualBounds,
+ bool hasContent, const RECT& contentBounds,
+ bool enforceMinSize);
+void GetResizeHandleAnchor(int handle, const RECT& sel, int& ax, int& ay);
+void ApplyResizeSelection(HWND hwnd, CaptureContext* ctx);
+bool HandleSelectionNudgeKey(HWND hwnd, CaptureContext* ctx, WPARAM vk);
+
+// ---- 覆盖层消息处理(实现在 overlay_paint/overlay_input,由 WndProc 分发)----
+LRESULT OnPaint(HWND hwnd, CaptureContext* ctx);
+LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx);
+LRESULT OnMouseMove(HWND hwnd, CaptureContext* ctx);
+LRESULT OnLButtonUp(HWND hwnd, CaptureContext* ctx);
+LRESULT OnKeyDown(HWND hwnd, WPARAM wParam, CaptureContext* ctx);
+LRESULT OnImeComposition(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx);
+LRESULT OnChar(HWND hwnd, WPARAM wParam, CaptureContext* ctx);
+LRESULT OnSetCursor(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx);
+
+LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
+void ScreenshotCaptureThread();
diff --git a/src/screenshot/lc_frame_io.cpp b/src/screenshot/lc_frame_io.cpp
new file mode 100644
index 0000000..876002e
--- /dev/null
+++ b/src/screenshot/lc_frame_io.cpp
@@ -0,0 +1,314 @@
+// 长截图子系统:抓帧 / DIB / 缩略图 / 位图构建 / 消息泵。
+// CR-021 拆分自 long_capture_windows.cpp 的「滚轮观察 / 逐帧采样」与
+// 「缩略图 / 输出行窗口 / 结果位图 / 消息泵 / 首帧初始化」段。
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// ==================== 长截图:滚轮观察 / 逐帧采样 / 增量拼接 ====================
+
+bool LongCaptureRegisterWheelObserver(HWND target) {
+ if (!target) return false;
+ RAWINPUTDEVICE rid = {};
+ rid.usUsagePage = 0x01; // Generic Desktop
+ rid.usUsage = 0x02; // Mouse
+ rid.dwFlags = RIDEV_INPUTSINK; // 非前台也接收(前台是选区下的底层应用)
+ rid.hwndTarget = target;
+ return RegisterRawInputDevices(&rid, 1, sizeof(rid)) != FALSE;
+}
+
+// 会话结束注销滚轮观察(RIDEV_REMOVE 要求 hwndTarget 为 NULL)。
+
+void LongCaptureUnregisterWheelObserver() {
+ RAWINPUTDEVICE rid = {};
+ rid.usUsagePage = 0x01;
+ rid.usUsage = 0x02;
+ rid.dwFlags = RIDEV_REMOVE;
+ RegisterRawInputDevices(&rid, 1, sizeof(rid));
+}
+
+// 写入可复用的 DIBSection,再复制为 32bpp BGRA 缓冲(自上而下行序)。成功返回 true。
+
+// 采样选区当前视口帧:直接用屏幕 DC BitBlt 出选区物理像素(不再整屏抓取后裁剪),
+
+bool LongCaptureEnsureDib(LongCaptureContext* c, HDC screenDC) {
+ if (c->dibBmp && c->dibW == c->capW && c->dibH == c->capH) return true;
+ if (c->dibDC) { DeleteDC(c->dibDC); c->dibDC = NULL; }
+ if (c->dibBmp) { DeleteObject(c->dibBmp); c->dibBmp = NULL; }
+ c->dibDC = CreateCompatibleDC(screenDC);
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi.bmiHeader.biWidth = c->capW;
+ bi.bmiHeader.biHeight = -c->capH; // 负值 = 自上而下
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+ c->dibBmp = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &c->dibBits, NULL, 0);
+ if (!c->dibDC || !c->dibBmp) {
+ // 创建失败必须复位全部 DIB 状态:旧位图已在上方删除,dibBits 若仍指向
+ // 已释放内存、宽高若保留旧值,调用方据此判重入会误用脏数据参与匹配。
+ if (c->dibDC) { DeleteDC(c->dibDC); c->dibDC = NULL; }
+ if (c->dibBmp) { DeleteObject(c->dibBmp); c->dibBmp = NULL; }
+ c->dibBits = nullptr;
+ c->dibW = 0; c->dibH = 0;
+ return false;
+ }
+ c->dibW = c->capW; c->dibH = c->capH;
+ SelectObject(c->dibDC, c->dibBmp);
+ return true;
+}
+
+bool LongCaptureCaptureFrameBuf(LongCaptureContext* c, std::vector& out) {
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return false;
+ if (!LongCaptureEnsureDib(c, screenDC)) { ReleaseDC(NULL, screenDC); return false; }
+ // DPI 感知下屏幕 DC 坐标为物理像素:源偏移 = 虚拟屏幕物理原点 + 选区相对偏移。
+ // 不带 CAPTUREBLT:该标志强制 DWM 合成所有分层窗口(含本功能自身的全屏蒙版),
+ // 单帧耗时可达数十毫秒且伴随光标闪烁;选区取景内容为普通窗口桌面,无需该标志。
+ // BitBlt 失败(UAC 安全桌面/独占全屏瞬断)时 dibBits 残留的是上一帧像素,
+ // 绝不能当作本帧返回——否则新旧混合帧会静默错拼。直接失败,由调用方按
+ // 瞬态故障重试(首帧路径则上报失败)。
+ if (!BitBlt(c->dibDC, 0, 0, c->capW, c->capH, screenDC,
+ c->physOriginX + c->physX, c->physOriginY + c->physY, SRCCOPY)) {
+ ReleaseDC(NULL, screenDC);
+ return false;
+ }
+ ReleaseDC(NULL, screenDC);
+
+ size_t n = (size_t)c->capW * (size_t)c->capH;
+ out.resize(n);
+ // 横向模式:帧缓冲转置(capW×capH → capH×capW = physW×physH),水平滚动位移
+ // 由此映射为垂直位移,匹配/拼接/缩略图整条管线与纵向完全同构,仅最终输出回转。
+ // T(行 rhorizontal) {
+ memcpy(out.data(), c->dibBits, n * 4);
+ } else {
+ const uint32_t* src = (const uint32_t*)c->dibBits;
+ for (int r = 0; r < c->capW; r++)
+ for (int q = 0; q < c->capH; q++)
+ out[(size_t)r * c->capH + q] = src[(size_t)q * c->capW + r];
+ }
+ return true;
+}
+
+// 将一列物理像素行缩为一行(列方向整数面积平均,AVG 通道忽略),供面板缩略图增量维护。
+
+void LongCaptureDownscaleRow(const uint32_t* src, uint32_t* dst, int srcW, int dstW) {
+ if (srcW <= 0 || dstW <= 0) return;
+ if (srcW == dstW) { memcpy(dst, src, (size_t)dstW * 4); return; }
+ for (int c = 0; c < dstW; c++) {
+ int s0 = (int)((long long)c * srcW / dstW);
+ int s1 = (int)((long long)(c + 1) * srcW / dstW);
+ if (s1 <= s0) s1 = s0 + 1;
+ if (s1 > srcW) s1 = srcW;
+ unsigned r = 0, g = 0, b = 0;
+ for (int s = s0; s < s1; s++) {
+ uint32_t px = src[s];
+ b += px & 0xFF;
+ g += (px >> 8) & 0xFF;
+ r += (px >> 16) & 0xFF;
+ }
+ int n = s1 - s0;
+ dst[c] = 0xFF000000u | ((r / n) << 16) | ((g / n) << 8) | (b / n);
+ }
+}
+
+// 重建面板绘制用合并缩略图(reverse(thumbHeadRev) + thumbBody),内容变更时调用。
+// 横向模式额外生成回转后的显示缓冲(thumbMerged 为转置空间的纵向图,直接展示会被
+// 旋转 90°;thumbDisplay 转置回原方向供面板绘制)。
+
+void LongCaptureRebuildThumb(LongCaptureContext* c) {
+ if (!c->thumbDirty || c->thumbW <= 0) return;
+ c->thumbMerged.resize((size_t)c->thumbW * (size_t)c->thumbH);
+ const size_t rowW = (size_t)c->thumbW;
+ uint32_t* dst = c->thumbMerged.data() + (size_t)c->thumbHeadH * rowW;
+ if (!c->thumbBody.empty())
+ memcpy(dst, c->thumbBody.data(), c->thumbBody.size() * 4);
+ dst = c->thumbMerged.data();
+ for (int r = c->thumbHeadH - 1; r >= 0; r--)
+ memcpy(dst + (size_t)(c->thumbHeadH - 1 - r) * rowW,
+ c->thumbHeadRev.data() + (size_t)r * rowW, rowW * 4);
+ c->thumbDirty = false;
+ if (c->horizontal) c->thumbDisplayDirty = true;
+}
+
+// 横向模式:把转置空间的合并缩略图回转为原方向显示缓冲(宽高互换的纯转置)。
+
+void LongCaptureRebuildThumbDisplay(LongCaptureContext* c) {
+ if (!c->horizontal) { c->thumbDisplayDirty = false; return; }
+ int w = c->thumbW, h = c->thumbH;
+ if (w <= 0 || h <= 0) { c->thumbDisplayDirty = false; return; }
+ c->thumbDisplay.resize((size_t)w * (size_t)h);
+ for (int y = 0; y < w; y++)
+ for (int x = 0; x < h; x++)
+ c->thumbDisplay[(size_t)y * h + x] = c->thumbMerged[(size_t)x * w + y];
+ c->thumbDisplayW = h;
+ c->thumbDisplayH = w;
+ c->thumbDisplayDirty = false;
+}
+
+// 当前输出行窗口(拼接图行坐标;未裁剪 = [0, stitchH))。裁剪只约束预览/尺寸标签/输出,
+// 绝不修改拼接缓冲与匹配基准,已捕获内容始终保留在缓冲内(待剔除区间见
+// LongCaptureExecuteCropPurge)。裁剪锚点以内容坐标存储:拼接图行 = 内容坐标 + headRows,
+// 窗口随前插自动平移、且未设置边界的一侧恒开放——继续滚动新增的拼接行始终落在
+// 输出窗口内(「裁剪后继续滚动 = 以当前位置为基准继续拼图」的显示侧保证)。
+
+void LongCaptureOutputRows(const LongCaptureContext* c, int& outTop, int& outBottom) {
+ outTop = 0;
+ outBottom = c->stitchH;
+ if (!c->cropped) return;
+ // 哨兵值显式短路:INT64_MIN/MAX 直接参与 +headRows 加法是有符号溢出(UB);
+ // 开放侧保持哨兵、由区间钳制自然落到界外,与「该侧未设边界」语义一致。
+ int64_t t = c->cropTopY == INT64_MIN ? INT64_MIN : c->cropTopY + c->headRows;
+ if (t > 0 && t < c->stitchH) outTop = (int)t;
+ int64_t b = c->cropBottomY == INT64_MAX ? INT64_MAX : c->cropBottomY + c->headRows;
+ if (b > outTop && b < c->stitchH) outBottom = (int)b;
+}
+
+// 写出 c->outWidth/outHeight,返回位图(调用方负责 DeleteObject)。
+// 由拼接段(reverse(headRev) + body)构造结果位图:先应用裁剪行窗口,横向模式再
+// 转置回原方向(拼接空间恒为「宽度=physW、高度=行数」的纵向图)。
+
+HBITMAP LongCaptureBuildResultBitmap(LongCaptureContext* c) {
+ if (c->stitchH <= 0 || c->physW <= 0) return NULL;
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ if (rows <= 0) return NULL;
+ // 段合并仅发生在结束时这一次:头部倒序段 + 主体段
+ const size_t rowW = (size_t)c->physW;
+ std::vector merged(rowW * (size_t)rows);
+ {
+ // headRev/body 在 [0, headRows) / [headRows, stitchH) 两个行区间内取行
+ auto copyRows = [&](int64_t from, int64_t to, int64_t dstOff) {
+ for (int64_t r = from; r < to; r++) {
+ const uint32_t* srcRow = r < c->headRows
+ ? c->headRev.data() + (size_t)(c->headRows - 1 - r) * rowW
+ : c->body.data() + (size_t)(r - c->headRows) * rowW;
+ memcpy(merged.data() + (size_t)(dstOff + r - rowStart) * rowW, srcRow, rowW * 4);
+ }
+ };
+ copyRows(rowStart, rowEnd, 0);
+ }
+
+ // 横向模式:转置回原方向(拼接空间 physW×rows → 显示空间 rows×physW):
+ // F(行 fy finalBuf;
+ int outW = 0, outH = 0;
+ if (c->horizontal) {
+ outW = rows;
+ outH = c->physW;
+ finalBuf.resize((size_t)outW * (size_t)outH);
+ for (int fy = 0; fy < outH; fy++)
+ for (int fx = 0; fx < outW; fx++)
+ finalBuf[(size_t)fy * outW + fx] = merged[(size_t)fx * c->physW + fy];
+ } else {
+ outW = c->physW;
+ outH = rows;
+ finalBuf.swap(merged);
+ }
+
+ HDC screenDC = GetDC(NULL);
+ HDC outDC = CreateCompatibleDC(screenDC);
+ HBITMAP outBmp = CreateCompatibleBitmap(screenDC, outW, outH);
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi.bmiHeader.biWidth = outW;
+ bi.bmiHeader.biHeight = -outH; // 负值 = 自上而下
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+ bi.bmiHeader.biSizeImage = (DWORD)((SIZE_T)outW * (SIZE_T)outH * 4);
+ SetDIBits(outDC, outBmp, 0, outH, finalBuf.data(), &bi, DIB_RGB_COLORS);
+ DeleteDC(outDC);
+ ReleaseDC(NULL, screenDC);
+ c->outWidth = outW;
+ c->outHeight = outH;
+ return outBmp;
+}
+
+// 泵送线程消息:滚轮钩子回调、预览面板绘制与按钮点击均依赖本线程消息循环。
+
+void LongCapturePumpMessages(LongCaptureContext* c) {
+ MSG msg;
+ while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
+ if (msg.message == WM_QUIT) { c->abortFlag.store(true); return; }
+ TranslateMessage(&msg);
+ DispatchMessage(&msg);
+ }
+}
+
+// 以给定帧初始化第 0 帧基准(纯数据初始化,不抓屏):frame 即首屏内容
+//(既作主体段也作重叠检测基准),调用后 frame 缓冲被交换复用。
+// 由 LongCaptureInitFirstFrame(真实抓屏链路)与单元测试(合成帧注入)共用。
+
+void LongCaptureInitBaseline(LongCaptureContext* c, std::vector& frame) {
+ c->lastFrame.swap(frame); // lastFrame = 首帧;frame 复用旧缓冲
+ c->body = c->lastFrame;
+ c->bodyRows = c->physH;
+ c->stitchH = c->physH;
+ LongCaptureBuildMatchData(c->lastFrame, c->physW, c->physH, c->lastMatch);
+ // 跟踪/历史基准初始化:首帧即已提交基准(内容坐标 0),tentative 与 committed 对齐;
+ // 首帧同时作为第 0 条历史条目(已提交、位置精确),供失败后的多跳回溯起步。
+ c->committedContentTop = 0;
+ c->tentativeContentTop = 0;
+ c->tentativeValid = true;
+ c->tentativeConfidence = 1.0f;
+ c->trackUnreliableStreak = 0;
+ c->lastCommittedFrameId = 0;
+ c->frameHistory.clear();
+ c->weakCandidateOffsets.clear();
+ LongCaptureHistoryPush(c, 0, LongMatchData(c->lastMatch), 0, true);
+ if (c->thumbW > 0) {
+ c->thumbBody.resize((size_t)c->physH * c->thumbW);
+ uint32_t* dst = c->thumbBody.data();
+ for (int r = 0; r < c->physH; r++) {
+ LongCaptureDownscaleRow(c->lastFrame.data() + (size_t)r * c->physW,
+ dst, c->physW, c->thumbW);
+ dst += c->thumbW;
+ }
+ c->thumbH = c->physH;
+ c->thumbDirty = true;
+ }
+}
+
+// 抓取首帧并初始化基准。返回 false 表示首帧抓取失败。
+
+bool LongCaptureInitFirstFrame(LongCaptureContext* c, std::vector& frameBuf) {
+ if (!LongCaptureCaptureFrameBuf(c, frameBuf)) return false;
+ LongCaptureInitBaseline(c, frameBuf);
+ return true;
+}
+
+// 构造最终输出位图:拼接合并(应用裁剪行窗口 + 横向回转)后按 DPI 缩放回逻辑尺寸,
+// 写出 outWidth/outHeight(逻辑尺寸,与回调量纲一致)。失败返回 NULL。
+
+HBITMAP LongCaptureBuildFinalBitmap(LongCaptureContext* c) {
+ HBITMAP stitched = LongCaptureBuildResultBitmap(c);
+ if (!stitched) return NULL;
+ double ds = c->dpiScale;
+ if (ds > 1.01 || ds < 0.99) {
+ int lw = (int)(c->outWidth / ds + 0.5);
+ int lh = (int)(c->outHeight / ds + 0.5);
+ if (lw >= 1 && lh >= 1) {
+ HDC screenDC = GetDC(NULL);
+ HDC srcDC = CreateCompatibleDC(screenDC);
+ HDC dstDC = CreateCompatibleDC(screenDC);
+ HBITMAP scaledBmp = CreateCompatibleBitmap(screenDC, lw, lh);
+ HGDIOBJ oldSrc = SelectObject(srcDC, stitched);
+ HGDIOBJ oldDst = SelectObject(dstDC, scaledBmp);
+ SetHalftoneStretchMode(dstDC);
+ StretchBlt(dstDC, 0, 0, lw, lh, srcDC, 0, 0, c->outWidth, c->outHeight, SRCCOPY);
+ SelectObject(srcDC, oldSrc);
+ SelectObject(dstDC, oldDst);
+ DeleteDC(srcDC);
+ DeleteDC(dstDC);
+ ReleaseDC(NULL, screenDC);
+ DeleteObject(stitched);
+ c->outWidth = lw;
+ c->outHeight = lh;
+ return scaledBmp;
+ }
+ }
+ return stitched;
+}
+
diff --git a/src/screenshot/lc_match_core.cpp b/src/screenshot/lc_match_core.cpp
new file mode 100644
index 0000000..72a569c
--- /dev/null
+++ b/src/screenshot/lc_match_core.cpp
@@ -0,0 +1,1401 @@
+// 长截图子系统:纯匹配算法(帧间位移搜索、识别、富验证)。
+// CR-021 拆分自 long_capture_windows.cpp 的「帧间全局位移搜索 / 识别阶段」段。
+// 本文件为纯函数:输入 LongMatchData 输出 LongMatchOutcome,不修改任何拼接状态,
+// 天然可单测。识别相关调参常量在此集中定义(extern,供 lc_stitch_state 等块共享)。
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// 可条件编译的调试日志(构建加 /DLC_DEBUG_LOG 启用,经 OutputDebugStringA 输出到调试器):
+// 输出采样管线的关键决策标签与全部评分明细(模式/offset/overlap/各 ROI 候选与权重/
+// overall/seam/top-middle-bottom/连续段/profile/edge/纹理占比/峰值分离度/综合置信度/
+// pending 状态/失败原因分类/稳定性闸门/动态屏蔽行数/basin 择优),供真实数据驱动的
+// 阈值调优;默认编译为零开销空函数。
+#ifdef LC_DEBUG_LOG
+#include
+#include
+static void LC_LOG(const char* fmt, ...) {
+ char buf[512];
+ va_list args;
+ va_start(args, fmt);
+ vsnprintf(buf, sizeof(buf), fmt, args);
+ va_end(args);
+ OutputDebugStringA(buf);
+ OutputDebugStringA("\n");
+}
+#else
+static inline void LC_LOG(const char*, ...) {}
+#endif
+
+// 失败分类名(LCFailReason → 日志字符串,仅可观测性用)。
+const char* LcFailReasonName(LCFailReason r) {
+ switch (r) {
+ case LCFailReason::NoCandidate: return "NO_CANDIDATE";
+ case LCFailReason::CandidateWeak: return "CANDIDATE_WEAK";
+ case LCFailReason::PeakAmbiguous: return "PEAK_AMBIGUOUS";
+ case LCFailReason::GlobalMismatch: return "GLOBAL_MISMATCH";
+ case LCFailReason::SeamMismatch: return "SEAM_MISMATCH";
+ case LCFailReason::SpatialMismatch: return "SPATIAL_MISMATCH";
+ case LCFailReason::ContinuityMismatch: return "CONTINUITY_MISMATCH";
+ case LCFailReason::ProfileMismatch: return "PROFILE_MISMATCH";
+ case LCFailReason::RoiInconsistent: return "ROI_INCONSISTENT";
+ case LCFailReason::OffsetImplausible: return "OFFSET_IMPLAUSIBLE";
+ case LCFailReason::DirectionConflict: return "DIRECTION_CONFLICT";
+ case LCFailReason::FrameUnstable: return "FRAME_UNSTABLE";
+ default: return "NONE";
+ }
+}
+
+
+// 每行参与匹配的采样列上限(列方向稀疏采样;垂直对齐需行级精度,行方向保持 1:1)。
+
+// 全位移搜索选优」,实现位于本文件靠前的采样/拼接段。
+
+// 变化等真实噪声下几乎永远失败,导致整帧重复拼接;改为「量化灰度 + 容差行匹配 +
+
+// 精确逐像素比对在亚像素滚动(平滑滚动/分数偏移的抗锯齿文字)、吸顶元素、滚动条
+
+// —— 帧间模糊对齐参数(参考市面长截图工具的容差匹配方案)——
+
+const int LONG_MATCH_MAX_COLS = 400;
+
+// 4bit 量化灰度的单列容忍差:|Δ| 超过该值计为差异列(吸收文字边缘抗锯齿的轻微偏移)。
+
+const int LONG_MATCH_TOL = 2;
+
+// 扫描阶段每个位移均匀采样的探针行数(只求筛出候选峰,精度由全量验证保证)。
+
+// —— 全位移搜索调参(对齐主流程见 LongCaptureDetectMatch / LongCaptureBestShiftInRange)——
+
+const int LC_SCAN_PROBES = 16;
+
+// 扫探单位移的有效权重下限:探针几乎全落空白行时该位移不可评(-1 分)。
+
+const int LC_SCAN_MIN_WEIGHT = 6;
+
+// 局部峰值合并窗口:±N 行内的得分类似属同一次对齐(亚像素抖动),只保留峰值。
+
+const int LC_PEAK_WIN = 3;
+
+// 进入全量验证的候选峰数量上限(按扫描分降序截断)。
+
+const int LC_MAX_CANDIDATES = 8;
+
+// 最小可信重叠行数(绝对下限):与 LC_MIN_OVERLAP_RATIO 取较大者约束位移搜索范围,
+// 重叠过小(跳变/懒加载)没有对齐依据——宁可判失败重试,也不允许“整帧兜底”式拼接。
+
+const int LC_MIN_OVERLAP = 24;
+
+// —— 鲁棒性约束(防单帧误识别污染累计拼接状态;全部为可调常量,勿在逻辑中散落魔法数)——
+
+// 重叠至少占视口高的比例:位移 |d| 超过 h×(1-该值) 的候选直接不在搜索范围内。
+// 旧实现仅要求 24 行重叠(2000px 视口下允许 |d|≈1976),是极端错误 offset 的主要来源。
+
+const float LC_MIN_OVERLAP_RATIO = 0.15f;
+
+// 跨 ROI 位移一致性容差(px):左/中/右三个列 ROI 各自独立求得的位移,
+// 组内差异 ≤ 该值视为同一次对齐(例:812/814/810 可信;812/811/3260 中 3260 为离群被否决)。
+
+const int LC_ROI_OFFSET_TOLERANCE = 4;
+
+// 综合置信度下限(overall/seam/ROI 一致性加权,见 LongCaptureDetectMatch):低于该值禁止拼接。
+
+const float LC_MIN_CONFIDENCE = 0.60f;
+
+// |d| 超过历史成功位移中位数该倍数视为异常跳变,直接拒绝且不更新历史。
+
+const float LC_OFFSET_JUMP_RATIO = 4.0f;
+
+// offset 历史窗口长度(合理性校验的中位数统计样本数;滚动节奏渐变时窗口滚动自适应)。
+
+const int LC_OFFSET_HISTORY_LEN = 5;
+
+// 历史样本达到该数量后才启用跳变比率校验(样本过少时中位数不稳,过早启用会误杀正常滚动)。
+
+const int LC_OFFSET_HISTORY_MIN = 3;
+
+// 单次采样的抓取/匹配尝试总数(首次 + 重试):滚动未生效/页面未渲染完成时等待后
+// 重试,而非带着错误拼接。加大尝试数以提高「等页面渲染稳定」窗口内的判定频次,
+// 提升重叠区域判定的成功率。
+
+const int LC_SAMPLE_ATTEMPTS = 6;
+
+// 采样重试间隔(ms):按结局分档递增——等待 scroll 动画/懒加载/DOM 重绘稳定,
+// 而非一次性永久放慢整体采样节奏。Normal 失败 5 档(数组长度 = 尝试数 - 1,首次
+// 尝试不占档位);Weak 候选复核 6 档(延迟确认需要跨多次采样观察页面稳定)。
+
+const int LC_RETRY_DELAY_NORMAL[LC_SAMPLE_ATTEMPTS - 1] = { 90, 120, 150, 180, 210 };
+
+const int LC_RETRY_DELAY_WEAK[6] = { 100, 130, 160, 180, 200, 220 };
+
+// —— 帧稳定性检测(抓帧准入闸门:减少「抓到过渡帧导致识别失败」)——
+// 连续两次抓帧(间隔数十至数百毫秒)的 4bit 灰度稀疏采样对比:有效权重行失配占比
+// 仍高 = 页面处于滚动动画/重绘/懒加载/布局过渡中,本帧不适合作为匹配输入——短延迟
+// 后重新采样,而不是带着过渡帧进 DetectMatch 白白烧掉重试预算。只判断「现在适不
+// 适合匹配」:不参与 offset 评分、不修改任何累计状态、不降低任何识别门槛;每采样
+// 轮等待预算耗尽后强制放行(快速连续滚动仍按既有中滚采样节拍匹配,机制不受阻塞)。
+
+const DWORD LC_STABLE_REF_MAX_GAP = 600; // 参考帧最大有效间隔(ms):更早的参考
+ // 属于上一个滚动阶段,不具可比性(直接放行)
+
+const float LC_STABLE_CHANGED_ROW_FRAC = 0.30f; // 有效权重行失配占比 ≥ 该值判不稳定
+
+const int LC_STABLE_MIN_WEIGHT = 40; // 稳定性判定的最低有效权重(更低无判据)
+
+const int LC_STABLE_MAX_WAITS = 3; // 每采样轮稳定性等待预算(此后强制进入匹配)
+
+const int LC_STABLE_RETRY_DELAY[LC_STABLE_MAX_WAITS] = { 30, 45, 60 }; // ms
+
+// Normal 失败后的短时重采样(「等待页面稳定」而非放宽条件):无候选/全宽不可评这类
+// 瞬态失败先走几十毫秒级快重采样——刚好抓在滚动/重绘过渡帧的情形在下一拍往往自愈。
+// 不提交、不修改正式累计状态、不降低任何阈值,预算独立于 LC_SAMPLE_ATTEMPTS 主重试梯。
+
+const int LC_QUICK_RESAMPLES = 2; // 瞬态失败快重采样次数上限
+
+const int LC_RESAMPLE_DELAY_QUICK[LC_QUICK_RESAMPLES] = { 40, 60 }; // ms
+
+// 滚动中主动采样最大间隔(ms):连续滚动尚未停稳时,距上次采样超过该节拍即不等
+// 停稳主动抓帧拼接——相邻帧位移因此足够小、overlap 稳定落在 Normal 匹配档,重叠
+// 判定首选大 overlap 的 Normal 路径,只有极端大跳变才轮到 Weak 档兜底。快速滚动
+// (一次滚过大半屏)下更短的节拍直接抬高相邻帧重叠量,是比事后恢复更前置的防线;
+// 该值在「重叠更大」与「采样/匹配开销更高」之间折中。用户配置的 interval 比该值
+// 更小时尊重用户值(滚动中节拍取两者较小者)。
+
+const int LC_SCROLL_SAMPLE_MAX_GAP = 150;
+
+// 连续「内容未变化」采样数达到该值确认滚动到底(与匹配失败完全独立的事件)。
+
+const int LC_BOTTOM_CONFIRM_SAMPLES = 3;
+
+// —— Weak / 大跳变匹配(低重叠双模式)——
+// Normal 档允许 overlap >= max(LC_MIN_OVERLAP, h×LC_MIN_OVERLAP_RATIO);
+// Weak 档把可识别范围放宽到 overlap >= max(LC_WEAK_MIN_OVERLAP, h×LC_WEAK_MIN_OVERLAP_RATIO),
+// 且只扫描 (weak 下限, normal 下限) 之间的纯弱重叠区间(两档不重叠,模式由重叠量唯一决定)。
+// 原则:overlap 越小,要求的证据越强——绝不简单降低门槛放行,更不回到“失败整帧兜底”的老路。
+
+const int LC_WEAK_MIN_OVERLAP = 32; // 弱重叠绝对下限(px)
+
+const float LC_WEAK_MIN_OVERLAP_RATIO = 0.05f; // 弱重叠占视口高下限(5%)
+
+// Weak 档跨 ROI 一致性:必须 3/3 ROI 全部产出候选且极差(max−min)不超过该值。
+// 任一 ROI 离群或缺失即整帧拒绝——低重叠下单 ROI 或多数派证据都不可信。
+
+const int LC_WEAK_ROI_TOLERANCE = 3;
+
+// Weak 档验收阈值(全部高于 Normal 严格档):
+
+const float LC_WEAK_ACCEPT_OVERALL = 0.65f;
+
+const float LC_WEAK_ACCEPT_SEAM = 0.75f;
+
+const float LC_WEAK_ACCEPT_EDGE = 0.80f; // 行边缘结构相关度下限
+
+const float LC_WEAK_MIN_CONFIDENCE = 0.72f; // 动态置信度门槛基线
+
+const float LC_WEAK_CONF_EXTRA_PENALTY = 0.08f; // overlap 从 15%h 降到 5%h 的置信度加罚上限
+
+const float LC_WEAK_OVERLAP_RATIO_LIMIT = 0.90f; // Weak offset 合理性放宽上限(×视口高)
+
+const int LC_WEAK_CONFIRM_OFFSET_TOL = 2; // 延迟确认:两次候选位移差上限(px)
+
+const int LC_WEAK_RETRY_ATTEMPTS = 6; // 单轮采样内 Weak 候选的重试次数(= LC_RETRY_DELAY_WEAK 档数)
+
+const int LC_WEAK_MAX_TRIES = 5; // Weak 候选独立采样轮数(耗尽即放弃候选链,从干净基准重新观察)
+
+// 滚轮 delta 软先验:候选 |d| 落在期望值 ±max(8, 期望/4) 内时给调整分加该值。
+// 仅影响同档候选的排序先后,绝不改变任何验收阈值或搜索范围。
+
+const float LC_WHEEL_PRIOR_BONUS = 0.04f;
+
+// —— Tentative 视觉跟踪 / 多跳恢复 / Weak 时间一致性(外围状态层,不改任何拼接验收门槛)——
+// 目标:某一帧 overlap 判定失败不再切断整个滚动跟踪链——小地图在"预测位置"层面持续
+// 反馈,而正式拼接始终保持严格保守(只有 SUCCESS 才能提交)。本组常量全部只作用于
+// 跟踪/历史/时间证据层,绝不参与 DetectPass 的空间验收阈值。
+
+const int LC_HISTORY_FRAMES = 8; // 最近帧环形历史容量(规格建议 4~8;快速滚动
+ // 失败帧多,适当加深历史给多跳恢复更多候选基准)
+
+const int LC_HISTORY_HOPS = 4; // 失败后最多回溯的历史基准数(最多 4 跳)
+
+const int LC_TRACK_FREEZE_FRAMES = 3; // 连续无视觉依据采样数达该值即冻结 tentative
+
+const int LC_TRACK_MIN_STEP = 2; // 小于该位移(px)不推进 tentative(噪声)
+
+const float LC_TRACK_PREDICT_CONFIDENCE = 0.35f; // 纯预测估计的初始置信度(低)
+
+const float LC_TRACK_CONFIDENCE_DECAY = 0.7f; // 预测链逐帧置信度衰减系数
+
+const int LC_WEAK_TEMPORAL_TOL = 3; // 弱候选与共识簇中位数的容差(px)
+
+const int LC_WEAK_TEMPORAL_MIN_SAMPLES = 2; // 形成时间共识所需的历史样本数
+
+const int LC_WEAK_TEMPORAL_MAX_HISTORY = 8; // 弱候选样本环形容量
+
+const float LC_WEAK_TEMPORAL_BONUS_MAX = 0.06f; // 时间共识给弱档置信度的加分手
+
+// 瑕疵最明显,验收时单独要求高匹配率。
+
+// 接缝窗行数(随重叠长度折半封顶):新增块直接续接在接缝窗之后,此处错位的视觉
+
+const int LC_SEAM_ROWS = 64;
+
+// 全量验证的有效权重下限(重叠区几乎全空白时无法评判对齐质量)。
+
+const int LC_VERIFY_MIN_WEIGHT = 20;
+
+// 重叠区有效纹理行占比(weight>0 行 / 重叠行数)低于该值时,开始按缺失比例轻度
+// 折扣综合置信度(见 DetectPass ⑥):大面积纯色重叠在错误位移处也可能偶然拿到
+// 高 overall——「匹配成功」与「匹配可信」必须区分,证据量本身要计价。
+
+const float LC_TEXTURE_CONF_FLOOR = 0.12f;
+
+const float LC_TEXTURE_CONF_SCALE = 0.5f; // 折扣强度:texture=0 时最多约 −0.06
+
+// 验收阈值:严格档覆盖常规页面;宽松档候选不再直接放行拼接,只作为 LOW_CONFIDENCE
+// 的判定依据(交由重试救援动画/高噪声页面),两档都不达标即 FAILED。
+
+const float LC_ACCEPT_STRICT_OVERALL = 0.55f;
+
+const float LC_ACCEPT_STRICT_SEAM = 0.65f;
+
+const float LC_ACCEPT_LOOSE_OVERALL = 0.42f;
+
+const float LC_ACCEPT_LOOSE_SEAM = 0.50f;
+
+// 与最近滚动方向相反的候选罚分(防误配插到错误一端;同向候选无罚分)。
+
+const float LC_DIR_PENALTY = 0.08f;
+
+// 不同峰之间调整分差距小于该值视为歧义(典型于周期性列表),偏向重叠更大(|d| 更小)的一峰。
+
+const float LC_AMBIGUITY_MARGIN = 0.05f;
+
+// —— ROI 证据融合与全宽空间一致性(「局部巧合 ≠ 真实重叠」防御的核心参数)——
+
+// 每个 ROI 提交给跨 ROI 聚类的候选数(Top-N 证据;ROI 不再单票定生死)。
+
+const int LC_BAND_TOP_N = 3;
+
+// 进入全宽富验证的聚类数上限(按 ROI 加权融合分降序截断)。
+
+const int LC_MAX_VERIFY_CANDIDATES = 5;
+
+// 低信息量 ROI 的权重下限:空白/弱纹理 ROI 仍有最低投票权(左右留白的单栏文本页),
+// 但绝不再与结构丰富的 ROI 等权(权重 = 下限 + (1-下限) × 带内细节占比)。
+
+const float LC_ROI_MIN_INFO_WEIGHT = 0.25f;
+
+// —— ROI 列带自适应放置(宽留白选区/空白页的匹配抗性)——
+// 三列 ROI 不再固定按采样列数三分,而是按「列垂直结构量」把内容等分三段(每段
+// 都携带约 1/3 内容量,见 LongCaptureBuildMatchData):固定三分在选区左右大量留白
+// 时会让边缘 ROI 整带空白(有效 ROI 只剩 1 个,Weak 档 3/3 全票永远不可达)。
+// 该值为自适应列带的最小宽度:行匹配容差为 (c1-c0)/20,带宽低于它容差归零;
+// 采样列总数不足 3×该值时退回固定三分(与旧行为一致)。
+
+const int LC_BAND_MIN_COLS = 24;
+
+// 候选综合分(不含分离度项)差达到该值视为峰值充分分离(无周期性歧义)。
+
+const float LC_PEAK_SEP_FULL = 0.15f;
+
+// 第一峰与次峰分差小于歧义裕度(周期性/重复内容)时的额外置信度加罚。
+
+const float LC_AMBIGUITY_CONF_PENALTY = 0.12f;
+
+// 匹配分布连续性的断点罚参数:gap 数达到 6 即罚满(0.15 × min(1, gaps/6))。
+
+const float LC_CONTINUITY_GAP_MAX = 6.0f;
+
+// —— 动态变化区域局部降权(候选已定后的行级异常带识别,见 LongCaptureVerifyCandidate)——
+// lazy-load 图片/视频帧/倒计时/sticky/局部重排会让部分行在相邻帧间真实变化,但这不
+// 代表整体 offset 错误。富验证时识别「局部、连续、空间有限」的失配带并将其从统计中
+// 剔除(与空白行同等对待:不计比率、不打断连续段),避免少量动态内容拖垮 overall/
+// continuity。硬约束(防「挽救错误 offset」):
+// · 单段超过 LC_DYNAMIC_MAX_ROWS、或候选屏蔽总量超过有效行占比上限 → 完全不屏蔽,
+// 大面积异常是真实失配证据,绝不能被忽略;
+// · 剔除只收回负面影响,绝不产生正向证据;下游阈值(LC_VERIFY_MIN_WEIGHT 与全部
+// 验收门槛)对剔除后的证据量原样生效——证据不足的候选不可能借屏蔽通过验收。
+
+const int LC_DYNAMIC_MIN_ROWS = 3; // 短于该行数的失配属散点噪声,正常统计
+
+const int LC_DYNAMIC_MAX_ROWS = 48; // 单段可屏蔽最大行数(更大 = 真实失配)
+
+const int LC_DYNAMIC_BRIDGE = 2; // 段内允许的空白桥接行数(动态区含留白)
+
+const float LC_DYNAMIC_MAX_FRACTION = 0.10f; // 可屏蔽有效行占比硬上限
+
+// 最终候选的局部 offset basin 合并半径(±px):胜出聚类中位数附近的 1~2px 峰漂移
+// (亚像素滚动/抗锯齿/量化,如 d=417/418/419)属同一 basin,在 ±半径内做全宽富验证
+// 择最优证据者作为最终位置。只做局部择优:绝不合并相距较远的周期性峰,也不改变
+// 任何验收阈值(邻域点越出本档重叠区间即跳过,不越档)。
+
+const int LC_BASIN_RADIUS = 2;
+
+// Weak 档新增证据下限(全部为「低重叠要求更强证据」的加严项,Normal 档无对应硬门槛,
+// 仅通过综合置信度起作用):空间一致性 / 匹配连续性 / 多尺度结构 / 峰值分离度。
+
+const float LC_WEAK_ACCEPT_SPATIAL = 0.70f;
+
+const float LC_WEAK_ACCEPT_CONTINUITY = 0.60f;
+
+const float LC_WEAK_ACCEPT_PROFILE = 0.50f;
+
+const float LC_WEAK_MIN_PEAK_SEP = 0.10f;
+
+// ==================== 长截图:帧间全局位移搜索(重叠检测) ====================
+
+bool LongCaptureRowMatchesRange(const uint8_t* a, const uint8_t* b, int c0, int c1) {
+ int bad = 0, limit = (c1 - c0) / 20;
+ for (int i = c0; i < c1; i++) {
+ int d = (int)a[i] - (int)b[i];
+ if (d > LONG_MATCH_TOL || -d > LONG_MATCH_TOL) {
+ if (++bad > limit) return false;
+ }
+ }
+ return true;
+}
+
+// 全宽行匹配(列区间 [0, cols) 的便捷包装)。
+
+bool LongCaptureRowMatches(const uint8_t* a, const uint8_t* b, int cols) {
+ return LongCaptureRowMatchesRange(a, b, 0, cols);
+}
+
+// 与全帧细节总量一并产出。
+
+// 行权重(= min(4, 行内相邻采样列灰度跳变数),空白行为 0、强纹理封顶防单行主导)
+
+// 构建匹配数据:BGRA → 整数灰度 (29B+150G+76R)>>8 → 4bit 量化;列采样、
+
+void LongCaptureBuildMatchData(const std::vector& frame, int w, int h,
+ LongMatchData& m) {
+ int left = (std::max)(8, w / 50); // 左侧剔除 ~2%(边缘抗锯齿)
+ int right = w - (std::max)(24, w / 20); // 右侧剔除 ~5%(覆盖滚动条宽度)
+ if (right - left < 8) { left = 0; right = w; } // 过窄选区兜底:全宽参与
+ int span = right - left;
+ int step = (std::max)(1, (span + LONG_MATCH_MAX_COLS - 1) / LONG_MATCH_MAX_COLS);
+ m.cols = (span + step - 1) / step;
+ m.h = h;
+ m.gray.assign((size_t)h * (size_t)m.cols, 0);
+ m.weight.assign((size_t)h, 0);
+ m.edge.assign((size_t)h, 0);
+ m.detailSum = 0;
+ // 三列 ROI(左/中/右)边界在灰度数据构建完成后按内容自适应放置(见下方
+ // 「列垂直结构量等分」段),不再固定按列数三分。列区间来自已剔除选区描边/
+ // 抗锯齿/滚动条的采样范围,选区 Overlay 与固定 UI 不会进入任何 ROI。
+ for (int r = 0; r < h; r++) {
+ const uint32_t* src = frame.data() + (size_t)r * w + left;
+ uint8_t* dst = m.gray.data() + (size_t)r * m.cols;
+ int trans = 0, edgeSum = 0;
+ for (int k = 0, cx = 0; k < m.cols; k++, cx += step) {
+ uint32_t px = src[cx]; // 内存序 BGRA
+ int g = ((int)(px & 0xFF) * 29 + (int)((px >> 8) & 0xFF) * 150
+ + (int)((px >> 16) & 0xFF) * 76) >> 8;
+ g >>= 4;
+ if (k > 0) {
+ int dG = g - (int)dst[k - 1];
+ if (dG < 0) dG = -dG;
+ if (dG > 0) trans++;
+ edgeSum += dG; // 行结构强度:相邻采样列量化灰度差绝对值之和
+ }
+ dst[k] = (uint8_t)g;
+ }
+ m.weight[r] = (uint8_t)((std::min)(trans, 4));
+ m.edge[r] = (uint16_t)edgeSum; // 上限 cols(≤400)×15,uint16 足够
+ m.detailSum += trans;
+ }
+
+ // —— 三列 ROI 自适应放置(宽留白选区/空白页匹配抗性的核心)——
+ // 按列垂直结构量(该列相邻行量化灰度跳变总数:纯色留白列为 0、文字/图片列很高)
+ // 把全宽内容等分为三段——每段都携带约 1/3 的内容量。固定列数三分在「选区左右
+ // 大量留白」时会让边缘 ROI 整带空白(有效 ROI 只剩 1 个,Weak 档 3/3 全票永远
+ // 不可达),内容等分后三个 ROI 各自落在有效内容上,「多个有效局部区域分别
+ // 匹配 + 跨 ROI 聚类取中位数」的既有机制(BestShiftInRange → DetectPass ③)
+ // 才真正成立。带宽钳制保证任一带 ≥ LC_BAND_MIN_COLS(行匹配容差 (c1-c0)/20
+ // 不归零);全帧无结构或采样列过少时退回固定三分(与旧行为一致)。
+ bool bandsByContent = false;
+ if (m.cols >= LC_BAND_MIN_COLS * LC_ROI_BANDS) {
+ std::vector colDetail((size_t)m.cols, 0);
+ for (int r = 1; r < h; r++) {
+ const uint8_t* row = m.gray.data() + (size_t)r * m.cols;
+ const uint8_t* prev = row - m.cols;
+ for (int k = 0; k < m.cols; k++)
+ if (row[k] != prev[k]) colDetail[k]++;
+ }
+ int total = 0;
+ for (int k = 0; k < m.cols; k++) total += colDetail[k];
+ if (total > 0) {
+ std::vector prefix((size_t)m.cols + 1, 0);
+ for (int k = 0; k < m.cols; k++)
+ prefix[(size_t)k + 1] = prefix[k] + colDetail[k];
+ int b1 = 0, b2 = 0;
+ while (b1 < m.cols && prefix[b1] * 3 < total) b1++; // 首个内容量 ≥ 1/3 的边界
+ while (b2 < m.cols && prefix[b2] * 3 < total * 2) b2++; // 首个内容量 ≥ 2/3 的边界
+ int lo1 = LC_BAND_MIN_COLS, hi1 = m.cols - 2 * LC_BAND_MIN_COLS;
+ if (b1 < lo1) b1 = lo1; else if (b1 > hi1) b1 = hi1; // 最小带宽钳制
+ int lo2 = b1 + LC_BAND_MIN_COLS, hi2 = m.cols - LC_BAND_MIN_COLS;
+ if (b2 < lo2) b2 = lo2; else if (b2 > hi2) b2 = hi2;
+ m.bandStart[0] = 0; m.bandCols[0] = b1;
+ m.bandStart[1] = b1; m.bandCols[1] = b2 - b1;
+ m.bandStart[2] = b2; m.bandCols[2] = m.cols - b2;
+ bandsByContent = true;
+ }
+ }
+ if (!bandsByContent) { // 固定三分兜底:全帧无结构 / 采样列过少
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ m.bandStart[b] = m.cols * b / LC_ROI_BANDS;
+ m.bandCols[b] = m.cols * (b + 1) / LC_ROI_BANDS - m.bandStart[b];
+ }
+ }
+ // 带内行权重:该带列区间内相邻采样列的灰度跳变数(空白行为 0),
+ // 供各 ROI 独立扫描/验证(LongCaptureBestShiftInRange)使用。
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ m.bandWeight[b].assign((size_t)h, 0);
+ int bs = m.bandStart[b], be = bs + m.bandCols[b];
+ for (int r = 0; r < h; r++) {
+ const uint8_t* row = m.gray.data() + (size_t)r * m.cols;
+ int bt = 0;
+ for (int k = bs + 1; k < be; k++)
+ if (row[k] != row[k - 1]) bt++;
+ m.bandWeight[b][r] = (uint8_t)((std::min)(bt, 4));
+ }
+ }
+
+ // 多尺度垂直结构 profile:每行按采样列均分为 LC_PROFILE_BUCKETS 桶取平均量化灰度
+ //(行桶均值对光标/局部动画等水平噪声鲁棒),再对每个桶做 4 行 / 8 行滑动窗口平均。
+ // 逐行灰度匹配可被「局部巧合、大面积纯色」偶然通过,而聚合 profile 刻画整段的
+ // 明暗起伏——真对齐处两个尺度的相关度都应高,用于区分「局部像素相似」与
+ // 「整段垂直结构一致」(仅作辅助证据,见 LongCaptureProfileCorrelation)。
+ // 帧尾不足一个窗口时自动截短(两帧同式处理,真对齐处两侧窗口仍逐桶相等)。
+ m.profile4.assign((size_t)h * LC_PROFILE_BUCKETS, 0.0f);
+ m.profile8.assign((size_t)h * LC_PROFILE_BUCKETS, 0.0f);
+ {
+ std::vector prefix((size_t)(h + 1) * LC_PROFILE_BUCKETS, 0.0);
+ for (int r = 0; r < h; r++) {
+ const uint8_t* row = m.gray.data() + (size_t)r * m.cols;
+ for (int b = 0; b < LC_PROFILE_BUCKETS; b++) {
+ int bc0 = m.cols * b / LC_PROFILE_BUCKETS;
+ int bc1 = m.cols * (b + 1) / LC_PROFILE_BUCKETS;
+ double s = 0;
+ for (int k = bc0; k < bc1; k++) s += row[k];
+ prefix[(size_t)(r + 1) * LC_PROFILE_BUCKETS + b] =
+ prefix[(size_t)r * LC_PROFILE_BUCKETS + b] + s / (std::max)(1, bc1 - bc0);
+ }
+ }
+ for (int r = 0; r < h; r++) {
+ for (int s = 0; s < 2; s++) {
+ int win = s == 0 ? 4 : 8;
+ std::vector& prof = s == 0 ? m.profile4 : m.profile8;
+ int r1 = (std::min)(h, r + win);
+ for (int b = 0; b < LC_PROFILE_BUCKETS; b++)
+ prof[(size_t)r * LC_PROFILE_BUCKETS + b] =
+ (float)((prefix[(size_t)r1 * LC_PROFILE_BUCKETS + b]
+ - prefix[(size_t)r * LC_PROFILE_BUCKETS + b])
+ / (std::max)(1, r1 - r));
+ }
+ }
+ }
+}
+
+// 位移记 -1 分(大片空白无法评判)。out 返回候选位移列表(含符号)。
+
+// (空白行权重 0 不计分;失配权重过半即早退),有效权重不足 LC_SCAN_MIN_WEIGHT 的
+
+// 单位移评分:重叠区均匀取 LC_SCAN_PROBES 个探针行按 curr 行权重累积匹配/失配权重
+
+// 相邻位移属同一次对齐的亚像素抖动),按分数降序保留至多 LC_MAX_CANDIDATES 个。
+
+// 全位移扫描:对每个可行位移打快速分,收集局部峰值(±LC_PEAK_WIN 邻域最大者,
+
+// 全位移扫描(限定列区间 [c0,c1) 与行权重 weight):对每个可行位移打快速分,
+// 收集局部峰值(±LC_PEAK_WIN 邻域最大者,相邻位移属同一次对齐的亚像素抖动),
+// 按分数降序保留至多 LC_MAX_CANDIDATES 个。out 返回候选位移列表(含符号)。
+// 探针行只落在重叠区内的有效行(weight>0)上——空白行无论布到多少针都不产生
+// 证据,优先把针位花在有纹理的行上(见函数内「有效行索引」注释)。
+//
+// 可行位移范围由重叠区间 [ovMin, ovMax] 参数化(双模式识别的档位边界):
+// · Normal 档:ovMin = max(LC_MIN_OVERLAP, h×15%)、ovMax = h(含 d=0),行为与原实现一致;
+// · Weak 档:只扫 (max(32, h×5%), Normal 下限) 的纯弱重叠区间,与 Normal 档不重叠。
+// 先粗后精的两段式(探针粗筛→候选全量验证)只在给定范围内进行,不做全图无界搜索;
+// 过小重叠(跳变/懒加载)没有对齐依据,直接产不出候选(判定失败走重试)。
+// 向下滚时重叠关系即「上一帧底部 ↔ 当前帧顶部」(curr[c] ↔ prev[d+c]),
+// 向上滚对称,天然聚焦在上下帧重叠区域而非盲搜。
+
+static void LongCaptureScanCandidatesRange(const LongMatchData& prevM, const LongMatchData& currM,
+ int c0, int c1, const uint8_t* weight,
+ int ovMin, int ovMax, std::vector& out) {
+ out.clear();
+ int h = currM.h;
+ if (ovMin < 2 || ovMax < ovMin || prevM.cols != currM.cols || prevM.h != h) return;
+ int cols = currM.cols;
+ int lo = -(h - ovMin), hi = h - ovMin;
+ if (lo > hi) return;
+ // 有效行索引(weight>0,升序):探针只在重叠区内的有效行上均匀取样。原实现
+ // 按重叠区均匀布针,重叠区大部分为空白(宽留白选区/页面留白段)时探针大量
+ // 落在空白行上,goodW+badW 凑不满 LC_SCAN_MIN_WEIGHT → 所有位移不可评 →
+ // 整帧无候选。内容导向布针后空白占比不再影响「可评性」,只影响证据量(有效
+ // 行更少 → 权重更低,下游 LC_VERIFY_MIN_WEIGHT 与验收门槛自然兜底)。
+ std::vector informative;
+ for (int r = 0; r < h; r++)
+ if (weight[r] > 0) informative.push_back(r);
+ std::vector score((size_t)hi - lo + 1, -1.0f);
+ for (int d = lo; d <= hi; d++) {
+ int ad = d > 0 ? d : -d;
+ int o = h - ad;
+ if (o > ovMax) continue; // 超出本档重叠上限(Weak 档排除 Normal 区间)
+ int cStart = d < 0 ? ad : 0; // 重叠区在 curr 内的行区间 [cStart, cEnd)
+ int cEnd = cStart + o;
+ int goodW = 0, badW = 0;
+ if (!informative.empty()) {
+ int f = (int)(std::lower_bound(informative.begin(), informative.end(), cStart)
+ - informative.begin());
+ int l = (int)(std::lower_bound(informative.begin(), informative.end(), cEnd)
+ - informative.begin());
+ int m = l - f; // 重叠区内有效行数
+ int nProbes = m < LC_SCAN_PROBES ? m : LC_SCAN_PROBES;
+ for (int i = 0; i < nProbes; i++) {
+ int c = informative[f + (int)((long long)m * (i * 2 + 1) / (nProbes * 2))];
+ int wt = weight[c];
+ if (LongCaptureRowMatchesRange(
+ prevM.gray.data() + (size_t)(d + c) * cols,
+ currM.gray.data() + (size_t)c * cols, c0, c1))
+ goodW += wt;
+ else
+ badW += wt;
+ if (badW >= 4 && badW * 2 > goodW + badW) break;
+ }
+ }
+ if (goodW + badW >= LC_SCAN_MIN_WEIGHT)
+ score[d - lo] = (float)goodW / (float)(goodW + badW);
+ }
+ for (int d = lo; d <= hi; d++) {
+ float s = score[d - lo];
+ if (s <= 0) continue;
+ bool peak = true;
+ for (int k = -LC_PEAK_WIN; k <= LC_PEAK_WIN && peak; k++) {
+ int dd = d + k;
+ if (dd == d || dd < lo || dd > hi) continue;
+ if (score[dd - lo] > s) peak = false;
+ }
+ if (peak) out.push_back(d);
+ }
+ if (out.size() > 1) {
+ std::stable_sort(out.begin(), out.end(), [&](int a, int b) {
+ return score[a - lo] > score[b - lo];
+ });
+ if (out.size() > (size_t)LC_MAX_CANDIDATES) out.resize((size_t)LC_MAX_CANDIDATES);
+ }
+}
+
+// 返回 false = 有效权重不足(重叠区几乎全空白),对齐不可信。
+
+// 重新暴露的内容失配集中在另一端,不影响 seam 判定。
+
+// 向下滚接缝在重叠区尾端、向上滚在头端;吸顶/吸底栏两帧同位必匹配,被其遮挡后
+
+// 新增块直接续接在接缝窗之后,此处错位的视觉瑕疵最明显,需单独达标;
+
+// seam :接缝窗(靠近新增块一端 LC_SEAM_ROWS 行,随重叠折半封顶)匹配权重占比——
+
+// overall:全区匹配权重占比——衡量对齐的整体可信度;
+
+// 候选位移全量验证:遍历整个重叠区逐行加权匹配。
+
+static bool LongCaptureVerifyShiftRange(const LongMatchData& prevM, const LongMatchData& currM,
+ int c0, int c1, const uint8_t* weight,
+ int d, float& overall, float& seam) {
+ int ad = d > 0 ? d : -d;
+ int cols = currM.cols;
+ int o = currM.h - ad;
+ int cStart = d < 0 ? ad : 0;
+ int win = (std::min)(LC_SEAM_ROWS, o / 2);
+ int seamLo = d > 0 ? cStart + o - win : cStart; // 接缝窗在 curr 内的行区间 [seamLo, seamHi)
+ int seamHi = seamLo + win;
+ int goodAll = 0, wAll = 0, goodSeam = 0, wSeam = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ int wt = weight[c];
+ if (wt <= 0) continue;
+ bool ok = LongCaptureRowMatchesRange(
+ prevM.gray.data() + (size_t)(d + c) * cols,
+ currM.gray.data() + (size_t)c * cols, c0, c1);
+ goodAll += ok ? wt : 0;
+ wAll += wt;
+ if (c >= seamLo && c < seamHi) {
+ goodSeam += ok ? wt : 0;
+ wSeam += wt;
+ }
+ }
+ if (wAll < LC_VERIFY_MIN_WEIGHT) return false;
+ overall = (float)goodAll / (float)wAll;
+ // 接缝窗内无有效行(连接处为纯色/空白)时不再一票否决:空白接缝在视觉上
+ // 不可能错位,行稀疏内容页的接缝窗常常全空白(旧实现在此整帾拒绝);
+ // seam 指标退化为继承整段 overall 作为最可得的质量代理,下游 seam 阈值对
+ // 「有证据的接缝」约束力不变。
+ seam = wSeam > 0 ? (float)goodSeam / (float)wSeam : overall;
+ return true;
+}
+
+// 峰邻 ±1 精搜(粗搜→精搜的收尾一级):扫描探针分数只负责筛峰,亚像素滚动/
+// 探针行稀疏时峰值本身可能偏 1px。对峰 d 及 d±1 各做一次整段验证(同
+// LongCaptureVerifyShiftRange 的 overall/seam 双指标),取 overall 最优(并列时
+// seam 更高)者;邻域点越出本档重叠区间 [ovMin, ovMax] 时跳过(不越档)。
+// 返回 false = 邻域内无可评位移。bestD/overall/seam 输出精搜后的最优位移与指标。
+static bool LongCaptureVerifyShiftRefined(const LongMatchData& prevM, const LongMatchData& currM,
+ int c0, int c1, const uint8_t* weight,
+ int d, int ovMin, int ovMax,
+ int& bestD, float& overall, float& seam) {
+ bestD = d;
+ bool any = false;
+ float bestOverall = 0.0f, bestSeam = 0.0f;
+ for (int dd = d - 1; dd <= d + 1; dd++) {
+ int ad = dd > 0 ? dd : -dd;
+ int o = currM.h - ad;
+ if (o < ovMin || o > ovMax) continue;
+ float ov = 0.0f, sm = 0.0f;
+ if (!LongCaptureVerifyShiftRange(prevM, currM, c0, c1, weight, dd, ov, sm)) continue;
+ if (!any || ov > bestOverall + 0.001f
+ || (ov > bestOverall - 0.001f && sm > bestSeam)) {
+ any = true;
+ bestD = dd;
+ bestOverall = ov;
+ bestSeam = sm;
+ }
+ }
+ if (!any) return false;
+ overall = bestOverall;
+ seam = bestSeam;
+ return true;
+}
+
+// ==================== 识别阶段(纯函数:不修改任何拼接状态) ====================
+
+// 轻量位移先验(明确不引入 ECC / dense optical flow / phase correlation 等重型配准):
+// 候选幅度综合两个低成本来源——
+// ① 滚轮累计增量 × 在线 px/notch 估计(估计可用且有待消化增量时);
+// ② 最近成功视觉位移中位数(估计尚不可用时的退路——滚动节奏通常稳定)。
+// 只作为同档候选的排序加分(LC_WHEEL_PRIOR_BONUS)与失败帧的预测幅度来源,
+// 绝不变质为 expectedOffset±fixedRange 硬约束——Windows/浏览器/页面平滑滚动/触控板
+
+// 重叠区行边缘结构强度的归一化相关度(加权 Pearson,权重 = curr 行权重,空白行不计):
+// Weak Match 的辅助强证据——量化灰度逐行匹配可被大面积纯色区“偶然通过”,
+// 而行结构强度曲线在真对齐处应逐行同起伏。有效权重不足或方差为零(曲线平坦)
+// 时无可判依据,返回 0(无法作为强证据)。
+static float LongCaptureEdgeCorrelation(const LongMatchData& prevM, const LongMatchData& currM,
+ const uint8_t* weight, int d) {
+ int h = currM.h;
+ int ad = d > 0 ? d : -d;
+ int o = h - ad;
+ int cStart = d < 0 ? ad : 0;
+ double wSum = 0, sa = 0, sb = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ double w = weight[c];
+ if (w <= 0) continue;
+ wSum += w;
+ sa += w * prevM.edge[(size_t)(d + c)];
+ sb += w * currM.edge[(size_t)c];
+ }
+ if (wSum < LC_VERIFY_MIN_WEIGHT) return 0.0f;
+ double ma = sa / wSum, mb = sb / wSum;
+ double cov = 0, va = 0, vb = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ double w = weight[c];
+ if (w <= 0) continue;
+ double da = (double)prevM.edge[(size_t)(d + c)] - ma;
+ double db = (double)currM.edge[(size_t)c] - mb;
+ cov += w * da * db;
+ va += w * da * da;
+ vb += w * db * db;
+ }
+ if (va <= 0.0 || vb <= 0.0) return 0.0f;
+ float corr = (float)(cov / std::sqrt(va * vb));
+ if (corr > 1.0f) corr = 1.0f;
+ if (corr < -1.0f) corr = -1.0f;
+ return corr;
+}
+
+// 单尺度聚合 profile 的加权 Pearson 相关度(权重 = curr 行权重,空白行不计):
+// 把重叠区每个有效行的 LC_PROFILE_BUCKETS 个桶值视作联合序列,衡量「整段垂直
+// 明暗结构」在候选位移处是否同起伏。有效样本不足或方差为零(结构平坦)时
+// 无可判依据,返回 0;负相关按零证据处理(结构反向不可能来自同一内容段)。
+static float LongCaptureProfileCorrOne(const LongMatchData& prevM, const LongMatchData& currM,
+ const uint8_t* weight, int d,
+ const std::vector& profPrev,
+ const std::vector& profCurr, int win) {
+ int h = currM.h;
+ int ad = d > 0 ? d : -d;
+ int o = h - ad;
+ if (o <= win || profPrev.empty() || profCurr.empty()) return 0.0f;
+ int cStart = d < 0 ? ad : 0;
+ int maxIdx = (std::max)(0, h - win); // 帧尾不足一个聚合窗口的行共用末窗口
+ double wSum = 0, sa = 0, sb = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ double w = weight[c];
+ if (w <= 0) continue;
+ int pa = (std::min)((std::max)(d + c, 0), maxIdx);
+ int pb = (std::min)(c, maxIdx);
+ for (int b = 0; b < LC_PROFILE_BUCKETS; b++) {
+ wSum += w;
+ sa += w * profPrev[(size_t)pa * LC_PROFILE_BUCKETS + b];
+ sb += w * profCurr[(size_t)pb * LC_PROFILE_BUCKETS + b];
+ }
+ }
+ if (wSum < (double)LC_VERIFY_MIN_WEIGHT * LC_PROFILE_BUCKETS) return 0.0f;
+ double ma = sa / wSum, mb = sb / wSum;
+ double cov = 0, va = 0, vb = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ double w = weight[c];
+ if (w <= 0) continue;
+ int pa = (std::min)((std::max)(d + c, 0), maxIdx);
+ int pb = (std::min)(c, maxIdx);
+ for (int b = 0; b < LC_PROFILE_BUCKETS; b++) {
+ double da = (double)profPrev[(size_t)pa * LC_PROFILE_BUCKETS + b] - ma;
+ double db = (double)profCurr[(size_t)pb * LC_PROFILE_BUCKETS + b] - mb;
+ cov += w * da * db;
+ va += w * da * da;
+ vb += w * db * db;
+ }
+ }
+ if (va <= 0.0 || vb <= 0.0) return 0.0f;
+ float corr = (float)(cov / std::sqrt(va * vb));
+ if (corr > 1.0f) corr = 1.0f;
+ if (corr < 0.0f) corr = 0.0f;
+ return corr;
+}
+
+// 多尺度垂直结构一致度 = 4 行与 8 行聚合 profile 相关度的均值(辅助证据,不替换
+// 灰度匹配):局部像素巧合在粗尺度上通常失去结构对应,真对齐则两个尺度都一致。
+static float LongCaptureProfileCorrelation(const LongMatchData& prevM, const LongMatchData& currM,
+ const uint8_t* weight, int d) {
+ float c4 = LongCaptureProfileCorrOne(prevM, currM, weight, d,
+ prevM.profile4, currM.profile4, 4);
+ float c8 = LongCaptureProfileCorrOne(prevM, currM, weight, d,
+ prevM.profile8, currM.profile8, 8);
+ return 0.5f * (c4 + c8);
+}
+
+// 候选位移的全宽富验证(识别阶段最终裁决的证据来源):遍历整个重叠区逐行加权匹配,
+// 一次扫描同时产出 overall/seam/三段分布/连续段统计。空白行(权重 0)不计入任何
+// 比率、也不打断连续段——它既不能证明也不能证伪对齐。动态变化区行(局部连续失配
+// 带,识别规则见常量块)与空白行同等对待:不参与比率、不打断连续段;屏蔽受单段
+// 行数与总量占比双重硬上限约束,大面积异常绝不忽略,剔除也绝不产生正向证据。
+// 返回 false = 有效权重不足(重叠区几乎全空白),该候选不可评。
+bool LongCaptureVerifyCandidate(const LongMatchData& prevM, const LongMatchData& currM,
+ int d, LCOverlapEvidence& ev) {
+ int h = currM.h;
+ int cols = currM.cols;
+ int ad = d > 0 ? d : -d;
+ int o = h - ad;
+ if (o < 2 || prevM.cols != cols || prevM.h != h) return false;
+ int cStart = d < 0 ? ad : 0;
+ const uint8_t* weight = currM.weight.data();
+ int win = (std::min)(LC_SEAM_ROWS, o / 2);
+ int seamLo = d > 0 ? cStart + o - win : cStart; // 接缝窗在 curr 内的行区间
+ // 第一遍:逐行匹配,行状态落盘(0=空白无证据 1=有效失配 2=有效匹配;3=动态区
+ // 屏蔽由第二遍回填)。单字节 × 帧高的行结果缓冲,成本可忽略。
+ std::vector state((size_t)h, 0);
+ int informative = 0;
+ for (int c = cStart; c < cStart + o; c++) {
+ if (weight[c] <= 0) continue; // 空白行:无证据
+ bool ok = LongCaptureRowMatchesRange(
+ prevM.gray.data() + (size_t)(d + c) * cols,
+ currM.gray.data() + (size_t)c * cols, 0, cols);
+ state[c] = ok ? 2 : 1;
+ informative++;
+ }
+ // 第二遍:动态变化区识别(局部连续失配带,允许少量空白桥接——动态区内常夹留白)。
+ // 候选屏蔽段必须同时满足:段内失配行数 ∈ [LC_DYNAMIC_MIN_ROWS, LC_DYNAMIC_MAX_ROWS],
+ // 且全部候选段失配行总数 ≤ 有效行数 × LC_DYNAMIC_MAX_FRACTION——任一不满足即完全
+ // 不屏蔽(宁可保守计入失配,也不给错误 offset 借局部屏蔽通过验收的空间)。
+ int maskedRows = 0;
+ {
+ int maskBudget = (int)((float)informative * LC_DYNAMIC_MAX_FRACTION);
+ struct LCMismatchRun { int lo, hi, len; }; // [lo,hi] 行闭区间;len = 段内失配行数
+ std::vector runs;
+ int i = cStart;
+ while (i < cStart + o) {
+ if (state[i] != 1) { i++; continue; }
+ int j = i, len = 1, k = i + 1;
+ while (k < cStart + o) {
+ if (state[k] == 1) { // 连续失配:段延伸
+ j = k; len++; k++; continue;
+ }
+ if (state[k] == 0) { // 空白桥接:段内小片留白不断开动态区
+ int b = k;
+ while (b < cStart + o && state[b] == 0) b++;
+ if (b < cStart + o && state[b] == 1 && b - k <= LC_DYNAMIC_BRIDGE) {
+ j = b; k = b + 1;
+ continue;
+ }
+ }
+ break; // 匹配行 / 超长空白:段终止
+ }
+ if (len >= LC_DYNAMIC_MIN_ROWS && len <= LC_DYNAMIC_MAX_ROWS)
+ runs.push_back({i, j, len});
+ i = j + 1;
+ }
+ int total = 0;
+ for (const LCMismatchRun& r : runs) total += r.len;
+ if (total > 0 && total <= maskBudget) {
+ for (const LCMismatchRun& r : runs)
+ for (int c = r.lo; c <= r.hi; c++)
+ if (state[c] == 1) { state[c] = 3; maskedRows++; }
+ }
+ }
+ // 第三遍:聚合统计(空白行与动态屏蔽行同为「无证据」:不参与比率、不打断连续段)。
+ long long goodAll = 0, wAll = 0, goodSeam = 0, wSeam = 0;
+ long long partGood[3] = {0, 0, 0}, partW[3] = {0, 0, 0};
+ int used = 0, run = 0, bestRun = 0, gaps = 0;
+ bool lastOk = false;
+ for (int c = cStart; c < cStart + o; c++) {
+ int wt = weight[c];
+ if (wt <= 0 || state[c] == 3) continue;
+ bool ok = state[c] == 2;
+ used++;
+ if (ok) {
+ run++;
+ if (run > bestRun) bestRun = run;
+ } else {
+ run = 0;
+ if (lastOk) gaps++; // 匹配带被切断:连续匹配分布出现断点
+ }
+ lastOk = ok;
+ goodAll += ok ? wt : 0;
+ wAll += wt;
+ if (c >= seamLo && c < seamLo + win) {
+ goodSeam += ok ? wt : 0;
+ wSeam += wt;
+ }
+ int third = (c - cStart) * 3 / (std::max)(1, o);
+ if (third > 2) third = 2;
+ partGood[third] += ok ? wt : 0;
+ partW[third] += wt;
+ }
+ if (wAll < LC_VERIFY_MIN_WEIGHT) return false;
+ ev.valid = true;
+ ev.textureRatio = (float)used / (float)o; // 评分基准(剔除动态行后)的有效行占比
+ ev.dynamicMaskedRows = maskedRows;
+ ev.overall = (float)((double)goodAll / (double)wAll);
+ // 同 LongCaptureVerifyShiftRange:接缝窗全空白(无有效行)时 seam 继承 overall,
+ // 不再一票否决——空白连接处无错位可言,行稀疏内容页不应因此整帾被拒。
+ ev.seam = wSeam > 0 ? (float)((double)goodSeam / (double)wSeam) : ev.overall;
+ int have = 0;
+ float minv = 1.0f, sum = 0.0f;
+ for (int i = 0; i < 3; i++) {
+ if (partW[i] > 0) {
+ ev.part[i] = (float)((double)partGood[i] / (double)partW[i]);
+ if (ev.part[i] < minv) minv = ev.part[i];
+ sum += ev.part[i];
+ have++;
+ } else {
+ ev.part[i] = -1.0f; // 该段几乎全空白:无证据(中性,不参与统计)
+ }
+ }
+ // 空间一致性:多个位置都有稳定证据才得高分(min 与均值折中,弱段拖累总分);
+ // 只有一段有证据时对折(覆盖面不足),top=.92 middle=.31 bottom=.26 型的
+ // 局部假匹配在这里崩塌,而 .88/.91/.87 型的强候选不受影响。
+ ev.spatial = have >= 2 ? 0.5f * (minv + sum / (float)have)
+ : (have == 1 ? 0.5f * sum : 0.0f);
+ ev.longestRunRatio = used > 0 ? (float)bestRun / (float)used : 0.0f;
+ ev.gapCount = gaps;
+ float gapNorm = (std::min)(1.0f, (float)gaps / LC_CONTINUITY_GAP_MAX);
+ ev.continuity = 0.6f * ev.longestRunRatio + 0.4f * ev.overall - 0.15f * gapNorm;
+ if (ev.continuity < 0.0f) ev.continuity = 0.0f;
+ if (ev.continuity > 1.0f) ev.continuity = 1.0f;
+ ev.profileScore = LongCaptureProfileCorrelation(prevM, currM, weight, d);
+ ev.edgeScore = LongCaptureEdgeCorrelation(prevM, currM, weight, d);
+ return true;
+}
+
+// Weak 档动态置信度门槛:overlap 越小要求越强——基线 LC_WEAK_MIN_CONFIDENCE,
+// overlap 从 Normal 下限降至 Weak 下限线性加罚至 LC_WEAK_CONF_EXTRA_PENALTY。
+// 视口过小导致区间退化时取最大加罚(最保守)。
+float LongCaptureWeakRequiredConfidence(int viewportH, int overlap) {
+ float lo = (float)((std::max)(LC_WEAK_MIN_OVERLAP, (int)(viewportH * LC_WEAK_MIN_OVERLAP_RATIO)));
+ float hi = (float)((std::max)(LC_MIN_OVERLAP, (int)(viewportH * LC_MIN_OVERLAP_RATIO)));
+ if (hi <= lo) return LC_WEAK_MIN_CONFIDENCE + LC_WEAK_CONF_EXTRA_PENALTY;
+ float t = ((float)overlap - lo) / (hi - lo);
+ if (t < 0.0f) t = 0.0f;
+ if (t > 1.0f) t = 1.0f;
+ return LC_WEAK_MIN_CONFIDENCE + (1.0f - t) * LC_WEAK_CONF_EXTRA_PENALTY;
+}
+
+// 在限定列区间 [c0,c1) 与重叠区间 [ovMin,ovMax] 内求解两帧间的候选集(单 ROI
+// 证据入口):扫描收集候选峰(粗搜)→ 峰邻 ±1 整段验证取最优(精搜)→ 严格档
+// 优先、按 overall 降序保留至多 LC_BAND_TOP_N 个彼此距离 > LC_PEAK_WIN 的候选。
+// 方向罚/滚轮先验/歧义取舍统一移到最终选优(LongCaptureDetectPass):
+// · 单 ROI 的局部歧义(相似纹理/重复代码行/头像列等造成的假峰)没有资格在
+// 这里被否决或放大——它只是证据之一,交给跨 ROI 聚类与全宽空间一致性裁决;
+// · 周期性内容的多峰接近也不再在此预选,由峰值分离度在最终置信度中统一处理。
+static void LongCaptureBestShiftInRange(const LongMatchData& prevM, const LongMatchData& currM,
+ int c0, int c1, const uint8_t* weight,
+ int ovMin, int ovMax,
+ std::vector& out) {
+ out.clear();
+ std::vector cands;
+ LongCaptureScanCandidatesRange(prevM, currM, c0, c1, weight, ovMin, ovMax, cands);
+ std::vector tiers[2]; // [0]=严格档验收 [1]=宽松档验收
+ for (int d : cands) {
+ float overall = 0.0f, seam = 0.0f;
+ int dr = d;
+ if (!LongCaptureVerifyShiftRefined(prevM, currM, c0, c1, weight, d, ovMin, ovMax,
+ dr, overall, seam))
+ continue;
+ if (overall >= LC_ACCEPT_STRICT_OVERALL && seam >= LC_ACCEPT_STRICT_SEAM)
+ tiers[0].push_back({dr, overall, true});
+ else if (overall >= LC_ACCEPT_LOOSE_OVERALL && seam >= LC_ACCEPT_LOOSE_SEAM)
+ tiers[1].push_back({dr, overall, false});
+ }
+ for (int tier = 0; tier < 2 && (int)out.size() < LC_BAND_TOP_N; tier++) {
+ std::vector& list = tiers[tier];
+ std::stable_sort(list.begin(), list.end(),
+ [](const LCBandCandidate& a, const LCBandCandidate& b) {
+ return a.overall > b.overall;
+ });
+ for (const LCBandCandidate& c : list) {
+ if ((int)out.size() >= LC_BAND_TOP_N) break;
+ bool dup = false; // ±LC_PEAK_WIN 内视为同一次对齐的亚像素抖动
+ for (const LCBandCandidate& e : out) {
+ int diff = c.d > e.d ? c.d - e.d : e.d - c.d;
+ if (diff <= LC_PEAK_WIN) { dup = true; break; }
+ }
+ if (!dup) out.push_back(c);
+ }
+ }
+}
+
+// 单档识别(Normal 或 WeakOverlap),六段式流程。核心原则:
+// 局部区域匹配得很好 ≠ 重叠区域匹配正确。
+// ROI 只提供证据,最终结论由「全宽空间一致性优先」的综合置信度决定:
+// ① 各 ROI 产出 Top-N 候选(LongCaptureBestShiftInRange),不再单 ROI 定生死,
+// 也不再要求「多数派一致,否则直接 FAIL」;
+// ② ROI 信息量加权:结构丰富(带内有效行细节多)的 ROI 高权重,大量空白/
+// 低信息量的 ROI 只有保底权重(LC_ROI_MIN_INFO_WEIGHT),没有等权投票权;
+// ③ 候选按 offset 聚类(容差 = ROI 一致性容差,Weak 用更紧的全票容差),相近
+// offset 的 ROI 证据加权融合为「聚类支持度」;
+// ④ Top 聚类做全宽富验证(LongCaptureVerifyCandidate):overall/seam 之外,
+// top/middle/bottom 三段空间一致性 + 匹配分布连续性(最长连续段/断点数)+
+// 4/8 行聚合 profile 多尺度结构一致度 + 行边缘结构相关度全部进入评分;
+// ⑤ 最终置信度 = 全宽匹配 + 空间一致性 + 连续性 + 多尺度结构 + ROI 加权证据 +
+// 候选峰值分离度的加权组合 − 有效纹理占比折扣(大面积空白重叠证据量打折);
+// 第一/第二峰接近(周期性/重复内容歧义)时加罚,
+// 并在分差 < 歧义裕度的远距峰之间偏向重叠更大的候选;
+// ⑤+ 局部 offset basin 择优:胜出候选 ±LC_BASIN_RADIUS 内全宽富验证取最优证据,
+// 吸收亚像素滚动/抗锯齿/量化的 1~2px 峰漂移(只做局部择优,绝不合并远距峰,
+// 验收阈值不变——跨采样候选因此更稳定,Weak 延迟确认的复现一致性同步受益);
+// ⑥ Weak 档证据链全面收紧:3/3 ROI 全票聚类 + 更高 overall/seam/edge 阈值 +
+// spatial/continuity/profile 下限 + 峰值分离度下限 + 随 overlap 缩小动态
+// 升高的置信度门槛。
+// 任何一关不过都不是 SUCCESS——LOW_CONFIDENCE / FAILED 一律按 LCFailReason 细分类
+// 记录(NO_CANDIDATE / SEAM_MISMATCH / ...,见枚举定义)后走重试,
+// 绝不拿候选 offset 或兜底值拼接(更不允许回到「失败整帧兜底」的老路)。
+// offset==0 且 SUCCESS 表示内容实际未滚动(仅光标闪烁等微差 |d|≤1),无需追加。
+
+LongMatchOutcome LongCaptureDetectPass(const LongMatchData& prevM,
+ const LongMatchData& currM, int dir,
+ const LongCaptureOffsetPrior& prior, bool weak,
+ const LCWeakTemporal& wt, int logId) {
+ LongMatchOutcome m;
+ m.mode = weak ? LongCaptureMatchMode::WeakOverlap : LongCaptureMatchMode::Normal;
+ int h = currM.h;
+ if (h < 2 || prevM.cols != currM.cols || prevM.h != h) {
+ m.reason = LCFailReason::NoCandidate; // 帧尺寸不一致:无可对齐依据
+ return m;
+ }
+ if (currM.detailSum < 16) {
+ m.reason = LCFailReason::NoCandidate; // 整帧近乎均匀(空白):无对齐依据
+ return m;
+ }
+
+ // 本档重叠区间:overlap ∈ [ovMin, ovMax]。Normal 覆盖 [Normal 下限, h];
+ // Weak 只覆盖 [Weak 下限, Normal 下限)——两档不重叠,模式由重叠量唯一决定。
+ int ovMin = weak ? (std::max)(LC_WEAK_MIN_OVERLAP, (int)(h * LC_WEAK_MIN_OVERLAP_RATIO))
+ : (std::max)(LC_MIN_OVERLAP, (int)(h * LC_MIN_OVERLAP_RATIO));
+ int ovMax = weak ? (std::max)(LC_MIN_OVERLAP, (int)(h * LC_MIN_OVERLAP_RATIO)) - 1 : h;
+ if (ovMax < ovMin) { // 视口过小:弱重叠区间不存在(Weak 档无候选)
+ m.reason = LCFailReason::NoCandidate;
+ return m;
+ }
+
+ // ① 各 ROI 独立产出 Top-N 候选(证据收集,不做硬否决)
+ std::vector bandCands[LC_ROI_BANDS];
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ LongCaptureBestShiftInRange(prevM, currM,
+ currM.bandStart[b],
+ currM.bandStart[b] + currM.bandCols[b],
+ currM.bandWeight[b].data(), ovMin, ovMax, bandCands[b]);
+ if (!bandCands[b].empty()) {
+ m.bandValid[b] = true;
+ m.bandOffsets[b] = bandCands[b][0].d;
+ m.validBandCount++;
+ }
+ }
+ if (m.validBandCount == 0) { // 全部 ROI 无候选:FAILED
+ m.reason = LCFailReason::NoCandidate;
+ return m;
+ }
+
+ // ② ROI 信息量权重:带内有效行权重总和越高(结构越丰富、有效行越多)权重越大;
+ // 空白/重复纹理等低信息 ROI 只有保底权重——不再与高信息 ROI 等权投票。
+ float bandW[LC_ROI_BANDS];
+ {
+ double raw[LC_ROI_BANDS];
+ double maxRaw = 0;
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ double s = 0;
+ const uint8_t* bw = currM.bandWeight[b].data();
+ for (int r = 0; r < h; r++) s += bw[r];
+ raw[b] = s;
+ if (s > maxRaw) maxRaw = s;
+ }
+ for (int b = 0; b < LC_ROI_BANDS; b++)
+ bandW[b] = maxRaw > 0
+ ? LC_ROI_MIN_INFO_WEIGHT
+ + (1.0f - LC_ROI_MIN_INFO_WEIGHT) * (float)(raw[b] / maxRaw)
+ : 1.0f;
+ }
+#ifdef LC_DEBUG_LOG
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ char cb[128];
+ int off = 0;
+ for (size_t k = 0; k < bandCands[b].size() && off < (int)sizeof(cb) - 32; k++)
+ off += snprintf(cb + off, sizeof(cb) - off, " d=%d(%.2f%s)",
+ bandCands[b][k].d, bandCands[b][k].overall,
+ bandCands[b][k].strict ? ",s" : ",l");
+ LC_LOG("[LC#%d] %s band%d w=%.2f cands:%s", logId, weak ? "WEAK" : "NORM",
+ b, bandW[b], bandCands[b].empty() ? " none" : cb);
+ }
+#endif
+
+ // ③ 跨 ROI 候选聚类:相近 offset 的证据融合为一簇(亚像素抖动归并),
+ // 聚类位移取成员中位数(鲁棒统计,离群单票不牵动中心),
+ // 支持度 = Σ(ROI 权重 × 带内最高匹配率) / Σ权重。
+ struct ClusterEntry { int d; int band; float overall; bool strict; };
+ std::vector all;
+ for (int b = 0; b < LC_ROI_BANDS; b++)
+ for (const LCBandCandidate& c : bandCands[b])
+ all.push_back({c.d, b, c.overall, c.strict});
+ std::sort(all.begin(), all.end(),
+ [](const ClusterEntry& a, const ClusterEntry& b) { return a.d < b.d; });
+ struct LCCluster {
+ int offset = 0;
+ int support = 0; // 支持 ROI 数(有无候选,不含权重)
+ float roiWeighted = 0.0f; // ROI 加权证据 [0,1]
+ float supportScore[LC_ROI_BANDS] = {0, 0, 0};
+ bool strictMember = false; // 是否有成员达到严格档
+ };
+ int clusterTol = weak ? LC_WEAK_ROI_TOLERANCE : LC_ROI_OFFSET_TOLERANCE;
+ std::vector clusters;
+ for (size_t i = 0; i < all.size();) {
+ LCCluster cl;
+ std::vector ds;
+ size_t j = i;
+ while (j < all.size() && (ds.empty() || all[j].d - ds.back() <= clusterTol)) {
+ ds.push_back(all[j].d);
+ if (all[j].overall > cl.supportScore[all[j].band])
+ cl.supportScore[all[j].band] = all[j].overall;
+ if (all[j].strict) cl.strictMember = true;
+ j++;
+ }
+ std::sort(ds.begin(), ds.end());
+ cl.offset = ds[ds.size() / 2]; // 组内中位数(鲁棒统计)
+ float num = 0, den = 0;
+ for (int b = 0; b < LC_ROI_BANDS; b++) {
+ den += bandW[b];
+ if (cl.supportScore[b] > 0) {
+ num += bandW[b] * (std::min)(1.0f, cl.supportScore[b]);
+ cl.support++;
+ }
+ }
+ cl.roiWeighted = den > 0 ? num / den : 0;
+ clusters.push_back(cl);
+ i = j;
+ }
+ std::stable_sort(clusters.begin(), clusters.end(),
+ [](const LCCluster& a, const LCCluster& b) {
+ if (a.roiWeighted != b.roiWeighted) return a.roiWeighted > b.roiWeighted;
+ if (a.support != b.support) return a.support > b.support;
+ int aa = a.offset > 0 ? a.offset : -a.offset;
+ int ab = b.offset > 0 ? b.offset : -b.offset;
+ return aa < ab; // 融合分并列时偏向重叠更大(|d| 更小)
+ });
+
+ // ④ Top 聚类全宽富验证 + 综合分(不含分离度项)。全宽空间一致性是主要指标:
+ // 半幅各配不同内容、只有局部巧合匹配的候选,spatial/continuity/profile
+ // 会全面崩塌,即使 overall 因高分峰集中而不低也拿不到综合分。
+ // 排序分 = 综合分 + 滚轮先验加分 − 反向候选罚(只影响排序,不影响验收阈值)。
+ // Weak 档:仅全票(3/3 ROI)聚类可胜出,其余只作竞争峰基线参与分离度计算。
+ struct FinalCand {
+ int d = 0;
+ LCOverlapEvidence ev{};
+ float roiWeighted = 0;
+ int support = 0;
+ bool strictMember = false;
+ bool eligible = true;
+ float baseConf = 0;
+ float rank = 0;
+ };
+ std::vector finals;
+ std::vector attempted(clusters.size(), 0); // 已进入富验证的聚类(含验证失败者)
+ // 综合分公式(不含分离度项):候选富验证证据 + ROI 加权证据的加权组合,
+ // verifyAndPush 主路径与下方 basin 择优后的重算共用同一份权重。
+ auto baseConfOf = [weak](const LCOverlapEvidence& ev, float roiW) {
+ return weak
+ ? 0.22f * ev.overall + 0.11f * ev.seam + 0.15f * ev.spatial
+ + 0.13f * ev.continuity + 0.15f * ev.profileScore + 0.14f * roiW
+ : 0.28f * ev.overall + 0.14f * ev.seam + 0.16f * ev.spatial
+ + 0.12f * ev.continuity + 0.10f * ev.profileScore + 0.12f * roiW;
+ };
+ // 单聚类 → 富验证候选的统一入口(下方 Top-N 主路径与先验补位共用)。
+ auto verifyAndPush = [&](size_t ci) {
+ FinalCand f;
+ if (!LongCaptureVerifyCandidate(prevM, currM, clusters[ci].offset, f.ev)) return;
+ f.d = clusters[ci].offset;
+ f.roiWeighted = clusters[ci].roiWeighted;
+ f.support = clusters[ci].support;
+ f.strictMember = clusters[ci].strictMember;
+ f.eligible = !weak || f.support == LC_ROI_BANDS;
+ f.baseConf = baseConfOf(f.ev, f.roiWeighted);
+ f.rank = f.baseConf;
+ bool opposite = (dir != 0) && ((f.d > 0) == (dir < 0));
+ if (opposite) f.rank -= LC_DIR_PENALTY;
+ if (prior.valid) {
+ int ad = f.d > 0 ? f.d : -f.d;
+ int tol = (std::max)(8, prior.expectedAbsOffset / 4);
+ int ed = ad > prior.expectedAbsOffset ? ad - prior.expectedAbsOffset
+ : prior.expectedAbsOffset - ad;
+ if (ed <= tol) f.rank += LC_WHEEL_PRIOR_BONUS;
+ }
+ finals.push_back(f);
+ LC_LOG("[LC#%d] %s cand d=%d ov=%d overall=%.2f seam=%.2f tmb=%.2f/%.2f/%.2f "
+ "run=%.2f gaps=%d cont=%.2f prof=%.2f edge=%.2f tex=%.2f dyn=%d "
+ "roiW=%.2f sup=%d base=%.2f",
+ logId, weak ? "WEAK" : "NORM", f.d, h - (f.d > 0 ? f.d : -f.d),
+ f.ev.overall, f.ev.seam,
+ (std::max)(0.0f, f.ev.part[0]), (std::max)(0.0f, f.ev.part[1]),
+ (std::max)(0.0f, f.ev.part[2]),
+ f.ev.longestRunRatio, f.ev.gapCount, f.ev.continuity, f.ev.profileScore,
+ f.ev.edgeScore, f.ev.textureRatio, f.ev.dynamicMaskedRows,
+ f.roiWeighted, f.support, f.baseConf);
+ };
+ for (size_t ci = 0; ci < clusters.size() && (int)finals.size() < LC_MAX_VERIFY_CANDIDATES; ci++) {
+ attempted[ci] = 1;
+ verifyAndPush(ci);
+ }
+ // 位移预测指引的验证补位(快速滚动支持):重复内容页上真峰的 ROI 融合分可能
+ // 排在 Top-N 之外而从未进入富验证。把与预测位移(滚轮增量估计 / 历史成功位移
+ // 中位数,见 LongCaptureBuildOffsetPrior)最接近且尚未验证的最高分聚类补验
+ // 一个——只扩大验证覆盖(至多多验一个聚类),不放宽任何验收阈值;先验仍然
+ // 只是排序/覆盖提示,绝不是搜索范围硬约束。
+ if (prior.valid) {
+ int tol = (std::max)(8, prior.expectedAbsOffset / 4);
+ int pick = -1;
+ for (size_t ci = 0; ci < clusters.size(); ci++) {
+ if (attempted[ci]) continue;
+ int ad = clusters[ci].offset > 0 ? clusters[ci].offset : -clusters[ci].offset;
+ int ed = ad > prior.expectedAbsOffset ? ad - prior.expectedAbsOffset
+ : prior.expectedAbsOffset - ad;
+ if (ed <= tol && (pick < 0 || clusters[ci].roiWeighted > clusters[pick].roiWeighted))
+ pick = (int)ci;
+ }
+ if (pick >= 0) verifyAndPush((size_t)pick);
+ }
+ if (finals.empty()) { // 全宽不可评(重叠区几乎全空白):FAILED
+ m.reason = LCFailReason::GlobalMismatch;
+ return m;
+ }
+ bool anyEligible = false;
+ for (const FinalCand& f : finals)
+ if (f.eligible) { anyEligible = true; break; }
+ if (!anyEligible) { // Weak 全票不成立:直接拒绝(无多数派概念)
+ m.reason = LCFailReason::RoiInconsistent;
+ return m;
+ }
+
+ // ⑤ 最终选优:排序分最高者胜出;分差 < 歧义裕度的远距峰之间偏向重叠更大者
+ // (周期性列表多峰得分接近,取大重叠少漏拼——与全宽复核共同裁决)。
+ int best = -1;
+ for (int i = 0; i < (int)finals.size(); i++) {
+ if (!finals[i].eligible) continue;
+ if (best < 0 || finals[i].rank > finals[best].rank) best = i;
+ }
+ for (int i = 0; i < (int)finals.size(); i++) {
+ if (!finals[i].eligible || i == best) continue;
+ int diff = finals[i].d > finals[best].d ? finals[i].d - finals[best].d
+ : finals[best].d - finals[i].d;
+ int ai = finals[i].d < 0 ? -finals[i].d : finals[i].d;
+ int ab = finals[best].d < 0 ? -finals[best].d : finals[best].d;
+ if (diff > LC_PEAK_WIN && finals[best].rank - finals[i].rank < LC_AMBIGUITY_MARGIN
+ && ai < ab)
+ best = i; // 歧义:改选重叠更大的峰
+ }
+
+ // ⑤+ 局部 offset basin 确认:胜出聚类中位数 ±LC_BASIN_RADIUS 内逐一做全宽富验证,
+ // 取最优证据(overall 优先、seam 决胜)者作为最终位置——吸收亚像素滚动/抗锯齿/
+ // 量化造成的 1~2px 峰漂移(d=417/418/419 属同一 basin,不应是三个独立候选)。
+ // 邻域点越出本档重叠区间即跳过(不越档);半径 ≤ 2~3px,绝不合并远距周期峰;
+ // 验收阈值原样不动(择优后的证据照常走 ⑥ 验收,只会更难不会更易)。
+ {
+ FinalCand& win = finals[best];
+ int d0 = win.d;
+ float oldOverall = win.ev.overall, oldSeam = win.ev.seam;
+ for (int dd = d0 - LC_BASIN_RADIUS; dd <= d0 + LC_BASIN_RADIUS; dd++) {
+ if (dd == d0) continue;
+ int ad2 = dd < 0 ? -dd : dd;
+ int o2 = h - ad2;
+ if (o2 < ovMin || o2 > ovMax) continue;
+ LCOverlapEvidence ev2;
+ if (!LongCaptureVerifyCandidate(prevM, currM, dd, ev2)) continue;
+ // 切换必须是「严格更优」:overall 明显提升且 seam 不实质受损,或 overall
+ // 持平下 seam 提升——绝不允许用 seam 换 overall(否则可能把本可通过验收
+ // 的中位数候选换成不通过的邻点,引入回归)。
+ bool better = (ev2.overall > win.ev.overall + 0.001f
+ && ev2.seam >= win.ev.seam - 0.02f)
+ || (ev2.overall > win.ev.overall - 0.001f
+ && ev2.seam > win.ev.seam + 0.001f);
+ if (better) {
+ win.d = dd;
+ win.ev = ev2;
+ }
+ }
+ if (win.d != d0) { // basin 内择优命中:综合分按新证据重算
+ win.baseConf = baseConfOf(win.ev, win.roiWeighted);
+ LC_LOG("[LC#%d] %s basin d=%d->%d overall=%.2f->%.2f seam=%.2f->%.2f",
+ logId, weak ? "WEAK" : "NORM", d0, win.d,
+ oldOverall, win.ev.overall, oldSeam, win.ev.seam);
+ }
+ }
+
+ // ⑥ 峰值分离度:与最优相距 > LC_PEAK_WIN 的其余可胜出候选中最高综合分之差。
+ // 分离度进入置信度(加分项),分差过小(周期性/重复内容歧义)额外加罚——
+ // 这种情况绝不立即高置信放行,宁可降置信度走重试。
+ float secondBase = -1.0f;
+ for (int i = 0; i < (int)finals.size(); i++) {
+ if (!finals[i].eligible || i == best) continue;
+ int diff = finals[i].d > finals[best].d ? finals[i].d - finals[best].d
+ : finals[best].d - finals[i].d;
+ if (diff <= LC_PEAK_WIN) continue;
+ if (finals[i].baseConf > secondBase) secondBase = finals[i].baseConf;
+ }
+ float sepRaw = secondBase < 0 ? LC_PEAK_SEP_FULL : finals[best].baseConf - secondBase;
+ if (sepRaw < 0.0f) sepRaw = 0.0f;
+ if (sepRaw > LC_PEAK_SEP_FULL) sepRaw = LC_PEAK_SEP_FULL;
+ float sepNorm = sepRaw / LC_PEAK_SEP_FULL;
+ float conf = finals[best].baseConf + (weak ? 0.10f : 0.08f) * sepNorm;
+ if (sepRaw < LC_AMBIGUITY_MARGIN) conf -= LC_AMBIGUITY_CONF_PENALTY;
+ // 有效纹理占比折扣(「匹配成功 ≠ 匹配可信」的最后一环):重叠区大部分为空白
+ // 时 overall/spatial 只由少数有效行支撑——大面积纯色区在错误位移处也可能偶然
+ // 高分,证据量本身要计价。按缺失比例轻度衰减(texture→0 时最多约 −0.06)。
+ if (finals[best].ev.textureRatio < LC_TEXTURE_CONF_FLOOR)
+ conf -= (LC_TEXTURE_CONF_FLOOR - finals[best].ev.textureRatio) * LC_TEXTURE_CONF_SCALE;
+ if (conf < 0.0f) conf = 0.0f;
+ if (conf > 1.0f) conf = 1.0f;
+
+ const FinalCand& w = finals[best];
+ const LCOverlapEvidence& ev = w.ev;
+ m.offset = w.d;
+ m.overlap = h - (w.d > 0 ? w.d : -w.d);
+ m.overall = ev.overall;
+ m.seam = ev.seam;
+ m.top = (std::max)(0.0f, ev.part[0]);
+ m.middle = (std::max)(0.0f, ev.part[1]);
+ m.bottom = (std::max)(0.0f, ev.part[2]);
+ m.spatial = ev.spatial;
+ m.continuity = ev.continuity;
+ m.profileScore = ev.profileScore;
+ m.edgeCorrelation = ev.edgeScore;
+ m.textureRatio = ev.textureRatio;
+ m.peakGap = sepNorm;
+ m.roiWeighted = w.roiWeighted;
+ m.agreeCount = w.support;
+ m.confidence = conf;
+ LC_LOG("[LC#%d] %s pick d=%d sep=%.2f conf=%.2f%s", logId, weak ? "WEAK" : "NORM",
+ w.d, sepNorm, conf, sepRaw < LC_AMBIGUITY_MARGIN ? " (ambiguous peaks)" : "");
+
+ if (weak) {
+ // ⑥-Weak:全票 + 高阈值 + 空间/连续性/多尺度结构/分离度下限 + 动态置信度门槛。
+ float need = LongCaptureWeakRequiredConfidence(h, m.overlap);
+ // Weak 时间一致性(短时序列共识):多个相邻采样持续指向同一 offset 区间时给
+ // 置信度加分。只加分——空间硬门槛与动态门槛数值原样不动,也绝不因
+ // 「历史上一直是这个值」绕过下方验收直接提交。
+ if (wt.active) {
+ int tdiff = m.offset > wt.refOffset ? m.offset - wt.refOffset : wt.refOffset - m.offset;
+ if (tdiff <= LC_WEAK_TEMPORAL_TOL && m.confidence < 1.0f) {
+ m.confidence += wt.bonus;
+ if (m.confidence > 1.0f) m.confidence = 1.0f;
+ }
+ }
+ if (m.overall >= LC_WEAK_ACCEPT_OVERALL && m.seam >= LC_WEAK_ACCEPT_SEAM
+ && m.edgeCorrelation >= LC_WEAK_ACCEPT_EDGE
+ && m.spatial >= LC_WEAK_ACCEPT_SPATIAL && m.continuity >= LC_WEAK_ACCEPT_CONTINUITY
+ && m.profileScore >= LC_WEAK_ACCEPT_PROFILE
+ && sepRaw >= LC_WEAK_MIN_PEAK_SEP && conf >= need) {
+ m.status = LC_MATCH_SUCCESS;
+ } else {
+ m.status = LC_MATCH_LOW_CONFIDENCE; // 有候选但证据不足:交由重试,绝不拼接
+ // 失败分类(首个未达标指标定类):整体匹配/边缘结构/综合置信度不足 →
+ // CANDIDATE_WEAK,其余指标各有专属类别,便于区分失败来源。
+ if (m.overall < LC_WEAK_ACCEPT_OVERALL)
+ m.reason = LCFailReason::CandidateWeak;
+ else if (m.seam < LC_WEAK_ACCEPT_SEAM)
+ m.reason = LCFailReason::SeamMismatch;
+ else if (m.edgeCorrelation < LC_WEAK_ACCEPT_EDGE)
+ m.reason = LCFailReason::CandidateWeak;
+ else if (m.spatial < LC_WEAK_ACCEPT_SPATIAL)
+ m.reason = LCFailReason::SpatialMismatch;
+ else if (m.continuity < LC_WEAK_ACCEPT_CONTINUITY)
+ m.reason = LCFailReason::ContinuityMismatch;
+ else if (m.profileScore < LC_WEAK_ACCEPT_PROFILE)
+ m.reason = LCFailReason::ProfileMismatch;
+ else if (sepRaw < LC_WEAK_MIN_PEAK_SEP)
+ m.reason = LCFailReason::PeakAmbiguous;
+ else
+ m.reason = LCFailReason::CandidateWeak; // 剩余唯一失败原因:置信度不足
+ }
+ return m;
+ }
+
+ // ⑥-Normal:严格档 overall/seam + 可信来源 + 综合置信度三重把关。
+ // 单 ROI 支持的聚类也可由全宽富复核裁决,但要求该 ROI 达到严格档(沿用
+ // 「左右留白单栏文本页」的逃生通道);多 ROI 加权支持本身就是强证据。
+ bool strictPass = m.overall >= LC_ACCEPT_STRICT_OVERALL && m.seam >= LC_ACCEPT_STRICT_SEAM;
+ bool credible = w.support >= 2 || w.strictMember;
+ if (strictPass && credible && conf >= LC_MIN_CONFIDENCE) {
+ m.status = LC_MATCH_SUCCESS;
+ m.offset = (w.d < 0 ? -w.d : w.d) > 1 ? w.d : 0;
+ } else {
+ m.status = LC_MATCH_LOW_CONFIDENCE; // 有候选但证据不足:交由重试,绝不拼接
+ // 失败分类:整体匹配不足 → CANDIDATE_WEAK / 接缝不足 → SEAM_MISMATCH /
+ // ROI 支持不足 → ROI_INCONSISTENT / 峰值歧义压低置信度 → PEAK_AMBIGUOUS。
+ if (!strictPass)
+ m.reason = m.overall < LC_ACCEPT_STRICT_OVERALL
+ ? LCFailReason::CandidateWeak : LCFailReason::SeamMismatch;
+ else if (!credible)
+ m.reason = LCFailReason::RoiInconsistent;
+ else
+ m.reason = sepRaw < LC_AMBIGUITY_MARGIN
+ ? LCFailReason::PeakAmbiguous : LCFailReason::CandidateWeak;
+ }
+ return m;
+}
+
+// 识别主入口(双档扫描,纯函数):先 Normal 档,未 SUCCESS 再试 WeakOverlap 档
+//(纯弱重叠区间 + 严格交叉验证 + 延迟确认 + 时间一致性加分)。Weak 只有在强证据下
+// 才产出 SUCCESS;否则保留信息量更大的诊断结论返回——弱档候选置信度更高时返回弱档
+// 结论(LOW_CONFIDENCE 比 FAILED / Normal 诊断信息量更大,供 Weak 时间一致性登记与
+// 失败归因使用;两者对调用方同样只是「本帧被拒绝」)。logId 仅用于调试日志关联采样
+// 序号(不参与任何判定)。
+LongMatchOutcome LongCaptureDetectMatch(const LongMatchData& prevM,
+ const LongMatchData& currM, int dir,
+ const LongCaptureOffsetPrior& prior,
+ const LCWeakTemporal& wt, int logId) {
+ LongMatchOutcome m = LongCaptureDetectPass(prevM, currM, dir, prior, false, wt, logId);
+ if (m.status == LC_MATCH_SUCCESS) return m;
+ LongMatchOutcome w = LongCaptureDetectPass(prevM, currM, dir, prior, true, wt, logId);
+ if (w.status == LC_MATCH_SUCCESS) return w;
+ return w.confidence > m.confidence ? w : m;
+}
+
+// CR-018: 防常量漂移。LC_RETRY_DELAY_WEAK 按弱候选重试档分档,调用方以
+// LC_WEAK_RETRY_ATTEMPTS 为循环上界索引 LC_RETRY_DELAY_WEAK[weakRetries++],
+// 档数不一致将越界读或漏掉档位。
+static_assert(sizeof(LC_RETRY_DELAY_WEAK) / sizeof(LC_RETRY_DELAY_WEAK[0]) == LC_WEAK_RETRY_ATTEMPTS,
+ "LC_RETRY_DELAY_WEAK dimension must equal LC_WEAK_RETRY_ATTEMPTS");
diff --git a/src/screenshot/lc_panel_ui.cpp b/src/screenshot/lc_panel_ui.cpp
new file mode 100644
index 0000000..bc0cd57
--- /dev/null
+++ b/src/screenshot/lc_panel_ui.cpp
@@ -0,0 +1,661 @@
+// 长截图子系统:侧边小地图面板 + 全屏灰蒙版 UI。
+// CR-021 拆分自 long_capture_windows.cpp 的「预览面板 / 全屏蒙版」段。
+// 面板分层渲染(UpdateLayeredWindow 原子提交)+ 滚轮观察目标窗口 + 蒙版取景窗。
+// EnsureArgbSurface/FreeArgbSurface 是面板/工具栏/tooltip 共用的 32bpp 预乘 ARGB
+// 后备 DIB 辅助,在此定义(long_capture_internal.h 声明)。
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// 蒙版样式(预乘 ARGB):整屏半透明灰,采样裁剪区整透明透出实况桌面
+const int LONG_MASK_GRAY = 44;
+
+const BYTE LONG_MASK_ALPHA = 0xA0;
+
+// 预览面板布局常量(逻辑像素,绘制时按 dpiScale 缩放)。
+// 面板只保留小地图本体(帧提示与完成/取消按钮已移至选区底部的长截图工具栏窗口)。
+
+const int LC_PANEL_W = 232; // 面板总宽
+
+const int LC_PANEL_PAD = 8; // 内边距
+
+static const int LC_PANEL_MIN_H = 48; // 预览最小高度(逻辑像素,防止无内容时塌缩成线)
+
+#ifndef RI_MOUSE_HWHEEL
+#define RI_MOUSE_HWHEEL 0x0800
+#endif
+
+// 预览图目标矩形(客户区坐标):拼接结果等比缩放,水平居中、垂直居中于面板。
+// 尺寸取「显示空间」:纵向 = physW×行数;横向 = 行数×physW(回转后宽高互换),
+// 行数含裁剪窗口(裁掉的部分不进预览)。
+
+static RECT LongCapturePanelPreviewRect(LongCaptureContext* c, int cw, int ch) {
+ RECT r = {0, 0, 0, 0};
+ if (!c || c->stitchH <= 0 || c->physW <= 0) return r;
+ double ds = c->dpiScale;
+ int pad = (int)(LC_PANEL_PAD * ds + 0.5);
+ int availW = cw - pad * 2;
+ int availH = ch - pad * 2;
+ if (availW < 1 || availH < 1) return r;
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ if (rows <= 0) return r;
+ // 显示空间(物理像素)宽高:固定轴 = physW(= cropRect 的物理对应,纵向=宽/横向=高),
+ // 滚动轴 = rows。输出为面板物理矩形,故用物理量;逻辑标签版见 LongCaptureOutputSizeLabel,
+ // 高度生长版见 LongCapturePanelUpdate,三者同一换算口径(CR-023)。
+ double dispW = c->horizontal ? (double)rows : (double)c->physW;
+ double dispH = c->horizontal ? (double)c->physW : (double)rows;
+ double scale = (std::min)((double)availW / dispW, (double)availH / dispH);
+ int pw = (int)(dispW * scale + 0.5);
+ int ph = (int)(dispH * scale + 0.5);
+ r.left = (cw - pw) / 2;
+ r.top = pad + (availH - ph) / 2;
+ r.right = r.left + pw;
+ r.bottom = r.top + ph;
+ return r;
+}
+
+// 内容坐标 → 预览像素的视口框(小地图预览坐标,显示空间):contentTop + headRows =
+// 拼接图内位置,等比缩放并钳制进裁剪后的预览范围。committed(实线已确认框)与
+// tentative(虚线预计框)共用本换算;横向模式的「行位置」映射为水平位置。
+// 移动轴贴合预览区两侧换算;固定轴(纵向=左右、横向=上下)向外扩 1px——预览图外侧
+// 一圈正是整体描边所在(见 WM_PAINT),扩出的框线恰落于该环上,重合处整像素压住
+// 描边而非与之混叠透白。移动轴同理:带被钳制到输出窗口头端/尾端时该侧边界再外扩
+// 1px 吞掉相邻环行,蓝框在极值处完整封边、不残留灰白线;中间位置保留环行(语义 =
+// 当前视图之外还有已捕获内容)。
+static RECT LongCaptureViewportRectAt(LongCaptureContext* c, const RECT& preview, int64_t contentTop) {
+ RECT v = { 0, 0, 0, 0 };
+ if (!c || c->stitchH <= 0 || c->physW <= 0
+ || preview.right <= preview.left || preview.bottom <= preview.top) return v;
+ bool horiz = !!c->horizontal;
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ if (rows <= 0) return v;
+ if (rows <= c->physH) { // 未拼接滚动:整体即当前区域(四边均贴端外扩)
+ v = preview;
+ v.left--; v.right++; v.top--; v.bottom++;
+ return v;
+ }
+ int64_t topStitch = contentTop + c->headRows; // 内容坐标 → 拼接坐标
+ if (topStitch < rowStart) topStitch = rowStart;
+ int64_t tailLimit = (int64_t)rowEnd - c->physH;
+ if (tailLimit < rowStart) tailLimit = rowStart;
+ if (topStitch > tailLimit) topStitch = tailLimit;
+ bool atHead = topStitch <= rowStart; // 带抵输出窗口头端
+ bool atTail = topStitch >= tailLimit; // 带抵尾端
+ if (!horiz) {
+ double scale = (double)(preview.bottom - preview.top) / rows;
+ int vhPx = (int)(c->physH * scale + 0.5);
+ if (vhPx < 1) vhPx = 1;
+ int topPx = preview.top + (int)((topStitch - rowStart) * scale + 0.5);
+ v.left = preview.left - 1;
+ v.right = preview.right + 1;
+ v.top = topPx - (atHead ? 1 : 0);
+ v.bottom = topPx + vhPx + (atTail ? 1 : 0);
+ } else {
+ double scale = (double)(preview.right - preview.left) / rows;
+ int vwPx = (int)(c->physH * scale + 0.5);
+ if (vwPx < 1) vwPx = 1;
+ int leftPx = preview.left + (int)((topStitch - rowStart) * scale + 0.5);
+ v.left = leftPx - (atHead ? 1 : 0);
+ v.right = leftPx + vwPx + (atTail ? 1 : 0);
+ v.top = preview.top - 1;
+ v.bottom = preview.bottom + 1;
+ }
+ return v;
+}
+
+// 已确认(committed)视口框:最新一次提交帧的精确位置(替代旧的 lastDir 顶/底猜测;
+// 纯向下滚 = 底部、纯向上滚 = 顶部,混合滚动方向时为真实中间位置)。
+
+static RECT LongCaptureViewportRect(LongCaptureContext* c, const RECT& preview) {
+ return LongCaptureViewportRectAt(c, preview, c ? c->committedContentTop : 0);
+}
+
+
+// 浮点坐标圆角矩形路径(GDI+ 抗锯齿路径基础):int 版本见 overlay_ui_windows.cpp 的
+// AddRoundedRect。描边需半像素级内缩对齐才能保证 1px 笔画完整落在表面像素带内,
+// 整数坐标版本无法表达,故专设本重载。radius 自动钳制不超过短边一半。
+
+static void AddRoundedRectF(Gdiplus::GraphicsPath& outPath, Gdiplus::REAL x, Gdiplus::REAL y,
+ Gdiplus::REAL w, Gdiplus::REAL h, Gdiplus::REAL radius) {
+ Gdiplus::REAL d = radius * 2;
+ if (d > w) { radius = w / 2; d = w; }
+ if (d > h) { radius = h / 2; d = h; }
+ Gdiplus::RectF rect(x, y, w, h);
+ if (radius < 0.5f) { outPath.AddRectangle(rect); return; }
+ outPath.AddArc(rect.X, rect.Y, d, d, 180, 90);
+ outPath.AddArc(rect.GetRight() - d, rect.Y, d, d, 270, 90);
+ outPath.AddArc(rect.GetRight() - d, rect.GetBottom() - d, d, d, 0, 90);
+ outPath.AddArc(rect.X, rect.GetBottom() - d, d, d, 90, 90);
+ outPath.CloseFigure();
+}
+
+// 面板后备表面(与窗口等大的预乘 ARGB DIB,随窗口销毁释放)
+
+static HDC s_lcPnSurfDC = NULL;
+static HBITMAP s_lcPnSurfBmp = NULL;
+static void* s_lcPnSurfBits = nullptr;
+static int s_lcPnSurfW = 0, s_lcPnSurfH = 0;
+
+// 整幅渲染小地图面板并经 UpdateLayeredWindow 原子提交:
+// 清透明 → 深色圆角底 → 内缩整像素描边 → 缩略小地图(零拷贝包装增量缩略缓冲,
+// GDI+ 高质量插值)→ 视口框三层标注(半透明衬底 / 实线蓝框 / 虚线橙框)。
+// 分层渲染一次性解决三个历史问题:
+// 1) 滚动闪动——旧实现往屏幕 DC 分四层直绘且窗口生长时「先伸缩后绘制」,每帧中间态
+// 直达合成器;现全部绘制进后备表面,ULW 单次提交,屏幕只见完整帧;
+// 2) 右/底边框截断——旧实现把圆角矩形贴着窗口边界画,居中描边的外半宽越出客户区被
+// 裁掉;现描边路径整体内缩半像素,1px 笔画完整落在 [0,cw-1]/[0,ch-1] 像素带内;
+// 3) 圆角外黑色直角残留——旧实现为不透明方形窗口且只填圆角路径内部,方角区域从未
+// 绘制(表现为黑);现为逐像素 alpha,圆角外 alpha=0 真透明。
+// panel 由调用方传入而非读 g_longControlWindow:创建时机上首帧渲染发生在全局句柄赋值前。
+
+void LongCapturePanelRender(HWND panel, LongCaptureContext* c) {
+ if (!panel || !c) return;
+ RECT cr;
+ if (!GetClientRect(panel, &cr)) return;
+ RECT wr;
+ GetWindowRect(panel, &wr); // ULW 目标几何用窗口原点(客户区=整个 WS_POPUP)
+ int cw = cr.right - cr.left, ch = cr.bottom - cr.top;
+ if (cw < 1 || ch < 1) return;
+ if (!EnsureArgbSurface(s_lcPnSurfDC, s_lcPnSurfBmp, s_lcPnSurfBits,
+ s_lcPnSurfW, s_lcPnSurfH, cw, ch))
+ return;
+ double ds = c->dpiScale;
+ float radius = (float)(8 * ds + 0.5);
+ {
+ Gdiplus::Bitmap surf(cw, ch, cw * 4, PixelFormat32bppPARGB, (BYTE*)s_lcPnSurfBits);
+ Gdiplus::Graphics g(&surf);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ Gdiplus::SolidBrush clear(Gdiplus::Color(0, 0, 0, 0));
+ g.FillRectangle(&clear, 0, 0, cw, ch);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ // 圆角深色背景(半径按 DPI 缩放;旧实现固定物理 8px 与其余 UI 不同步)
+ {
+ Gdiplus::GraphicsPath path;
+ AddRoundedRectF(path, 0, 0, (Gdiplus::REAL)cw, (Gdiplus::REAL)ch, radius);
+ Gdiplus::SolidBrush bg(Gdiplus::Color(255, 52, 52, 53));
+ g.FillPath(&bg, &path);
+ // 边框描边:路径内缩半像素(0.5,.5)-(cw-.5,ch-.5),居中 1px 笔画恰占满
+ // 边界整像素环;半径同步内缩保持同心。
+ Gdiplus::GraphicsPath border;
+ AddRoundedRectF(border, 0.5f, 0.5f, (Gdiplus::REAL)(cw - 1), (Gdiplus::REAL)(ch - 1),
+ (std::max)(radius - 0.5f, 1.0f));
+ Gdiplus::Pen pen(Gdiplus::Color(255, 102, 102, 102), 1.0f);
+ g.DrawPath(&pen, &border);
+ }
+ // 缩略小地图:先按固定列宽增量缩列的缩略缓冲,这里只重采样行(避免逐帧重读拼接大缓冲)
+ LongCaptureRebuildThumb(c);
+ if (c->horizontal) LongCaptureRebuildThumbDisplay(c);
+ RECT preview = LongCapturePanelPreviewRect(c, cw, ch);
+ if (c->thumbW > 0 && c->thumbH > 0 && preview.right > preview.left
+ && preview.bottom > preview.top) {
+ // 裁剪行窗口 → 源矩形:纵向映射 srcY/srcH(行 = 纵向轴),
+ // 横向用回转缓冲映射 srcX/srcW(行 = 回转后的水平轴)
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ const uint32_t* bits = c->thumbMerged.data();
+ int bw = c->thumbW, bh = c->thumbH;
+ int srcX = 0, srcY = rowStart, srcW = bw, srcH = rows;
+ if (c->horizontal && !c->thumbDisplay.empty()) {
+ bits = c->thumbDisplay.data();
+ bw = c->thumbDisplayW; bh = c->thumbDisplayH;
+ srcX = rowStart; srcY = 0; srcW = rows; srcH = bh;
+ }
+ if (bw > 0 && bh > 0 && srcW > 0 && srcH > 0 && srcW <= bw && srcH <= bh) {
+ // 零拷贝包装缩略缓冲(PixelFormat32bppRGB 视作不透明 RGBX:等宽快路径
+ // memcpy 自抓屏 DIB 的 alpha 字节不可信)。先裁剪到预览矩形再 DrawImage,
+ // 防高质量插值向外渗出压到描边上。
+ Gdiplus::Bitmap thumb(bw, bh, bw * 4, PixelFormat32bppRGB, (BYTE*)bits);
+ Gdiplus::Rect dstR(preview.left, preview.top,
+ preview.right - preview.left, preview.bottom - preview.top);
+ g.SetClip(dstR);
+ g.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBilinear);
+ g.DrawImage(&thumb, dstR, srcX, srcY, srcW, srcH, Gdiplus::UnitPixel, NULL);
+ g.ResetClip();
+ g.SetInterpolationMode(Gdiplus::InterpolationModeDefault);
+ }
+ }
+ // 整体框:预览图外侧一圈 1px 灰描边(占外围相邻一像素环)= 已捕获的完整区域标记
+ // 已确认区域框(实线蓝):最新一次提交帧的精确位置,只在 Commit 后移动;固定轴
+ // 恰落在整体框那圈描边上(见 LongCaptureViewportRectAt),整像素覆盖其上
+ // 预计区域框(虚线橙):本帧未提交、但已确认滚动方向/粗略位移时的 tentative 位置
+ // 描边一律整数像素 FillRectangle:整像素、确定性栅格化,语义与旧版一致
+ if (preview.right > preview.left && preview.bottom > preview.top) {
+ auto hbar = [&](int y, int x0, int x1, const Gdiplus::Color& color) {
+ Gdiplus::SolidBrush br(color);
+ g.FillRectangle(&br, x0, y, x1 - x0, 1);
+ };
+ auto vbar = [&](int x, int y0, int y1, const Gdiplus::Color& color) {
+ Gdiplus::SolidBrush br(color);
+ g.FillRectangle(&br, x, y0, 1, y1 - y0);
+ };
+ const Gdiplus::Color kRingC(255, 190, 190, 195); // 整体框
+ const Gdiplus::Color kBlueC(255, 0x2F, 0x7E, 0xE5); // 已确认视口框
+ const Gdiplus::Color kOrngC(255, 0xE8, 0xA3, 0x3C); // 预计框虚线
+ RECT vp = LongCaptureViewportRect(c, preview);
+ bool vpValid = vp.right > vp.left && vp.bottom > vp.top;
+ RECT tp = {};
+ bool tpShow = false;
+ if (c->tentativeValid) {
+ int64_t tdiff = c->tentativeContentTop - c->committedContentTop;
+ if (tdiff < 0) tdiff = -tdiff;
+ if (tdiff >= LC_TRACK_MIN_STEP) {
+ tp = LongCaptureViewportRectAt(c, preview, c->tentativeContentTop);
+ tpShow = tp.right > tp.left && tp.bottom > tp.top;
+ }
+ }
+ // 半透明内衬只铺描边内侧、向内收 1px;先画衬底再叠不透明描边
+ if (vpValid) {
+ Gdiplus::SolidBrush fill(Gdiplus::Color(60, GetRValue(SC_THEME_TOOLBAR_BLUE),
+ GetGValue(SC_THEME_TOOLBAR_BLUE), GetBValue(SC_THEME_TOOLBAR_BLUE)));
+ g.FillRectangle(&fill, vp.left + 1, vp.top + 1,
+ vp.right - vp.left - 2, vp.bottom - vp.top - 2);
+ }
+ if (tpShow) {
+ Gdiplus::SolidBrush tfill(Gdiplus::Color(36, 0xE8, 0xA3, 0x3C));
+ g.FillRectangle(&tfill, tp.left + 1, tp.top + 1,
+ tp.right - tp.left - 2, tp.bottom - tp.top - 2);
+ }
+ // 1) 整体环:预览图外沿相邻一像素(左右列 left-1/right、上下行 top-1/bottom)
+ vbar(preview.left - 1, preview.top - 1, preview.bottom + 1, kRingC);
+ vbar(preview.right, preview.top - 1, preview.bottom + 1, kRingC);
+ hbar(preview.top - 1, preview.left - 1, preview.right + 1, kRingC);
+ hbar(preview.bottom, preview.left - 1, preview.right + 1, kRingC);
+ // 2) 视口蓝框:四条边界条各占一整像素行/列——固定轴两条恰压住整体环,
+ // 移动轴两条压住视口带首末行/列(无衬底处也不会漏出原始内容)
+ if (vpValid) {
+ hbar(vp.top, vp.left, vp.right, kBlueC);
+ hbar(vp.bottom-1, vp.left, vp.right, kBlueC);
+ vbar(vp.left, vp.top + 1, vp.bottom - 1, kBlueC);
+ vbar(vp.right - 1, vp.top + 1, vp.bottom - 1, kBlueC);
+ }
+ // 3) tentative 橙色虚线框:四边按「画4空3」分段(超出已捕获范围时贴边停驻,
+ // 与 committed 同一套换算,同样盖住整体环与带内首末行)
+ if (tpShow) {
+ auto dashH = [&](int y, int x0, int x1) {
+ for (int x = x0; x < x1; x += 7) hbar(y, x, (std::min)(x + 4, x1), kOrngC);
+ };
+ auto dashV = [&](int x, int y0, int y1) {
+ for (int y = y0; y < y1; y += 7) vbar(x, y, (std::min)(y + 4, y1), kOrngC);
+ };
+ dashH(tp.top, tp.left, tp.right);
+ dashH(tp.bottom - 1, tp.left, tp.right);
+ dashV(tp.left, tp.top + 1, tp.bottom - 1);
+ dashV(tp.right - 1, tp.top + 1, tp.bottom - 1);
+ }
+ }
+ }
+ POINT dst = { wr.left, wr.top };
+ SIZE sz = { cw, ch };
+ POINT src = { 0, 0 };
+ BLENDFUNCTION bf = { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA };
+ UpdateLayeredWindow(panel, NULL, &dst, &sz, s_lcPnSurfDC, &src, 0, &bf, ULW_ALPHA);
+}
+
+// 预览面板窗口过程:仅承载拼接缩略小地图与视口框的分层渲染提交时机(WM_PAINT/
+// InvalidateRect 仍作为串行化重绘入口),并作为滚轮观察(Raw Input)的目标窗口。
+
+LRESULT CALLBACK LongCapturePanelWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
+ switch (msg) {
+ case WM_PAINT: {
+ // 内容由 LongCapturePanelRender 经 ULW 提交:这里只验证更新区并触发整幅渲染。
+ PAINTSTRUCT ps;
+ BeginPaint(hwnd, &ps);
+ EndPaint(hwnd, &ps);
+ LongCapturePanelRender(hwnd, g_longCtx.load());
+ return 0;
+ }
+ case WM_INPUT: {
+ // 滚轮观察(Raw Input INPUTSINK,注册见 LongCaptureRegisterWheelObserver):
+ // 只被动解析滚轮方向与时机供防抖采样,不拦截、不影响任何输入送达底层应用。
+ // 纵向模式消费纵滚轮;横向模式消费横滚轮(RI_MOUSE_HWHEEL)与 Shift+纵滚轮
+ // (多数应用把 Shift+滚轮翻译为水平滚动)。wheelAccumDelta 的符号约定与
+ // LongCaptureUpdateWheelEstimate 对齐:正累计 ↔ 位移 d<0(内容向头部方向滚)。
+ LongCaptureContext* c = g_longCtx.load();
+ if (c) {
+ BYTE buf[sizeof(RAWINPUT) + 64];
+ UINT size = sizeof(buf);
+ if (GetRawInputData((HRAWINPUT)lp, RID_INPUT, buf, &size,
+ sizeof(RAWINPUTHEADER)) != (UINT)-1) {
+ const RAWINPUT* raw = (const RAWINPUT*)buf;
+ if (raw->header.dwType == RIM_TYPEMOUSE) {
+ USHORT flags = raw->data.mouse.usButtonFlags;
+ short delta = 0;
+ bool isHWheel = false;
+ if (flags & RI_MOUSE_HWHEEL) {
+ delta = (short)raw->data.mouse.usButtonData;
+ isHWheel = true;
+ } else if (flags & RI_MOUSE_WHEEL) {
+ delta = (short)raw->data.mouse.usButtonData;
+ }
+ if (delta != 0) {
+ int dir = 0, accum = 0;
+ bool consume = false;
+ if (!c->horizontal) {
+ if (!isHWheel) { // 纵向模式忽略横滚轮
+ dir = delta > 0 ? -1 : 1; // 正 delta = 向上滚
+ accum = delta;
+ consume = true;
+ }
+ } else if (isHWheel) { // 横滚轮:正 delta = 向右滚 = 追加尾部
+ dir = delta > 0 ? 1 : -1;
+ accum = -delta;
+ consume = true;
+ } else if (GetAsyncKeyState(VK_SHIFT) & 0x8000) {
+ dir = delta < 0 ? 1 : -1; // Shift+滚轮向下 = 向右滚
+ accum = delta;
+ consume = true;
+ }
+ if (consume) {
+ c->wheelPending = true;
+ c->lastDir = dir;
+ c->lastWheelTick = GetTickCount();
+ // 软先验:累计待消化的滚轮增量(带符号),成功提交时折算 px/notch;
+ // 只经 LongCaptureBuildOffsetPrior 参与候选排序,绝不约束搜索范围。
+ c->wheelAccumDelta += accum;
+ }
+ }
+ }
+ }
+ }
+ DefWindowProc(hwnd, msg, wp, lp); // 让系统释放本条原始输入缓冲
+ return 0;
+ }
+ case WM_SETCURSOR: { SetCursor(LoadCursor(NULL, IDC_ARROW)); return TRUE; }
+ case WM_NCHITTEST: return HTCLIENT;
+ case WM_ERASEBKGND: return 1; // 分层窗口自绘,无系统擦底
+ case WM_DESTROY:
+ FreeArgbSurface(s_lcPnSurfDC, s_lcPnSurfBmp, s_lcPnSurfBits,
+ s_lcPnSurfW, s_lcPnSurfH);
+ return 0; // 不 PostQuitMessage:截图线程拥有消息循环
+ }
+ return DefWindowProc(hwnd, msg, wp, lp);
+}
+
+// 依据拼接内容重算面板尺寸(左上角锚定、向下生长,只增不减)并触发重绘。
+// 预览高等比换算按「显示空间」(横向模式宽高互换);生长上限额外避让选区底部
+// 工具栏(两者水平范围重叠时面板不得越过工具栏顶边)。
+
+void LongCapturePanelUpdate(LongCaptureContext* c) {
+ HWND panel = g_longControlWindow;
+ if (!panel || c->stitchH <= 0 || c->physW <= 0) return;
+ double ds = c->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ RECT wr;
+ GetWindowRect(panel, &wr);
+ int winW = wr.right - wr.left;
+ int pad = sc(LC_PANEL_PAD);
+ int availW = winW - pad * 2;
+ // 显示空间逻辑尺寸:宽度撑满 availW 时的等比预览高
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ if (rows <= 0) return;
+ // 显示空间逻辑尺寸统一公式(CR-023):固定轴取 cropRect 逻辑尺寸(纵向=宽/横向=高,
+ // 无 /ds 舍入往返误差),滚动轴取 rows / ds。与 LongCaptureOutputSizeLabel、
+ // LongCapturePanelPreviewRect 同一换算口径。
+ double dispWLogical = c->horizontal ? rows / ds : (double)(c->cropRect.right - c->cropRect.left);
+ double dispHLogical = c->horizontal ? (double)(c->cropRect.bottom - c->cropRect.top)
+ : rows / ds;
+ if (dispWLogical < 1.0) dispWLogical = 1.0;
+ int prevH = (int)(dispHLogical * ((double)availW / dispWLogical) + 0.5);
+ // 屏幕下沿约束:预览不超过面板顶部以下剩余空间与屏高 45%
+ int roomH = (c->vy + c->vh) - sc(4) - (int)wr.top - pad * 2;
+ // 选区底部工具栏避让:水平范围与面板重叠时,面板生长不得越过工具栏顶边
+ if (g_longToolbarWindow) {
+ RECT tb;
+ if (GetWindowRect(g_longToolbarWindow, &tb)
+ && tb.left < wr.right + sc(8) && tb.right > wr.left - sc(8)
+ && tb.top > wr.top) {
+ int byTb = tb.top - sc(8) - (int)wr.top - pad * 2;
+ if (byTb < roomH) roomH = byTb;
+ }
+ }
+ // 面板在选区上方时:生长不得越过选区顶边(否则面板入画)
+ if (c->panelAbove) {
+ int bySel = c->selection.top - sc(12) - (int)wr.top - pad * 2;
+ if (bySel < roomH) roomH = bySel;
+ }
+ int capH = (int)(c->vh * 0.45);
+ if (roomH < capH) capH = roomH;
+ if (capH < sc(LC_PANEL_MIN_H)) capH = sc(LC_PANEL_MIN_H);
+ if (prevH > capH) prevH = capH;
+ int newH = pad * 2 + prevH;
+ int oldH = wr.bottom - wr.top;
+ if (newH > oldH)
+ SetWindowPos(panel, NULL, 0, 0, winW, newH, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
+ InvalidateRect(panel, NULL, FALSE);
+}
+
+// 创建预览面板窗口:停靠选区右侧(空间不足退左侧,再退化到选区下方/上方),
+// 初始高度按选区首帧等比,之后由 LongCapturePanelUpdate 随拼接内容向下生长。
+
+HWND LongCaptureCreatePanel(CaptureContext* ctx, LongCaptureContext* c) {
+ static bool registered = false;
+ if (!registered) {
+ WNDCLASSEXW wc = {};
+ wc.cbSize = sizeof(wc);
+ wc.lpfnWndProc = LongCapturePanelWndProc;
+ wc.hInstance = GetModuleHandle(NULL);
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW);
+ wc.lpszClassName = L"ZToolsLongCapturePanel";
+ RegisterClassExW(&wc);
+ registered = true;
+ }
+ double ds = ctx->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ int winW = sc(LC_PANEL_W);
+ int pad = sc(LC_PANEL_PAD);
+ int margin = sc(12);
+ // 首帧即整个选区:按采样裁剪等比计算初始预览高,避免面板先闪空再放大
+ int selW = (std::max)(1, (int)(c->cropRect.right - c->cropRect.left));
+ int selH = (std::max)(1, (int)(c->cropRect.bottom - c->cropRect.top));
+ double scale0 = (double)(winW - pad * 2) / selW;
+ int prevH0 = (int)(selH * scale0 + 0.5);
+ int capH = (int)(ctx->virtualH * 0.45);
+ if (prevH0 > capH) prevH0 = capH;
+ int winH = pad * 2 + prevH0;
+
+ int x = c->selection.right + margin;
+ int y = c->selection.top;
+ if (x + winW > ctx->virtualX + ctx->virtualW) x = c->selection.left - winW - margin;
+ if (x < ctx->virtualX) {
+ // 水平无空间(选区接近全屏宽):退化为选区下方/上方,避免面板覆盖选区入画
+ x = (std::min)((std::max)(ctx->virtualX + sc(4),
+ (int)((c->selection.left + c->selection.right) / 2) - winW / 2),
+ ctx->virtualX + ctx->virtualW - winW - sc(4));
+ y = c->selection.bottom + margin;
+ if (y + winH > ctx->virtualY + ctx->virtualH) {
+ y = c->selection.top - winH - margin;
+ c->panelAbove = true;
+ }
+ }
+ if (y + winH > ctx->virtualY + ctx->virtualH) y = ctx->virtualY + ctx->virtualH - winH - sc(4);
+ if (y < ctx->virtualY) y = ctx->virtualY + sc(4);
+ // WS_EX_LAYERED:面板内容全部走 LongCapturePanelRender 的逐像素 alpha ULW 提交。
+ // 不透明方形窗口会让圆角外四角从未绘制(表现为黑色直角残留),分层后圆角外 alpha=0。
+ HWND panel = CreateWindowExW(WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE
+ | WS_EX_LAYERED,
+ L"ZToolsLongCapturePanel", L"LongCapturePanel", WS_POPUP, x, y, winW, winH,
+ NULL, NULL, GetModuleHandle(NULL), NULL);
+ if (!panel) return NULL;
+ // Win11 默认给无框架弹窗叠加系统圆角,与自绘圆角双重裁剪会在角部产生残缺;
+ // 显式声明禁止系统圆角(属性仅 Win11 存在,Win10 返回错误码忽略即可)。
+ {
+ DWORD pref = 1; // DWMWCP_DONOTROUND
+ DwmSetWindowAttribute(panel, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */,
+ &pref, sizeof(pref));
+ }
+ // 分层窗口 ShowWindow 前没有可显示的内容(首次 ULW 前 DWM 不合成),
+ // 先整幅渲染一次空面板(深色圆角占位框),随后再显示——无「先透明后闪现」中间帧。
+ LongCapturePanelRender(panel, g_longCtx.load());
+ ShowWindow(panel, SW_SHOWNOACTIVATE);
+ return panel;
+}
+
+LRESULT CALLBACK LongCaptureMaskWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
+ switch (msg) {
+ case WM_NCHITTEST: return HTTRANSPARENT;
+ case WM_ERASEBKGND: return 1;
+ case WM_PAINT: { PAINTSTRUCT ps; BeginPaint(hwnd, &ps); EndPaint(hwnd, &ps); return 0; }
+ case WM_DESTROY: return 0;
+ }
+ return DefWindowProc(hwnd, msg, wp, lp);
+}
+
+// 采样裁剪矩形 = 选区每边内缩 LC_CROP_INSET_LOGI(避开选区描边/抗锯齿边缘,
+// 防止污染拼接内容),内缩后钳制保证宽高至少 1px。蒙版取景窗(EnterLongCaptureMask)
+// 与长截图抓屏(BeginLongCapture 的 lc->cropRect)共用同一算法,原先两处手抄易漂移。
+
+RECT CalcSampleCrop(const CaptureContext* ctx) {
+ RECT crop = ctx->selection;
+ int inset = LC_CROP_INSET_LOGI;
+ crop.left = (std::min)(crop.right - 1, crop.left + inset);
+ crop.top = (std::min)(crop.bottom - 1, crop.top + inset);
+ crop.right = (std::max)(crop.left + 1, crop.right - inset);
+ crop.bottom = (std::max)(crop.top + 1, crop.bottom - inset);
+ return crop;
+}
+
+// (整窗点击穿透),因此滚轮/点击在任意位置都直达底层窗口——整个背景均可滚动操作。
+
+// 蒙版为 WS_EX_LAYERED(逐像素预乘 ARGB,UpdateLayeredWindow 一次性提交)+ WS_EX_TRANSPARENT
+
+// 进入长截图蒙版:创建全屏半透明灰色蒙版窗口,覆盖所有屏幕(不限于选区)。
+
+void EnterLongCaptureMask(const CaptureContext* ctx) {
+ static bool registered = false;
+ if (!registered) {
+ WNDCLASSEXW wc = {};
+ wc.cbSize = sizeof(wc);
+ wc.lpfnWndProc = LongCaptureMaskWndProc;
+ wc.hInstance = GetModuleHandle(NULL);
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW);
+ wc.lpszClassName = L"ZToolsLongCaptureMask";
+ RegisterClassExW(&wc);
+ registered = true;
+ }
+ if (g_longMaskWindow) { DestroyWindow(g_longMaskWindow); g_longMaskWindow = NULL; }
+
+ int w = ctx->virtualW, h = ctx->virtualH;
+ HWND mask = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED | WS_EX_TRANSPARENT,
+ L"ZToolsLongCaptureMask", L"LongCaptureMask", WS_POPUP,
+ ctx->virtualX, ctx->virtualY, w, h,
+ NULL, NULL, GetModuleHandle(NULL), NULL);
+ if (!mask) return;
+ g_longMaskWindow = mask;
+
+ // 采样裁剪矩形 = 选区每边内缩(公式收口见 CalcSampleCrop 注释)
+ RECT crop = CalcSampleCrop(ctx);
+ int cropW = crop.right - crop.left, cropH = crop.bottom - crop.top;
+
+ // 32bpp 预乘 ARGB 表面:整屏半透明灰 → 选区边缘蓝色描边(外偏采样区)→ 采样裁剪区清透明
+ HDC screenDC = GetDC(NULL);
+ HDC memDC = CreateCompatibleDC(screenDC);
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi.bmiHeader.biWidth = w;
+ bi.bmiHeader.biHeight = -h; // 负值 = 自上而下
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+ void* bits = NULL;
+ HBITMAP bmp = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0);
+ if (bmp && bits) {
+ HGDIOBJ old = SelectObject(memDC, bmp);
+ Gdiplus::Bitmap gbmp(w, h, w * 4, PixelFormat32bppPARGB, (BYTE*)bits);
+ {
+ // 1) 整屏半透明灰
+ Gdiplus::Graphics g(&gbmp);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::SolidBrush gray(Gdiplus::Color(
+ LONG_MASK_ALPHA, LONG_MASK_GRAY, LONG_MASK_GRAY, LONG_MASK_GRAY));
+ g.FillRectangle(&gray, (Gdiplus::REAL)0, (Gdiplus::REAL)0, (Gdiplus::REAL)w, (Gdiplus::REAL)h);
+ }
+ {
+ // 2) 选区边缘蓝色描边(落在半透明灰上,清晰可辨)。
+ // 路径 = 采样裁剪矩形向外偏移 LC_CROP_INSET_LOGI 的圆角矩形(半径同步外扩):
+ // GDI+ 居中描边的内半宽(1.25px)+ 抗锯齿(~0.5px)会越过路径内侧,若直接以
+ // crop 为路径,内半圈描边恰好落进采样区(crop = 每帧 BitBlt 取样范围 = 最终
+ // 拼接画面),滚动拼接后画面边缘与接缝处会混入蓝色边框线。外偏 2px 使直边段
+ // 描边整体留在采样区外;圆角弧段在大半径时仍会斜向内凹越过采样区直角角落,
+ // 由紧随其后的第 3 步清透明兜底擦除。
+ Gdiplus::Graphics g(&gbmp);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::Pen pen(ScOpaqueColor(SC_THEME_ACCENT_BLUE), 2.5f);
+ Gdiplus::GraphicsPath path;
+ int outX = crop.left - ctx->virtualX - LC_CROP_INSET_LOGI;
+ int outY = crop.top - ctx->virtualY - LC_CROP_INSET_LOGI;
+ int outW = cropW + LC_CROP_INSET_LOGI * 2;
+ int outH = cropH + LC_CROP_INSET_LOGI * 2;
+ int radius = (std::min)(ctx->selectionCornerRadius, (std::min)(cropW, cropH) / 2)
+ + LC_CROP_INSET_LOGI;
+ AddRoundedRect(path, outX, outY, outW, outH, radius);
+ g.DrawPath(&pen, &path);
+ }
+ {
+ // 3) 清空采样裁剪区为全透明(SourceCopy 直接写回,透出实况桌面)。
+ // 放在描边之后:即使描边圆角弧段越界进入采样区,也在此被整体擦除,
+ // 保证抓屏取样范围内绝无蒙版自身像素。
+ Gdiplus::Graphics g(&gbmp);
+ Gdiplus::SolidBrush clear(Gdiplus::Color(0, 0, 0, 0));
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ g.FillRectangle(&clear,
+ (Gdiplus::REAL)(crop.left - ctx->virtualX), (Gdiplus::REAL)(crop.top - ctx->virtualY),
+ (Gdiplus::REAL)cropW, (Gdiplus::REAL)cropH);
+ }
+ SIZE sz = { w, h };
+ POINT dst = { ctx->virtualX, ctx->virtualY };
+ POINT src = { 0, 0 };
+ BLENDFUNCTION bf = { AC_SRC_OVER, 0, 255, AC_SRC_ALPHA };
+ UpdateLayeredWindow(mask, NULL, &dst, &sz, memDC, &src, 0, &bf, ULW_ALPHA);
+ SelectObject(memDC, old);
+ }
+ if (memDC) DeleteDC(memDC);
+ ReleaseDC(NULL, screenDC);
+ if (bmp) DeleteObject(bmp);
+ ShowWindow(mask, SW_SHOWNOACTIVATE);
+}
+
+// 确保 32bpp 预乘 ARGB 后备 DIB 就绪:尺寸不变时复用,变化时释放重建。
+// dc/bmp/bits/w/h 由调用方持有(工具栏/tooltip 各一份静态),成功返回 true。
+bool EnsureArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h,
+ int wantW, int wantH) {
+ if (bmp && w == wantW && h == wantH) return true;
+ if (dc) { DeleteDC(dc); dc = NULL; }
+ if (bmp) { DeleteObject(bmp); bmp = NULL; }
+ bits = nullptr;
+ w = h = 0;
+ if (wantW <= 0 || wantH <= 0) return false;
+ HDC screen = GetDC(NULL);
+ if (!screen) return false;
+ dc = CreateCompatibleDC(screen);
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi.bmiHeader.biWidth = wantW;
+ bi.bmiHeader.biHeight = -wantH; // 负值 = 自上而下
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+ if (dc) bmp = CreateDIBSection(screen, &bi, DIB_RGB_COLORS, &bits, NULL, 0);
+ ReleaseDC(NULL, screen);
+ if (!dc || !bmp || !bits) return false;
+ SelectObject(dc, bmp);
+ w = wantW;
+ h = wantH;
+ return true;
+}
+
+// 释放后备 DIB(窗口销毁时调用)
+void FreeArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h) {
+ if (dc) { DeleteDC(dc); dc = NULL; }
+ if (bmp) { DeleteObject(bmp); bmp = NULL; }
+ bits = nullptr;
+ w = h = 0;
+}
+
diff --git a/src/screenshot/lc_session.cpp b/src/screenshot/lc_session.cpp
new file mode 100644
index 0000000..06476fc
--- /dev/null
+++ b/src/screenshot/lc_session.cpp
@@ -0,0 +1,359 @@
+// 长截图子系统:会话主循环 / 生命周期 / 全局状态。
+// CR-021 拆分自 long_capture_windows.cpp 的「主循环 + 生命周期」段。
+// 本文件定义长截图会话级全局状态(g_longCtx / 控制窗口句柄 / 参数默认值),
+// 手动滚动捕获主循环 RunLongCapture,以及会话初始化 BeginLongCapture、
+// JS 线程中止 LongCaptureAbort 与清理入口 DestroyLongCaptureContext。
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// g_longControlWindow 为侧边小地图面板(完成/取消按钮也在此)。
+
+// g_longCtx 由 BeginLongCapture 创建、WM_LONGCAPTURE_RUN 完成后释放;
+// 指针生命周期由 g_longCtxMutex 保护(JS 线程 abort 与捕获线程清理互斥,
+// 读写协议见 internal.h),其余读取点均在捕获线程内,天然与释放串行。
+
+// 长截图滚动捕获全局状态(独立于编辑态状态机)。
+
+std::atomic g_longCtx(nullptr);
+
+HWND g_longControlWindow = NULL; // 侧边小地图预览面板(滚轮观察的 Raw Input 目标)
+
+HWND g_longToolbarWindow = NULL; // 选区底部工具栏(宽高/方向/自动滚动/裁剪/保存/取消/完成并复制)
+
+HWND g_longMaskWindow = NULL; // 长截图全屏灰色蒙版(半透明 + 点击穿透)
+
+// 长截图参数(初值即默认值;每次 start() 会先重置为默认再按 JS 覆盖,避免跨会话粘滞):
+int g_lcMaxFrames = 100; // 默认最大拼接帧数(1~200,start() 的 longCapture.maxFrames 可配;
+ // 滚动中主动高频采样使帧数消耗更快,默认值随之调高)
+
+int g_lcInterval = 250; // 默认滚轮防抖间隔 ms(50~2000,start() 的 longCapture.interval 可配)
+
+
+// 拼接总像素上限:超过即自动完成,防止超大选区/超多帧下内存与编码雪崩(约 800MB BGRA)
+
+const long long LONG_CAPTURE_MAX_PIXELS = 200000000LL;
+
+// 构造失败结果并经截图会话回调回传 JS(success=false)。
+// 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏。
+
+void LongCaptureEmitFailure() {
+ EmitScreenshotResult(false);
+}
+
+
+// 实况桌面)→ PostMessage 让覆盖层窗口过程执行 RunLongCapture(手动滚动捕获主循环)。
+
+// 覆盖层进入 CS_LongCapturing(黑色遮罩 + 选区框 + 底部工具栏,甜甜圈区域使选区内直通
+
+// 由工具栏「长截图」按钮触发:快照选区 → 构造长截图上下文 → 创建侧边小地图面板 →
+
+void BeginLongCapture(CaptureContext* ctx, HWND overlayHwnd) {
+ LongCaptureContext* lc = new LongCaptureContext();
+ lc->maxFrames = g_lcMaxFrames;
+ lc->interval = g_lcInterval;
+ lc->vx = ctx->virtualX; lc->vy = ctx->virtualY;
+ lc->vw = ctx->virtualW; lc->vh = ctx->virtualH;
+ lc->dpiScale = ctx->dpiScale;
+ lc->selection = ctx->selection;
+
+ // 采样裁剪 = 选区每边内缩(公式收口见 CalcSampleCrop 注释),换算物理像素(与 ExtractRegionResult 同式)
+ lc->cropRect = CalcSampleCrop(ctx);
+ RECT crop = lc->cropRect;
+ int lx = crop.left - ctx->virtualX;
+ int ly = crop.top - ctx->virtualY;
+ double ds = ctx->dpiScale;
+ lc->physX = (int)(lx * ds + 0.5);
+ lc->physY = (int)(ly * ds + 0.5);
+ // 屏幕采样 DIB 尺寸(未转置);帧缓冲尺寸 physW/physH 在横向模式下为其转置
+ //(进入时恒为纵向,方向切换经工具栏的 LongCaptureSwitchDirection 处理)
+ lc->capW = (int)((crop.right - crop.left) * ds + 0.5);
+ lc->capH = (int)((crop.bottom - crop.top) * ds + 0.5);
+ if (lc->capW < 1 || lc->capH < 1) { delete lc; return; } // 选区过小
+ lc->physW = lc->horizontal ? lc->capH : lc->capW;
+ lc->physH = lc->horizontal ? lc->capW : lc->capH;
+
+ // 虚拟屏幕物理原点(屏幕 DC 坐标):直接区域采样时作为 BitBlt 源偏移
+ MonitorEnumData med = { INT_MAX, INT_MAX, INT_MIN, INT_MIN, 1.0, 0 };
+ EnumDisplayMonitors(NULL, NULL, MonitorEnumProc, reinterpret_cast(&med));
+ lc->physOriginX = med.minLeft;
+ lc->physOriginY = med.minTop;
+ if (med.monitorCount == 0) {
+ lc->physOriginX = (int)(lc->vx * ds);
+ lc->physOriginY = (int)(lc->vy * ds);
+ }
+ // 面板两级缩略图固定列宽 = 面板预览内宽(物理像素),不超过选区宽度
+ int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * ds + 0.5);
+ lc->thumbW = (std::min)((std::max)(1, previewPx), lc->physW);
+
+ g_longCtx.store(lc);
+
+ // 覆盖层进入长截图态:隐藏原覆盖层(甜甜圈区域设计不再使用),
+ // 由独立全屏灰蒙版接管显示与输入(半透明、点击穿透)。
+ ctx->state = CS_LongCapturing;
+ ShowWindow(overlayHwnd, SW_HIDE);
+ EnterLongCaptureMask(ctx);
+
+ // 侧边小地图面板(只读预览)+ 选区底部工具栏(方向/自动滚动/裁剪/保存/取消/完成)。
+ // 后创建保证两者位于灰蒙版之上(蒙版点击穿透不影响其交互)。
+ g_longControlWindow = LongCaptureCreatePanel(ctx, lc);
+ g_longToolbarWindow = LongCaptureCreateToolbar(ctx, lc);
+
+ // 由覆盖层窗口过程执行手动滚动捕获主循环(结束后统一清理会话)
+ PostMessage(overlayHwnd, WM_LONGCAPTURE_RUN, 0, 0);
+}
+
+
+// 有界等待(采样重试间隔用):持续泵消息保持面板/工具栏绘制与按钮响应,
+// 超时或用户中止/完成/保存信号到达即返回,绝不无限阻塞。
+
+void LongCaptureWaitMessages(LongCaptureContext* c, DWORD ms) {
+ DWORD until = GetTickCount() + ms;
+ while (GetTickCount() < until) {
+ if (c->abortFlag.load() || c->finishFlag.load() || c->saveFlag.load()) return;
+ LongCapturePumpMessages(c);
+ if (c->abortFlag.load() || c->finishFlag.load() || c->saveFlag.load()) return;
+ MsgWaitForMultipleObjectsEx(0, NULL, 15, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
+ }
+}
+
+
+// 返回 true 表示成功产出(已发回调),false 表示失败/取消(已发回调)。
+
+// 滚轮停稳后采样一视口帧 → 识别/校验/提交(LongCaptureTryStitch)→ 面板实时刷新。
+
+// 手动滚动捕获主循环:安装滚轮观察钩子 → 泵消息(面板/按钮响应)→
+
+bool RunLongCapture(LongCaptureContext* c) {
+ // 第 0 帧:进入长截图时选区内的当前内容即首屏(既作主体段也作重叠检测基准)
+ std::vector frameBuf; // 单帧读取缓冲(跨迭代复用,避免每帧分配)
+ if (!LongCaptureInitFirstFrame(c, frameBuf)) { LongCaptureEmitFailure(); return false; }
+ c->frameCount.store(1);
+ c->lastSampleTick = GetTickCount(); // 滚动中主动采样节拍从首帧起算
+ LongCapturePanelUpdate(c);
+ LongCaptureToolbarRepaint(); // 初始宽×高标签
+
+ // 注册滚轮观察(Raw Input INPUTSINK → 面板 WM_INPUT):滚动照常送达选区下的目标窗口,
+ // 这里只被动接收广播解析滚轮方向与时机;不安装 WH_MOUSE_LL 之类的全局同步钩子,
+ // 避免钩子回调依赖本线程泵消息而拖慢全系统鼠标输入。
+ if (!LongCaptureRegisterWheelObserver(g_longControlWindow)) {
+ LongCaptureEmitFailure();
+ return false;
+ }
+
+ bool savedToFile = false; // 「保存到本地」已成功落盘(回调成功但不进剪贴板)
+
+ while (true) {
+ if (c->abortFlag.load()) break;
+ if (c->finishFlag.load()) break;
+ if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) { c->abortFlag.store(true); break; }
+
+ // 保存到本地:主循环内处理(取消保存对话框则回到捕获继续)。确认路径时单次
+ // 编码同时产出 PNG 文件与 base64,直接走成功回调(不进剪贴板)。
+ if (c->saveFlag.load()) {
+ c->saveFlag.store(false);
+ LongCaptureSetAutoScroll(c, false); // 模态对话框期间不得继续注入滚轮
+ LongCaptureSetMenu(c, LCM_None); // 收起展开中的二级菜单
+ LongCaptureSetTopmost(false); // 系统保存对话框需要真正置顶
+ std::wstring path = PromptSaveFilePath(NULL);
+ LongCaptureSetTopmost(true);
+ if (!path.empty()) {
+ HBITMAP bmp = LongCaptureBuildFinalBitmap(c);
+ if (bmp) {
+ std::string b64;
+ bool ok = EncodeHBitmapPng(bmp, &b64, nullptr, path.c_str());
+ DeleteObject(bmp);
+ if (ok) {
+ c->base64 = b64;
+ savedToFile = true;
+ break;
+ }
+ }
+ }
+ }
+
+ // 泵消息:滚轮观察(面板 WM_INPUT)、面板绘制与按钮点击均依赖本线程消息循环
+ LongCapturePumpMessages(c);
+ if (c->abortFlag.load()) break;
+
+ // 触发一次采样轮(三条件任一满足;采样 = 抓取 → 识别 → 校验 →(Weak 档延迟
+ // 确认)→(全部通过才)提交,见 LongCaptureTryStitch):
+ // · 停稳采样:滚轮 interval 内无新事件——捕获滚动尾段,消费 wheelPending;
+ // · 滚动中主动采样:滚动尚未停稳、但距上次采样已达节拍(min(interval,
+ // LC_SCROLL_SAMPLE_MAX_GAP))即不等停稳主动抓帧拼接。相邻帧位移因此足够
+ // 小、overlap 稳定落在 Normal 档,重叠判定成功率从源头提高;不消费
+ // wheelPending,停稳后仍补一次收尾采样;
+ // · Weak 待复核:存在 pendingMatch 时即使没有新滚轮也继续采样(大跳变后的
+ // 平滑滚动/懒加载往往要跨多次采样才能稳定复现候选,由 weakTries 预算
+ // 保证该自主采样有界)。
+ // 轮内重试(语义 =「等待页面稳定」,绝不放宽匹配条件):
+ // · 稳定性闸门:抓帧后先做轻量稳定性检测(TryStitch ⓪),页面仍在快速变化
+ // 时本帧不进入正式匹配,按 LC_STABLE_RETRY_DELAY(30~60ms)短延迟重新采样,
+ // 预算 LC_STABLE_MAX_WAITS 耗尽后闸门关闭、强制进入正式匹配——手动连续
+ // 滚动下的中滚采样节拍不受阻塞;自动滚动开启时持续注入使页面恒处微滚动
+ // 状态,闸门注定不放行、只会白耗每轮重试预算(最终仍是被强制匹配),直接跳过;
+ // · 瞬态快重采样:Normal 失败且分类为瞬态(无候选/全宽不可评,多为抓在滚动/
+ // 重绘过渡帧)时先走 LC_RESAMPLE_DELAY_QUICK(40~60ms)短延迟重采样,
+ // 预算独立、不提交、不改任何累计状态、不降低任何阈值;
+ // · 常规重试:Normal 失败/内容未变按 LC_SAMPLE_ATTEMPTS 次预算、间隔 90~210ms
+ // 递增等待渲染稳定;Weak 候选走独立 6 次重试 + 更长间隔。
+ // 重试耗尽仍硬失败 → 本轮放弃:不拼接、不污染任何累计状态、也绝不自动完成——
+ // 保留已拼接的有效内容,等用户继续滚动(后续采样仍从干净基准恢复)或手动结束。
+ // 采样频次由 interval 与滚动节拍共同限频(默认 ~4 次/秒),每次成功拼接立即刷新面板。
+ DWORD now = GetTickCount();
+ DWORD scrollGap = c->interval < LC_SCROLL_SAMPLE_MAX_GAP
+ ? (DWORD)c->interval : (DWORD)LC_SCROLL_SAMPLE_MAX_GAP;
+ bool settleSample = c->wheelPending && now - c->lastWheelTick >= (DWORD)c->interval;
+ bool midScrollSample = c->wheelPending && !settleSample
+ && now - c->lastSampleTick >= scrollGap;
+ if (settleSample || midScrollSample || c->pendingMatch.valid) {
+ if (settleSample) c->wheelPending = false;
+ int trackRevBefore = c->trackingRevision; // 本轮 tentative 是否变化的观察点
+ LCSampleOutcome oc = LCSampleOutcome::Failed;
+ int normalTries = 0, weakRetries = 0;
+ int stableWaits = 0, quickResamples = 0; // 稳定性等待 / 瞬态快重采样预算
+ while (true) {
+ if (c->abortFlag.load() || c->finishFlag.load()) break;
+ if (LongCaptureCaptureFrameBuf(c, frameBuf))
+ // 稳定性闸门在等待预算内启用(预算耗尽后强制进入正式匹配,
+ // 保证中滚采样节拍不被阻塞);自动滚动持续注入时页面恒处微滚动,
+ // 闸门必然不放行,直接跳过以免白耗重试预算
+ oc = LongCaptureTryStitch(c, frameBuf, c->lastDir,
+ !c->autoScroll && stableWaits < LC_STABLE_MAX_WAITS);
+ // 已提交(新增行)或已重定位(回滚到已捕获范围内):本次采样即告结束,
+ // 重试只会对着新基准匹配到相同内容,没有意义
+ if (oc == LCSampleOutcome::Stitched
+ || oc == LCSampleOutcome::Repositioned) break;
+ if (oc == LCSampleOutcome::WeakPending || oc == LCSampleOutcome::WeakRejected) {
+ // Weak 候选:延迟确认需要更多次稳定采样,间隔走 weak 档
+ if (weakRetries >= LC_WEAK_RETRY_ATTEMPTS) break;
+ LongCaptureWaitMessages(c, LC_RETRY_DELAY_WEAK[weakRetries++]);
+ } else if (oc == LCSampleOutcome::Unstable) {
+ // 稳定性未过:短延迟后重新采样(等待页面稳定),预算独立于
+ // Normal/Weak 重试梯,也不消耗它们
+ if (stableWaits >= LC_STABLE_MAX_WAITS) break;
+ LongCaptureWaitMessages(c, LC_STABLE_RETRY_DELAY[stableWaits++]);
+ } else if (oc == LCSampleOutcome::Failed && quickResamples < LC_QUICK_RESAMPLES
+ && (c->lastFailReason == LCFailReason::NoCandidate
+ || c->lastFailReason == LCFailReason::GlobalMismatch)) {
+ // 瞬态失败(无候选/全宽不可评——多为抓在滚动/重绘过渡帧):几十毫秒级
+ // 快重采样等页面稳定,而不是放宽匹配条件;不消耗主重试预算
+ LongCaptureWaitMessages(c, LC_RESAMPLE_DELAY_QUICK[quickResamples++]);
+ } else {
+ // Normal 失败或内容未变:等待渲染稳定后重试(现有采样预算)
+ if (normalTries >= LC_SAMPLE_ATTEMPTS - 1) break;
+ LongCaptureWaitMessages(c, LC_RETRY_DELAY_NORMAL[normalTries++]);
+ }
+ }
+ c->lastSampleTick = GetTickCount(); // 滚动中主动采样节拍以采样轮结束时刻起算
+ if (oc == LCSampleOutcome::Stitched) {
+ c->noChangeCount = 0;
+ c->weakTries = 0;
+ c->frameCount.fetch_add(1);
+ LongCapturePanelUpdate(c);
+ LongCaptureToolbarRepaint(); // 宽×高标签刷新
+ if (c->frameCount.load() >= c->maxFrames) break; // 帧数上限:自动完成
+ if ((long long)c->physW * c->stitchH >= LONG_CAPTURE_MAX_PIXELS)
+ break; // 内存上限:自动完成
+ } else if (oc == LCSampleOutcome::Repositioned) {
+ // 反向回滚未越出已捕获边界:视口基准已确认推进(小地图当前区域标注随
+ // committedContentTop 移动),无新增行——不计帧数、不重算面板尺寸;
+ // 面板重绘由下方 trackingRevision 变化判定触发(CommitStitch 内自增)。
+ c->noChangeCount = 0;
+ c->weakTries = 0;
+ } else if (oc == LCSampleOutcome::NoChange) {
+ // 内容未变化(匹配成功且 d=0):与“匹配失败”完全独立的事件。
+ // 连续多次未变化才确认滚动到底;匹配失败绝不计入该计数。
+ c->weakTries = 0;
+ if (++c->noChangeCount >= LC_BOTTOM_CONFIRM_SAMPLES) c->reachedBottom = true;
+ } else if (oc == LCSampleOutcome::WeakPending || oc == LCSampleOutcome::WeakRejected) {
+ // 「发现了候选,但 Weak 验证/复核不足」:与“完全没有候选”是两种失败,
+ // 不计入任何终止条件;独立预算(LC_WEAK_MAX_TRIES 轮)耗尽即放弃当前
+ // 候选链,从干净基准重新观察。
+ c->noChangeCount = 0;
+ if (++c->weakTries >= LC_WEAK_MAX_TRIES) {
+ c->weakTries = 0;
+ c->pendingMatch.valid = false; // 放弃当前候选链,从干净基准重新观察
+ c->weakCandidateOffsets.clear(); // 候选链作废:时间一致性样本一并过期
+ }
+ } else {
+ // 硬失败(重试耗尽仍无可信对齐):不拼接、不污染基准、也绝不自动完成
+ // ——会话由用户经「完成/取消/ESC」结束,基准未被污染,后续采样仍可
+ // 从当前基准继续拼接。
+ c->noChangeCount = 0;
+ }
+ // 自动滚动的自动停止:确认滚动到底,或连续多次硬失败(内容可能已滚出可
+ // 匹配范围,继续注入滚轮只会丢内容)时停止注入——会话保留,由用户处置。
+ if (c->autoScroll) {
+ if (oc == LCSampleOutcome::Failed) {
+ if (++c->autoFailStreak >= LC_AUTOSCROLL_STOP_FAILS)
+ LongCaptureSetAutoScroll(c, false);
+ } else if (oc != LCSampleOutcome::Unstable && oc != LCSampleOutcome::WeakPending) {
+ c->autoFailStreak = 0;
+ }
+ if (c->reachedBottom) LongCaptureSetAutoScroll(c, false);
+ }
+ // tentative 跟踪状态在本轮发生变化(多跳恢复/预测推进/候选否决回退):
+ // 小地图按「预计位置」刷新——正式拼接未变,不触发面板尺寸重算
+ //(LongCapturePanelUpdate 仍只在 Stitched 后调用)。
+ if (c->trackingRevision != trackRevBefore && g_longControlWindow)
+ InvalidateRect(g_longControlWindow, NULL, FALSE);
+ }
+ // 等待消息到达或 15ms 超时:有滚轮/绘制/点击消息立即唤醒(替代固定 Sleep,
+ // 防抖计时与面板重绘不被固定睡眠拖延)。
+ MsgWaitForMultipleObjectsEx(0, NULL, 15, QS_ALLINPUT, MWMO_INPUTAVAILABLE);
+ }
+
+ LongCaptureUnregisterWheelObserver();
+
+ if (c->abortFlag.load()) { LongCaptureEmitFailure(); return false; }
+
+ // 拼接 + 输出(复用截图输出管线):
+ // · 完成并复制:拼接结果缩放回逻辑尺寸 → base64 + 剪贴板;
+ // · 保存到本地(savedToFile):文件已落盘、base64 已生成,直接回调成功(不进剪贴板)。
+ bool ok = false;
+ if (savedToFile) {
+ ok = true;
+ } else {
+ HBITMAP finalBmp = LongCaptureBuildFinalBitmap(c);
+ if (finalBmp) {
+ c->base64 = BitmapToBase64Png(finalBmp);
+ ok = SaveBitmapToClipboard(finalBmp);
+ DeleteObject(finalBmp);
+ }
+ }
+ c->success = ok;
+
+ // 回调(统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏;
+ // 原代码先 new 后判 TSFN 且不查 nonblocking 返回值,TSFN 为空或队列满时 result 泄漏
+ // —— 此处一并修复)。
+ if (ok) {
+ EmitScreenshotResult(true, c->cropRect.left, c->cropRect.top,
+ c->cropRect.right, c->cropRect.bottom, c->outWidth, c->outHeight, c->base64);
+ } else {
+ EmitScreenshotResult(false);
+ }
+ return ok;
+}
+
+
+// JS 线程主动中止进行中的长截图。
+
+void LongCaptureAbort() {
+ // g_longCtxMutex 保证「load 到指针 → 写 abortFlag」期间对象不被捕获线程
+ // 的清理段(WM_LONGCAPTURE_RUN 内 store(nullptr)+delete)释放:两端都以该锁
+ // 为边界,读到非空即意味着 delete 尚未发生。锁内只有原子读写,无消息调用。
+ std::lock_guard lock(g_longCtxMutex);
+ LongCaptureContext* c = g_longCtx.load();
+ if (c) c->abortFlag.store(true);
+}
+
+
+// 释放长截图上下文的采样 DIB 段(dibDC/dibBmp/dibBits)。
+// CR-021:原 wndproc_windows.cpp 的 WM_LONGCAPTURE_RUN 清理段手动释放 lc->dibDC/dibBmp
+// 的跨界所有权收进此处统一释放——调用方(wndproc 清理段)改为调用本函数,
+// 释放语义、顺序、守卫(if (lc->dibDC) DeleteDC ...)与原实现逐字一致。
+void DestroyLongCaptureContext(LongCaptureContext* lc) {
+ if (!lc) return;
+ if (lc->dibDC) DeleteDC(lc->dibDC);
+ if (lc->dibBmp) DeleteObject(lc->dibBmp);
+}
diff --git a/src/screenshot/lc_stitch_state.cpp b/src/screenshot/lc_stitch_state.cpp
new file mode 100644
index 0000000..b2beb70
--- /dev/null
+++ b/src/screenshot/lc_stitch_state.cpp
@@ -0,0 +1,772 @@
+// 长截图子系统:可写累计状态层(提交 / 跟踪 / 历史 / Weak 候选 / 裁剪 / 稳定性 / 管线)。
+// CR-021 拆分自 long_capture_windows.cpp 的「Tentative 跟踪 / 提交阶段 / 单帧管线」段。
+// 本文件是唯一允许修改累计拼接状态(body/headRev/stitchH/lastFrame/lastMatch/offsetHistory)
+// 的位置;LongCaptureTryStitch 是单帧「识别→校验→提交」管线入口。
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// 可条件编译的调试日志(构建加 /DLC_DEBUG_LOG 启用,经 OutputDebugStringA 输出到调试器):
+// 输出采样管线的关键决策标签与全部评分明细(模式/offset/overlap/各 ROI 候选与权重/
+// overall/seam/top-middle-bottom/连续段/profile/edge/纹理占比/峰值分离度/综合置信度/
+// pending 状态/失败原因分类/稳定性闸门/动态屏蔽行数/basin 择优),供真实数据驱动的
+// 阈值调优;默认编译为零开销空函数。
+#ifdef LC_DEBUG_LOG
+#include
+#include
+static void LC_LOG(const char* fmt, ...) {
+ char buf[512];
+ va_list args;
+ va_start(args, fmt);
+ vsnprintf(buf, sizeof(buf), fmt, args);
+ va_end(args);
+ OutputDebugStringA(buf);
+ OutputDebugStringA("\n");
+}
+#else
+static inline void LC_LOG(const char*, ...) {}
+#endif
+
+// 由上下文的滚轮累计增量与 px/notch 估计构造位移先验;不可用时退回成功位移中位数。
+LongCaptureOffsetPrior LongCaptureBuildOffsetPrior(const LongCaptureContext* c) {
+ LongCaptureOffsetPrior p;
+ if (c->pixelsPerWheelNotch > 1.0f && c->wheelAccumDelta != 0) {
+ int notches = (c->wheelAccumDelta > 0 ? c->wheelAccumDelta : -c->wheelAccumDelta) / WHEEL_DELTA;
+ if (notches >= 1) {
+ p.expectedAbsOffset = (int)(notches * c->pixelsPerWheelNotch + 0.5f);
+ p.valid = p.expectedAbsOffset > 0;
+ }
+ }
+ if (!p.valid && !c->offsetHistory.empty()) {
+ std::vector hist = c->offsetHistory;
+ std::sort(hist.begin(), hist.end());
+ p.expectedAbsOffset = hist[hist.size() / 2];
+ p.valid = p.expectedAbsOffset > 0;
+ }
+ return p;
+}
+
+// —— Weak 时间一致性(短时序列共识)——
+// 维护最近若干次弱重叠候选位移(直连 lastFrame 的参考系,与 pendingMatch 流一致),
+// 多个相邻采样持续指向同一 offset 区间时给弱档置信度加分。铁律:只加分——
+// · 所有空间硬门槛(overall/seam/edge/spatial/continuity/profile/峰值分离度)
+// 与动态置信度门槛数值原样不动;
+// · 任何时刻都不会因为「历史上一直是这个值」绕过 DetectPass 验收直接提交。
+
+// 登记一次弱重叠候选位移(LOW_CONFIDENCE 弱候选 / WeakPending 首次候选均计入),
+// 环形容量 LC_WEAK_TEMPORAL_MAX_HISTORY;提交成功或候选链作废时整体清空。
+void LongCapturePushWeakCandidate(LongCaptureContext* c, int offset) {
+ if (offset == 0) return;
+ c->weakCandidateOffsets.push_back(offset);
+ if ((int)c->weakCandidateOffsets.size() > LC_WEAK_TEMPORAL_MAX_HISTORY)
+ c->weakCandidateOffsets.erase(c->weakCandidateOffsets.begin());
+}
+
+// 由最近弱候选序列构造时间一致性上下文:同号且与中位数偏差 ≤ LC_WEAK_TEMPORAL_TOL
+// 的样本达 LC_WEAK_TEMPORAL_MIN_SAMPLES 个即共识成立;共识强度随样本数增长封顶。
+LCWeakTemporal LongCaptureWeakTemporalContext(const LongCaptureContext* c) {
+ LCWeakTemporal wt;
+ if ((int)c->weakCandidateOffsets.size() < LC_WEAK_TEMPORAL_MIN_SAMPLES) return wt;
+ std::vector s = c->weakCandidateOffsets;
+ std::sort(s.begin(), s.end());
+ int med = s[s.size() / 2];
+ int n = 0;
+ for (int off : s) {
+ int dd = off > med ? off - med : med - off;
+ if (dd <= LC_WEAK_TEMPORAL_TOL && (off > 0) == (med > 0)) n++;
+ }
+ if (n < LC_WEAK_TEMPORAL_MIN_SAMPLES) return wt;
+ wt.active = true;
+ wt.refOffset = med;
+ float ratio = (std::min)(1.0f, (float)n / 4.0f);
+ wt.bonus = LC_WEAK_TEMPORAL_BONUS_MAX * ratio;
+ return wt;
+}
+
+// ==================== Tentative 跟踪 / 最近帧历史 / 多跳恢复(外围状态层) ====================
+// 本段全部为「视觉跟踪」基础设施,与正式拼接状态(body/headRev/stitchH/lastFrame/
+// lastMatch/offsetHistory)完全隔离:FAILED / LOW_CONFIDENCE 帧只允许触碰这里的
+// 跟踪字段与帧历史。正式拼接仍然只有 DetectPass SUCCESS → LongCaptureCommitStitch
+// 一条路;多跳恢复产出的提交同样必须先通过与 lastFrame 的单点富验证 + 历史合理性
+//(见 LongCaptureTryStitch 失败分流处的四道关卡),绝不放宽任何门槛。
+
+// tentative 相对 committed 的最大漂移(内容坐标 px,2×视口高):超过即钳制,
+
+// 防止纯预测链在连续失败下无限累加误差。正常一次失败恢复窗口内的漂移远小于该值。
+static int64_t LongCaptureTrackingDriftLimit(const LongCaptureContext* c) {
+ return (int64_t)c->physH * 2;
+}
+
+// 以「视觉验证依据」直接设定 tentative 绝对位置(来源:多跳匹配 / 零位移对齐 /
+// 与基准帧逐像素全同)。视觉依据可解除冻结并重置不可靠计数;漂移上限仍生效
+//(防御性钳制,正常视觉链不会触达)。只改跟踪字段,绝不触碰拼接状态。
+void LongCaptureTrackingSetVisual(LongCaptureContext* c, int64_t contentY, float confidence) {
+ int64_t limit = LongCaptureTrackingDriftLimit(c);
+ if (contentY < c->committedContentTop - limit) contentY = c->committedContentTop - limit;
+ if (contentY > c->committedContentTop + limit) contentY = c->committedContentTop + limit;
+ c->tentativeContentTop = contentY;
+ c->tentativeValid = true;
+ c->tentativeConfidence = confidence;
+ c->trackUnreliableStreak = 0; // 重新获得视觉依据
+ c->trackingRevision++;
+}
+
+// 丢失跟踪(候选链被否决等):tentative 回归 committed 基准并计一次不可靠样本。
+// 不清除 tentativeValid——位置退回已确认处,后续采样可从干净基准重新建立预计。
+void LongCaptureTrackingResetToCommitted(LongCaptureContext* c) {
+ c->tentativeContentTop = c->committedContentTop;
+ c->trackUnreliableStreak++;
+ c->trackingRevision++;
+}
+
+// 纯预测推进(无视觉依据):冻结状态(连续 LC_TRACK_FREEZE_FRAMES 帧无视觉依据)
+// 不再推进;预测位移必须在历史位移合理范围内(有历史样本时 = 中位数×跳变比与
+// 视口高一半的较大者),明显违反则只计不可靠样本、位置不动——冻结而不是继续漂移。
+void LongCaptureTrackingAdvancePredicted(LongCaptureContext* c, int direction, double magnitude) {
+ if (!c->tentativeValid || c->trackUnreliableStreak >= LC_TRACK_FREEZE_FRAMES) return;
+ if (direction == 0 || magnitude < (double)LC_TRACK_MIN_STEP) return;
+ double cap = (double)c->physH * 0.5;
+ if ((int)c->offsetHistory.size() >= LC_OFFSET_HISTORY_MIN) {
+ std::vector hist = c->offsetHistory;
+ std::sort(hist.begin(), hist.end());
+ cap = (std::max)(cap, (double)hist[hist.size() / 2] * LC_OFFSET_JUMP_RATIO);
+ }
+ c->trackUnreliableStreak++; // 预测帧本身无视觉依据
+ if (magnitude > cap) return; // 违反历史位移范围:冻结(streak 已累计)
+ int64_t next = c->tentativeContentTop + (int64_t)(direction * magnitude);
+ int64_t limit = LongCaptureTrackingDriftLimit(c);
+ if (next > c->committedContentTop + limit) next = c->committedContentTop + limit;
+ if (next < c->committedContentTop - limit) next = c->committedContentTop - limit;
+ c->tentativeContentTop = next;
+ c->tentativeConfidence *= LC_TRACK_CONFIDENCE_DECAY;
+ c->trackingRevision++;
+}
+
+// 失败帧的 TrackingEstimate(注意:TrackingEstimate != StitchMatch):只服务 tentative
+// 跟踪与下一帧先验,绝不进入 LongCaptureCommitStitch。方向 = 最近滚轮方向;
+// 幅度优先取最近成功视觉位移中位数(滚动节奏稳定),无历史样本时退回位移先验。
+
+LongCaptureTrackingEstimate LongCaptureBuildTrackingEstimate(const LongCaptureContext* c, int dir,
+ const LongCaptureOffsetPrior& prior) {
+ LongCaptureTrackingEstimate e;
+ e.direction = dir;
+ if (!c->offsetHistory.empty()) {
+ std::vector hist = c->offsetHistory;
+ std::sort(hist.begin(), hist.end());
+ e.predictedOffset = hist[hist.size() / 2];
+ } else if (prior.valid) {
+ e.predictedOffset = prior.expectedAbsOffset;
+ }
+ e.confidence = LC_TRACK_PREDICT_CONFIDENCE;
+ e.valid = e.direction != 0 && e.predictedOffset >= (double)LC_TRACK_MIN_STEP;
+ return e;
+}
+
+// 压入一条最近帧历史(环形容量 LC_HISTORY_FRAMES,超出丢最旧):match 为该帧完整
+// 匹配数据(不保存位图);contentY 为视口顶内容坐标(已提交=精确,未提交=tentative
+// 估计);detailSum 过低的近空白帧标记 validForMatching=false,多跳回溯直接跳过。
+void LongCaptureHistoryPush(LongCaptureContext* c, int frameId, LongMatchData&& match,
+ int64_t contentY, bool committed) {
+ LongCaptureFrameHistory e;
+ e.frameId = frameId;
+ e.match = std::move(match);
+ e.contentY = contentY;
+ e.committed = committed;
+ e.validForMatching = e.match.detailSum >= 16;
+ c->frameHistory.push_back(std::move(e));
+ if ((int)c->frameHistory.size() > LC_HISTORY_FRAMES)
+ c->frameHistory.erase(c->frameHistory.begin());
+}
+
+// 多跳匹配恢复:当前帧与 lastFrame 直连失败后,回溯最近历史帧重建已验证的位移
+// 关系——某一次失败不再直接切断跟踪链。基准优先级区分「可靠匹配基准」与「失败帧
+// 历史」:先回溯已提交帧(位置精确、未被任何失败路径污染),再退回未提交帧(位置
+// 为 tentative 估计)——失败帧仍可参与多跳回溯/tentative 跟踪,但绝不因「最新」就
+// 优先于可靠基准,质量差的帧不能成为后续多帧匹配的污染源。总跳数预算不变
+//(LC_HISTORY_HOPS,两段共享)。全部复用现有 LongCaptureDetectMatch 验收,不放宽
+// 任何门槛;产出的 offset 只用于:
+// · tentative 跟踪推进(视觉依据,可解冻);
+// · 当且仅当基准为「已提交帧」且 Normal 档强证据时,在 TryStitch 中推导相对
+// lastFrame 的提交位移(四道关卡全过才提交,见调用处)。
+// 绝不把中间失败帧插入正式拼接(未提交基准的链只能推进跟踪,数学上无法构造
+// 未经验证的提交位移)。
+static LCMultihopResult LongCaptureMultihopDetect(const LongCaptureContext* c,
+ const LongMatchData& currMatch, int dir,
+ const LongCaptureOffsetPrior& prior) {
+ LCMultihopResult r;
+ if (currMatch.detailSum < 16) return r; // 近空白帧:无对齐依据,跳过多跳
+ int tried = 0;
+ for (int pass = 0; pass < 2 && !r.matched; pass++) {
+ bool wantCommitted = pass == 0; // 第一段:已提交帧;第二段:未提交帧
+ for (size_t i = c->frameHistory.size(); i-- > 0; ) {
+ const LongCaptureFrameHistory& h = c->frameHistory[i];
+ if (!h.validForMatching || h.committed != wantCommitted) continue;
+ if (h.committed && h.frameId == c->lastCommittedFrameId)
+ continue; // hop1(lastFrame 直连)已在主路径尝试并失败
+ if (++tried > LC_HISTORY_HOPS) return r;
+ LongMatchOutcome m = LongCaptureDetectMatch(h.match, currMatch, dir, prior,
+ LCWeakTemporal(), h.frameId);
+ if (m.status != LC_MATCH_SUCCESS) continue; // 该基准失败:继续回溯更早帧
+ r.matched = true;
+ r.hopCommitted = h.committed;
+ r.mode = m.mode;
+ r.offset = m.offset;
+ r.confidence = m.confidence;
+ r.hopContentY = h.contentY;
+ r.hopFrameId = h.frameId;
+ return r; // 由近及远,首个成功即止
+ }
+ }
+ return r;
+}
+
+// 成功提交后的外围状态收尾(调用点:Normal 提交 / Weak 延迟确认提交 / 多跳推导提交,
+// 均在 LongCaptureCommitStitch 之后):登记已提交历史条目(匹配数据取提交后的
+// lastMatch 拷贝,位置精确)、标记最新已提交帧序号、清空弱候选簇(新基准下旧候选失效)。
+void LongCaptureAfterCommit(LongCaptureContext* c, int frameId) {
+ c->lastCommittedFrameId = frameId;
+ LongCaptureHistoryPush(c, frameId, LongMatchData(c->lastMatch), c->committedContentTop, true);
+ c->weakCandidateOffsets.clear();
+}
+
+// —— offset 合理性(历史趋势)校验:异常跳变直接拒绝 ——
+// 为什么必须 reject 而不是“放行让流程继续”:一次误识别的极端 offset 一旦被采纳,
+// 会同时污染累计画布、lastFrame 匹配基准与 offset 历史,后续所有帧都基于错误基准
+// 匹配,错误滚雪球直至整张长图错位。拒绝时绝不更新任何状态,让下一帧以未受污染的
+// 基准重新尝试。滚动节奏正常时相邻采样的位移量级稳定;Normal 档维持
+// 「历史成功位移中位数 × LC_OFFSET_JUMP_RATIO」上限,防止误杀之外也防误放。
+// Weak 档把跳变上限放宽为 max(历史上限, 视口高×LC_WEAK_OVERLAP_RATIO_LIMIT):
+// 合法的大滚动(最近都滚 120px、用户突然一次 850px)不应被历史中位数误杀;
+// 该放宽只对已通过 3/3 ROI + 边缘结构 + 动态高置信度的 Weak 强证据候选开放
+//(调用方仅在 DetectPass(weak) SUCCESS 之后才以 WeakOverlap 模式调用),绝不无限放宽。
+// 返回 true = 合理(允许提交);false = 异常(本帧按失败处理,不更新历史)。
+
+bool LongCaptureOffsetPlausible(const LongCaptureContext* c, int d,
+ LongCaptureMatchMode mode) {
+ int ad = d > 0 ? d : -d;
+ int h = c->physH;
+ int minOv = mode == LongCaptureMatchMode::WeakOverlap
+ ? (std::max)(LC_WEAK_MIN_OVERLAP, (int)(h * LC_WEAK_MIN_OVERLAP_RATIO))
+ : (std::max)(LC_MIN_OVERLAP, (int)(h * LC_MIN_OVERLAP_RATIO));
+ if (ad > h - minOv) return false; // 重叠不足(防御:扫描范围理论上已排除)
+ if ((int)c->offsetHistory.size() >= LC_OFFSET_HISTORY_MIN) {
+ std::vector hist = c->offsetHistory;
+ std::sort(hist.begin(), hist.end());
+ int median = hist[hist.size() / 2];
+ int jumpLimit = (int)(median * LC_OFFSET_JUMP_RATIO);
+ if (mode == LongCaptureMatchMode::WeakOverlap)
+ jumpLimit = (std::max)(jumpLimit, (int)(h * LC_WEAK_OVERLAP_RATIO_LIMIT));
+ if (ad > jumpLimit) return false; // 相对历史异常跳变
+ }
+ return true;
+}
+
+// 无可信对齐(跳变/懒加载):不拼接、走重试;失败绝不自动完成,何时结束由用户决定。
+
+// (头部段以倒序存储,拼接恒为 O(新增行),向上滚动不再整段搬移大缓冲);
+
+// 向上滚:curr 底部与 lastFrame 顶部重叠 → 前插 curr 去重后的顶部行到 headRev 段
+
+// 向下滚:curr 顶部与 lastFrame 底部重叠 → 追加 curr 去重后的底部行到 body 段;
+
+// 将一次采样的视口帧增量拼接(帧间模糊对齐去相邻帧重叠行),原始段与面板缩略图段同步更新:
+
+// ==================== 提交阶段(唯一允许修改累计拼接状态的位置) ====================
+
+// ==================== 裁剪:内容坐标窗口 / 待剔除区间的延迟执行 ====================
+
+// 是否存在任何生效的裁剪约束(窗口锚点或待剔除区间);cropped 标志据此维护。
+bool LongCaptureHasCropConstraint(const LongCaptureContext* c) {
+ return c->cropTopY != INT64_MIN || c->cropBottomY != INT64_MAX
+ || c->cropPendTop || c->cropPendBottom;
+}
+
+// 物理删除拼接图显示行区间 [r0, r1)(可横跨头部段与主体段),缩略图段同步删除。
+// 仅由「待剔除裁剪区间」触发时调用:删除的内容区间与登记时刻完全一致(登记/执行都
+// 用内容坐标,头部前插不改变两侧取值),因此 committedContentTop/tentative/
+// frameHistory 等按内容坐标记账的字段全部无需平移——被删的只是缓冲里的行,坐标系
+// 本身不动。帧历史条目刻意保留:匹配数据始终与真实屏幕对齐,用户折返被裁区域时可
+// 作多跳基准把该区域重新拼回(后续内容是否越过旧裁剪线由输出窗口决定,缓冲完整性
+// 不再构成约束)。
+void LongCaptureEraseDisplayRows(LongCaptureContext* c, int64_t r0, int64_t r1) {
+ if (!c || r1 <= r0) return;
+ if (r0 < 0) r0 = 0;
+ if (r1 > c->stitchH) r1 = c->stitchH;
+ if (r1 <= r0) return;
+ const size_t rowW = (size_t)c->physW;
+ const size_t thumbRowW = (size_t)(std::max)(1, c->thumbW);
+ int64_t hr = c->headRows; // 两段均按删除前的 headRows 映射显示行 ↔ 数组下标
+ // 主体段部分(显示行 [max(r0,hr), r1))。先处理主体:其数组位置与头部删除无关。
+ {
+ int64_t b0 = (std::max)(r0, hr), b1 = r1;
+ if (b1 > b0) {
+ int64_t n = b1 - b0;
+ size_t off = (size_t)(b0 - hr) * rowW;
+ size_t len = (size_t)n * rowW;
+ size_t end = (std::min)(c->body.size(), off + len);
+ if (off < c->body.size())
+ c->body.erase(c->body.begin() + (std::ptrdiff_t)off, c->body.begin() + (std::ptrdiff_t)end);
+ c->bodyRows -= (int)n;
+ if (c->thumbW > 0 && !c->thumbBody.empty()) {
+ size_t toff = (size_t)(b0 - hr) * thumbRowW;
+ size_t tend = (std::min)(c->thumbBody.size(),
+ toff + (size_t)n * thumbRowW);
+ if (toff < c->thumbBody.size())
+ c->thumbBody.erase(c->thumbBody.begin() + (std::ptrdiff_t)toff,
+ c->thumbBody.begin() + (std::ptrdiff_t)tend);
+ }
+ }
+ }
+ // 头部段部分(显示行 [r0, min(r1, hr))):headRev 倒序存头部,显示行 r ↔ 下标
+ // (headRows-1-r),故区间对应下标 [hr-h1, hr-h0) 的连续段;缩略图同构。
+ {
+ int64_t h0 = r0, h1 = (std::min)(r1, hr);
+ if (h1 > h0) {
+ int64_t n = h1 - h0;
+ size_t off = (size_t)(hr - h1) * rowW;
+ size_t len = (size_t)n * rowW;
+ size_t end = (std::min)(c->headRev.size(), off + len);
+ if (off < c->headRev.size())
+ c->headRev.erase(c->headRev.begin() + (std::ptrdiff_t)off,
+ c->headRev.begin() + (std::ptrdiff_t)end);
+ c->headRows -= (int)n;
+ if (c->thumbW > 0) {
+ // 计数与主缓冲严格同步(在数据守卫之外递减),杜绝空缩略图路径下
+ // thumbHeadH 与 headRows 脱钩导致 thumbH 重算漂移
+ c->thumbHeadH = (std::max)(0, c->thumbHeadH - (int)n);
+ if (!c->thumbHeadRev.empty()) {
+ size_t toff = (size_t)(hr - h1) * thumbRowW;
+ size_t tend = (std::min)(c->thumbHeadRev.size(),
+ toff + (size_t)n * thumbRowW);
+ if (toff < c->thumbHeadRev.size())
+ c->thumbHeadRev.erase(c->thumbHeadRev.begin() + (std::ptrdiff_t)toff,
+ c->thumbHeadRev.begin() + (std::ptrdiff_t)tend);
+ }
+ }
+ }
+ }
+ c->stitchH = c->headRows + c->bodyRows; // 恒等式重建,杜绝计数漂移
+ if (c->thumbW > 0)
+ c->thumbH = c->thumbHeadH + (int)(c->thumbBody.size() / thumbRowW);
+ c->thumbDirty = true;
+}
+
+// 执行一个方向的待剔除区间(「丢弃上方/下方」延迟删除的落地点):物理删行并把该侧
+// 输出边界重新开放——此后的新增内容不再受旧裁剪线阻挡,直接从裁剪线续接拼图。
+// 触发时机 = CommitStitch 入口处、位移方向与登记方向一致的成功提交(含未新增行的
+// 重定位帧;提前删除只收紧已捕获范围,不影响识别与匹配基准)。
+void LongCaptureExecuteCropPurge(LongCaptureContext* c, bool below) {
+ if (!c || !c->cropped) return;
+ if (below) {
+ if (!c->cropPendBottom) return;
+ c->cropPendBottom = false;
+ // 内容区间 → 显示行区间:拼接图行 = 内容坐标 + headRows(当前值)
+ LongCaptureEraseDisplayRows(c, c->cropPendBottomLo + c->headRows,
+ c->cropPendBottomHi + c->headRows);
+ c->cropBottomY = INT64_MAX; // 下方重新开放(上方锚点不受影响)
+ } else {
+ if (!c->cropPendTop) return;
+ c->cropPendTop = false;
+ LongCaptureEraseDisplayRows(c, c->cropPendTopLo + c->headRows,
+ c->cropPendTopHi + c->headRows);
+ c->cropTopY = INT64_MIN; // 上方重新开放(下方锚点不受影响)
+ }
+ c->cropped = LongCaptureHasCropConstraint(c);
+}
+
+
+// 提交一次「已通过识别与 offset 合理性校验」的拼接。前置条件由 LongCaptureTryStitch 保证:
+// 匹配 SUCCESS 且 offset 合理。识别/校验阶段绝不触碰 body/headRev/stitchH、lastFrame/lastMatch、
+// offsetHistory 中的任何一个——先识别后提交,杜绝“先改状态、再发现匹配失败”的污染路径。
+// 新增行数由新视口是否越出「已捕获范围 [-headRows, bodyRows)」(内容坐标)决定,而非直接取 |d|:
+// · d>0 向下滚:仅当新视口底越出 bodyRows 才追加 curr 底部越界行(≤ |d|)到 body;
+// · d<0 向上滚:仅当新视口顶越出 -headRows 才前插 curr 顶部越界行(≤ |d|)到 headRev;
+// · 完全落在已捕获范围内(反向回滚未达捕获边界):不新增任何行,仅推进当前视口基准,
+// 小地图只移动当前区域标注——已捕获内容的重复帧绝不再次拼接;
+// · 越界拼接后已捕获范围随之扩张,即「重新记录初始位置」,反向后再反向按新边界同理判定。
+// 无论是否新增行,都旋转匹配基准(lastFrame/lastMatch)、写入 offset 历史并把
+// committedContentTop 推进到新视口位置(tentative 同步对齐)。
+// 返回本次实际新增的拼接行数(0 = 仅重定位)。
+
+int LongCaptureCommitStitch(LongCaptureContext* c, std::vector& curr,
+ LongMatchData& currMatch, int d) {
+ int w = c->physW, h = c->physH;
+ // —— 裁剪延迟剔除(LongCaptureExecuteCropPurge):朝被裁方向的成功提交在此触发
+ // 真删除;随后本帧 addRows 按删除后的新外沿计算,新增行自然从裁剪线续接,
+ // 「裁剪后继续滚动 = 继续从当前位置拼图」由此成立。必须在计算已捕获范围前执行。
+ if (d > 0 && c->cropPendBottom)
+ LongCaptureExecuteCropPurge(c, true);
+ else if (d < 0 && c->cropPendTop)
+ LongCaptureExecuteCropPurge(c, false);
+ // 新视口顶(内容坐标)与已捕获范围外沿;范围恒包含 lastFrame 整帧
+ // [committedContentTop, +h),故单帧只可能越出一个方向的外沿(由 d 的符号决定)。
+ int64_t newTop = c->committedContentTop + d;
+ int64_t capTop = -(int64_t)c->headRows; // 已捕获上外沿(“初始位置”)
+ int64_t capBottom = (int64_t)c->bodyRows; // 已捕获下外沿
+ int addRows = 0;
+ if (d > 0) {
+ int64_t newBottom = newTop + h;
+ if (newBottom > capBottom) addRows = (int)(newBottom - capBottom);
+ } else if (d < 0) {
+ if (newTop < capTop) addRows = (int)(capTop - newTop);
+ }
+ bool toHead = d < 0;
+ const uint32_t* src = d > 0 ? curr.data() + (size_t)(h - addRows) * w : curr.data();
+
+ // 原始拼接段:headRev 尾部追加「新增行块倒序」,body 尾部直接追加(回滚重定位无新增行)
+ if (addRows > 0) {
+ if (toHead) {
+ size_t old = c->headRev.size();
+ c->headRev.resize(old + (size_t)addRows * w);
+ for (int k = 0; k < addRows; k++)
+ memcpy(c->headRev.data() + old + (size_t)k * w,
+ src + (size_t)(addRows - 1 - k) * w, (size_t)w * 4);
+ c->headRows += addRows;
+ } else {
+ c->body.insert(c->body.end(), src, src + (size_t)addRows * w);
+ c->bodyRows += addRows;
+ }
+ c->stitchH += addRows;
+ }
+
+ // 面板两级缩略图段:行数与新增段一一对应,先按固定列宽缩列
+ if (addRows > 0 && c->thumbW > 0) {
+ if (toHead) {
+ size_t old = c->thumbHeadRev.size();
+ c->thumbHeadRev.resize(old + (size_t)addRows * c->thumbW);
+ for (int k = 0; k < addRows; k++)
+ LongCaptureDownscaleRow(src + (size_t)(addRows - 1 - k) * w,
+ c->thumbHeadRev.data() + old + (size_t)k * c->thumbW,
+ w, c->thumbW);
+ c->thumbHeadH += addRows;
+ } else {
+ size_t old = c->thumbBody.size();
+ c->thumbBody.resize(old + (size_t)addRows * c->thumbW);
+ uint32_t* dst = c->thumbBody.data() + old;
+ for (int k = 0; k < addRows; k++) {
+ LongCaptureDownscaleRow(src + (size_t)k * w, dst, w, c->thumbW);
+ dst += c->thumbW;
+ }
+ }
+ c->thumbH = c->thumbHeadH + (int)(c->thumbBody.size() / (size_t)c->thumbW);
+ c->thumbDirty = true;
+ }
+
+ // 成功提交后才更新匹配基准与 offset 历史(被拒绝的帧完全不经过这里,
+ // 因此单次失败不可能污染 lastFrame/lastMatch/offsetHistory)。
+ // offset 历史记录完整视觉位移 |d|(回滚重定位同样是已验证的真实滚动量)。
+ c->lastFrame.swap(curr);
+ std::swap(c->lastMatch, currMatch);
+ c->offsetHistory.push_back(d > 0 ? d : -d);
+ if ((int)c->offsetHistory.size() > LC_OFFSET_HISTORY_LEN)
+ c->offsetHistory.erase(c->offsetHistory.begin());
+
+ // SUCCESS 后 tentative 与 committed 对齐重置(视觉跟踪重新以本帧为基准)。
+ // 内容坐标不随头部前插平移,恒等式:lastFrame 视口顶 += d(d>0 下移 / d<0 上移)。
+ // 这是「tentative → committed」的唯一同步点,位于唯一提交入口内部。
+ // 回滚重定位同样走到这里:committedContentTop 前进即小地图当前区域标注移动。
+ c->committedContentTop += d;
+ c->tentativeContentTop = c->committedContentTop;
+ c->tentativeValid = true;
+ c->tentativeConfidence = 1.0f;
+ c->trackUnreliableStreak = 0;
+ c->trackingRevision++;
+ return addRows;
+}
+
+// 滚轮 delta ↔ 成功像素位移的在线关系估计(软先验数据源):成功提交后把自上次提交
+// 累计的滚轮增量折算成 px/notch 并 EMA 平滑;方向不一致或幅度离谱的样本直接丢弃。
+// 失败的采样绝不更新估计(误识别不能污染先验);累计增量随后清零重新观察。
+// 该估计只通过 LongCaptureBuildOffsetPrior 影响候选排序,不影响任何验收阈值。
+void LongCaptureUpdateWheelEstimate(LongCaptureContext* c, int d) {
+ int accum = c->wheelAccumDelta;
+ c->wheelAccumDelta = 0;
+ if (accum == 0 || d == 0) return;
+ int notches = (accum > 0 ? accum : -accum) / WHEEL_DELTA;
+ if (notches < 1) return; // 不足一个 notch(触控板微滚动):无可比性
+ if ((accum > 0) != (d < 0)) return; // 正 delta=向上滚 ↔ d<0,方向不符丢弃
+ float pxPerNotch = (float)(d > 0 ? d : -d) / (float)notches;
+ if (pxPerNotch < 1.0f || pxPerNotch > 20000.0f) return; // 离谱样本丢弃
+ c->pixelsPerWheelNotch = c->pixelsPerWheelNotch > 0.0f
+ ? 0.7f * c->pixelsPerWheelNotch + 0.3f * pxPerNotch // EMA 平滑(新样本权重 0.3)
+ : pxPerNotch;
+}
+
+// —— 轻量帧稳定性检测(进入正式 DetectMatch 前的准入闸门)——
+// 用连续两次抓帧的 4bit 灰度稀疏采样对比(直接复用 LongCaptureBuildMatchData 的现成
+// 产物,不做完整图像比较):间隔数十至数百毫秒的两帧在大量有效权重行上仍失配,
+// 说明页面仍处于滚动动画/重绘/懒加载/布局过渡中——本帧不适合作为匹配输入,应短延迟
+// 后重新采样(RunLongCapture 重试环按 LC_STABLE_RETRY_DELAY 节奏推进)。
+// 只回答「现在适不适合匹配」:不参与 offset 评分、不进入任何验收阈值、未过闸也
+// 不算匹配失败(跟踪/历史/Weak 候选全部不动)。参考帧超过 LC_STABLE_REF_MAX_GAP
+// 视为上一滚动阶段的残影,不具可比性(直接放行,由正式匹配裁决)。
+bool LongCaptureFrameUnstable(LongCaptureContext* c, const LongMatchData& curr) {
+ if (!c->stableRefValid || c->stableRefCols != curr.cols || c->stableRefH != curr.h)
+ return false;
+ if (GetTickCount() - c->stableRefTick > LC_STABLE_REF_MAX_GAP) return false;
+ const uint8_t* ref = c->stableRefGray.data();
+ const uint8_t* gray = curr.gray.data();
+ int wTotal = 0, wChanged = 0;
+ for (int r = 0; r < curr.h; r++) {
+ int wt = curr.weight[r];
+ if (wt <= 0) continue; // 空白行不具判据
+ wTotal += wt;
+ if (!LongCaptureRowMatchesRange(ref + (size_t)r * curr.cols,
+ gray + (size_t)r * curr.cols, 0, curr.cols))
+ wChanged += wt;
+ }
+ if (wTotal < LC_STABLE_MIN_WEIGHT) return false; // 有效行过少:无稳定性判据
+ return (float)wChanged / (float)wTotal >= LC_STABLE_CHANGED_ROW_FRAC;
+}
+
+// 滚动更新稳定性参考帧(每次抓帧构建匹配数据后调用,无论本帧走向哪条路径:
+// 闸门判定前后、匹配成败与否,参考系始终 = 最近一次抓帧)。
+void LongCaptureUpdateStabilityRef(LongCaptureContext* c, const LongMatchData& curr) {
+ c->stableRefGray = curr.gray; // h×cols 字节(≤ ~400KB),拷贝成本可忽略
+ c->stableRefCols = curr.cols;
+ c->stableRefH = curr.h;
+ c->stableRefTick = GetTickCount();
+ c->stableRefValid = true;
+}
+
+// 单帧「识别 → offset 校验 →(Weak 档)延迟确认 → 提交」管线入口(RunLongCapture
+// 采样与单元测试共用)。返回值语义见 LCSampleOutcome:
+// NoChange —— 两帧完全相同(滚动未生效/尚未渲染,调用方应等待重试),
+// 或匹配成功但位移为 0(≠失败,≠到底);
+// Failed —— Normal 档识别失败/置信度不足/offset 异常跳变:本帧被拒绝,
+// body/headRev/stitchH、lastFrame/lastMatch、offsetHistory 全部保持原样
+// (已登记的 pendingMatch 保留——硬失败并不否定候选,等下一帧复核);
+// WeakPending —— Weak 档首次可信候选:只登记 pendingMatch,不提交、不改任何累计状态;
+// WeakRejected —— Weak 候选被复核否决(第二次候选不一致/置信度跌破动态门槛/
+// Weak offset 异常):清除候选,仍不污染任何累计状态;
+// Unstable —— 稳定性闸门未过(页面仍处滚动/重绘/懒加载过渡,仅当调用方启用
+// 闸门时可能出现):本帧不进入正式匹配,不修改任何状态(含跟踪/
+// 历史/Weak 候选),调用方短延迟后重新采样;
+// Stitched —— 已安全提交且新增拼接行(唯一扩展累计拼接内容的结局;Normal 直接提交,
+// Weak 需连续两次稳定采样独立复现一致候选,多跳推导提交需过四道关卡);
+// Repositioned —— 匹配成功但新视口未越出已捕获范围(反向回滚):CommitStitch 只推进
+// 当前视口基准并移动小地图当前区域标注,无新增行/帧数。
+// 失败帧的跟踪分流(与本函数返回语义正交,只影响外围状态层):
+// · 与 lastFrame 直连失败 → 多跳回溯最近历史帧重建已验证位移(视觉依据);
+// · 无多跳依据 → 轻量预测(历史中位数/位移先验)推进 tentative;
+// · 本帧匹配数据登记进最近帧历史(未提交,位置 = 当前 tentative 估计),
+// 供后续帧多跳恢复——一次失败不再切断跟踪链,小地图仍能在预计位置层面反馈。
+// 注意:旧实现的“匹配失败按滚动方向整帧插入(整帧兜底)”已移除——那是单帧误识别
+// 把整个当前视口拼接到长图末尾(后续全部错位)的直接根因;失败现在只能重试或终止。
+
+LCSampleOutcome LongCaptureTryStitch(LongCaptureContext* c, std::vector& curr, int dir,
+ bool allowStabilityGate) {
+ int w = c->physW, h = c->physH;
+ c->sampleIndex++;
+ c->lastFailReason = LCFailReason::None; // 每次调用重置,各结局路径按需覆写
+ if (curr.size() != (size_t)w * h || c->lastFrame.size() != (size_t)w * h) {
+ c->lastFailReason = LCFailReason::NoCandidate;
+ return LCSampleOutcome::Failed;
+ }
+ if (memcmp(c->lastFrame.data(), curr.data(), (size_t)w * h * 4) == 0) {
+ c->pendingMatch.valid = false; // 内容回到基准:待复核候选已过期
+ c->weakCandidateOffsets.clear(); // 弱候选簇同样以基准为参考系,一并过期
+ // 与基准帧逐像素全同 = 精确视觉依据:当前视口就在 committed 位置
+ //(tentative 同步回归,纠正此前失败帧的预测漂移)。
+ LongCaptureTrackingSetVisual(c, c->committedContentTop, 1.0f);
+ LC_LOG("[LC#%d] NO_CHANGE h=%d (identical frame, pending cleared)", c->sampleIndex, h);
+ return LCSampleOutcome::NoChange;
+ }
+
+ // ① 识别(纯函数,不修改状态):Normal 档未 SUCCESS 时自动尝试 Weak 档
+ //(低重叠大跳变,需 3/3 ROI + 边缘结构 + 动态高置信度强证据)。
+ // lastFrame 对齐数据两帧采样间不变直接复用,curr 本轮计算,仅提交成功后旋转。
+ if (c->lastMatch.h != h || c->lastMatch.cols == 0)
+ LongCaptureBuildMatchData(c->lastFrame, w, h, c->lastMatch);
+ LongMatchData currMatch;
+ LongCaptureBuildMatchData(curr, w, h, currMatch);
+ // ⓪ 轻量稳定性检测(准入闸门,默认仅采样主循环启用):页面仍在快速变化时本帧
+ // 不进入正式匹配——短延迟后重新采样,而不是带着过渡帧烧掉正式匹配/重试预算。
+ // 本路径不修改任何累计状态(含跟踪/历史/Weak 候选)、不降低任何识别门槛、
+ // 也不参与 offset 评分;等待预算由调用方的 stableWaits 控制,耗尽后闸门关闭,
+ // 快速连续滚动仍按既有中滚采样节拍进入正式匹配。
+ if (allowStabilityGate && LongCaptureFrameUnstable(c, currMatch)) {
+ LongCaptureUpdateStabilityRef(c, currMatch); // 参考帧随抓帧滚动前进
+ c->lastFailReason = LCFailReason::FrameUnstable;
+ LC_LOG("[LC#%d] decision=FRAME_UNSTABLE (gate: page still changing, wait & resample)",
+ c->sampleIndex);
+ return LCSampleOutcome::Unstable;
+ }
+ LongCaptureUpdateStabilityRef(c, currMatch);
+ LongCaptureOffsetPrior prior = LongCaptureBuildOffsetPrior(c);
+ LCWeakTemporal wt = LongCaptureWeakTemporalContext(c);
+ LongMatchOutcome m = LongCaptureDetectMatch(c->lastMatch, currMatch, dir, prior, wt, c->sampleIndex);
+ LC_LOG("[LC#%d] detect: status=%d mode=%s d=%d overlap=%d roi=%d/%d/%d(valid=%d) agree=%d "
+ "overall=%.2f seam=%.2f tmb=%.2f/%.2f/%.2f spatial=%.2f cont=%.2f prof=%.2f "
+ "edge=%.2f sep=%.2f roiW=%.2f tex=%.2f conf=%.2f reason=%s dir=%d prior=%d/%d "
+ "pending=%d(%d)",
+ c->sampleIndex, (int)m.status,
+ m.mode == LongCaptureMatchMode::WeakOverlap ? "WEAK" : "NORMAL",
+ m.offset, m.overlap, m.bandOffsets[0], m.bandOffsets[1], m.bandOffsets[2],
+ m.validBandCount, m.agreeCount, m.overall, m.seam,
+ m.top, m.middle, m.bottom, m.spatial, m.continuity, m.profileScore,
+ m.edgeCorrelation, m.peakGap, m.roiWeighted, m.textureRatio, m.confidence,
+ LcFailReasonName(m.reason),
+ dir, prior.valid ? 1 : 0, prior.expectedAbsOffset,
+ c->pendingMatch.valid ? 1 : 0, c->pendingMatch.offset);
+ if (m.status != LC_MATCH_SUCCESS) {
+ // 方向冲突归因:有候选但与最近滚动方向相反(反向候选已被排序罚分压制、仍无
+ // 可信正向对齐)——覆写泛化的「证据不足」分类,便于定位方向先验问题。
+ if ((m.reason == LCFailReason::CandidateWeak || m.reason == LCFailReason::None)
+ && m.validBandCount > 0 && m.offset != 0 && dir != 0
+ && ((m.offset > 0) != (dir > 0)))
+ m.reason = LCFailReason::DirectionConflict;
+ // —— 失败帧分流:正式拼接保持原样(绝不拼接、绝不污染基准/历史),
+ // 先尝试多跳恢复跟踪,再退回轻量预测;本帧登记进最近帧历史。——
+ LCMultihopResult mh = LongCaptureMultihopDetect(c, currMatch, dir, prior);
+ if (mh.matched) {
+ // 已验证的多跳关系:tentative 直接落到链推导位置(视觉依据,可解冻)。
+ LongCaptureTrackingSetVisual(c, mh.hopContentY + mh.offset, mh.confidence);
+ LC_LOG("[LC#%d] multihop: base=%s frame=%d d=%d conf=%.2f trackY=%lld",
+ c->sampleIndex, mh.hopCommitted ? "committed" : "tentative",
+ mh.hopFrameId, mh.offset, mh.confidence,
+ (long long)(mh.hopContentY + mh.offset));
+ // 已提交基准链 + Normal 档强证据:可推导相对 lastFrame 的提交位移。
+ // 四道关卡(全过才提交,任一不过 → 只推进 tentative 跟踪):
+ // ① 推导位移与最近滚动方向同号,且与 lastFrame 重叠满足 Normal 档下限
+ // (重叠不足意味着可能跳过未捕获内容——绝不提交);
+ // ② 历史跳变合理性(LongCaptureOffsetPlausible);
+ // ③ 在推导位移处与 lastFrame 做单点富验证并达严格档(防 H 链误配入图);
+ // ④ 多跳匹配自身为 Normal 档 SUCCESS(弱档链只推进跟踪,不推导提交)。
+ if (mh.hopCommitted && mh.mode == LongCaptureMatchMode::Normal) {
+ int64_t currContentY = mh.hopContentY + mh.offset;
+ int dCommit = (int)(currContentY - c->committedContentTop);
+ int ad = dCommit > 0 ? dCommit : -dCommit;
+ int minOv = (std::max)(LC_MIN_OVERLAP, (int)(h * LC_MIN_OVERLAP_RATIO));
+ bool dirOk = dir == 0 || dCommit == 0 || ((dCommit > 0) == (dir > 0));
+ if (dCommit != 0 && dirOk && ad <= h - minOv
+ && LongCaptureOffsetPlausible(c, dCommit, LongCaptureMatchMode::Normal)) {
+ LCOverlapEvidence ev;
+ if (LongCaptureVerifyCandidate(c->lastMatch, currMatch, dCommit, ev)
+ && ev.overall >= LC_ACCEPT_STRICT_OVERALL
+ && ev.seam >= LC_ACCEPT_STRICT_SEAM) {
+ c->pendingMatch.valid = false; // 多跳强证据覆盖未决候选
+ int added = LongCaptureCommitStitch(c, curr, currMatch, dCommit);
+ LongCaptureUpdateWheelEstimate(c, dCommit);
+ LongCaptureAfterCommit(c, c->sampleIndex);
+ LC_LOG("[LC#%d] decision=MULTIHOP_COMMIT d=%d added=%d via frame=%d "
+ "conf=%.2f overall=%.2f seam=%.2f",
+ c->sampleIndex, dCommit, added, mh.hopFrameId, mh.confidence,
+ ev.overall, ev.seam);
+ return added > 0 ? LCSampleOutcome::Stitched
+ : LCSampleOutcome::Repositioned;
+ }
+ }
+ }
+ } else {
+ // 无多跳依据:轻量预测(TrackingEstimate != StitchMatch,只驱动 tentative,
+ // 绝不进入提交)。完全无法判断方向/位移时 tentative 不更新(冻结计数推进)。
+ LongCaptureTrackingEstimate est = LongCaptureBuildTrackingEstimate(c, dir, prior);
+ if (est.valid)
+ LongCaptureTrackingAdvancePredicted(c, est.direction, est.predictedOffset);
+ else
+ c->trackUnreliableStreak++;
+ }
+ // 弱档候选(未达标)登记为时间一致性样本(参考系 = lastFrame,与 pending 流一致)。
+ if (m.mode == LongCaptureMatchMode::WeakOverlap && m.offset != 0)
+ LongCapturePushWeakCandidate(c, m.offset);
+ // 本帧进入最近帧历史(未提交,位置 = 当前 tentative 估计),供后续帧多跳回溯。
+ LongCaptureHistoryPush(c, c->sampleIndex, std::move(currMatch),
+ c->tentativeContentTop, false);
+ // 失败可观测性:原因分类 + 本次最佳候选及主要证据留档(仅日志/重试节奏用,
+ // 不参与任何判定),用于区分「没找到候选 / 候选证据不足 / offset 被历史拒绝 /
+ // ROI 冲突 / 方向冲突」等失败来源。
+ c->lastFailReason = m.reason;
+ c->lastReject = m;
+ LC_LOG("[LC#%d] decision=REJECTED reason=%s d=%d overlap=%d overall=%.2f seam=%.2f "
+ "spatial=%.2f cont=%.2f prof=%.2f edge=%.2f sep=%.2f roiW=%.2f sup=%d "
+ "tex=%.2f conf=%.2f (state untouched, tracking only)",
+ c->sampleIndex, LcFailReasonName(m.reason), m.offset, m.overlap,
+ m.overall, m.seam, m.spatial, m.continuity, m.profileScore,
+ m.edgeCorrelation, m.peakGap, m.roiWeighted, m.agreeCount,
+ m.textureRatio, m.confidence);
+ return LCSampleOutcome::Failed; // LOW_CONFIDENCE / FAILED:绝不拼接,交给重试
+ }
+ if (m.offset == 0) {
+ c->pendingMatch.valid = false; // 对齐回零位移:此前候选系误识
+ c->weakCandidateOffsets.clear();
+ // 零位移对齐 = 视觉依据:当前视口与 committed 基准同位(tentative 回归)。
+ LongCaptureTrackingSetVisual(c, c->committedContentTop, m.confidence);
+ LC_LOG("[LC#%d] NO_CHANGE (zero-offset success)", c->sampleIndex);
+ return LCSampleOutcome::NoChange; // 匹配成功但内容未滚动:不是失败,也不判断到底
+ }
+
+ if (m.mode == LongCaptureMatchMode::Normal) {
+ // ②-Normal offset 合理性(历史趋势)→ ③ 提交(与原流程一致)。
+ if (!LongCaptureOffsetPlausible(c, m.offset, LongCaptureMatchMode::Normal)) {
+ m.reason = LCFailReason::OffsetImplausible;
+ c->lastFailReason = m.reason;
+ c->lastReject = m;
+ LC_LOG("[LC#%d] decision=OFFSET_IMPLAUSIBLE d=%d conf=%.2f (normal)",
+ c->sampleIndex, m.offset, m.confidence);
+ return LCSampleOutcome::Failed;
+ }
+ c->pendingMatch.valid = false; // Normal 强证据直接覆盖未决的 Weak 候选
+ int added = LongCaptureCommitStitch(c, curr, currMatch, m.offset);
+ LongCaptureUpdateWheelEstimate(c, m.offset);
+ LongCaptureAfterCommit(c, c->sampleIndex); // 已提交历史登记 + 弱候选簇清空
+ LC_LOG("[LC#%d] decision=NORMAL_SUCCESS d=%d added=%d overlap=%d conf=%.2f",
+ c->sampleIndex, m.offset, added, m.overlap, m.confidence);
+ // added=0:反向回滚未越出已捕获范围,仅重定位(小地图标注移动,无新增行)
+ return added > 0 ? LCSampleOutcome::Stitched : LCSampleOutcome::Repositioned;
+ }
+
+ // —— WeakOverlap:延迟确认提交(本次修改最重要的防污染机制) ——
+ if (!LongCaptureOffsetPlausible(c, m.offset, LongCaptureMatchMode::WeakOverlap)) {
+ c->pendingMatch.valid = false; // 候选幅度不可信:整条候选链作废
+ LongCaptureTrackingResetToCommitted(c);// 候选不可信:跟踪退回已确认基准
+ LongCaptureHistoryPush(c, c->sampleIndex, std::move(currMatch),
+ c->tentativeContentTop, false);
+ m.reason = LCFailReason::OffsetImplausible;
+ c->lastFailReason = m.reason;
+ c->lastReject = m;
+ LC_LOG("[LC#%d] decision=OFFSET_IMPLAUSIBLE d=%d conf=%.2f (weak)",
+ c->sampleIndex, m.offset, m.confidence);
+ return LCSampleOutcome::WeakRejected;
+ }
+ if (!c->pendingMatch.valid) {
+ c->pendingMatch.valid = true; // 只登记候选,不触碰任何累计拼接状态
+ c->pendingMatch.offset = m.offset;
+ c->pendingMatch.confidence = m.confidence;
+ c->pendingMatch.mode = LongCaptureMatchMode::WeakOverlap;
+ // 弱档候选已过全部空间硬门槛:tentative 按候选位移做视觉级推进(若延迟确认
+ // 被否决会退回 committed);候选同时登记为时间一致性样本;本帧进历史
+ //(位置 = committed + 候选位移),作为后续帧的就近多跳基准。
+ LongCaptureTrackingSetVisual(c, c->committedContentTop + m.offset, m.confidence);
+ LongCapturePushWeakCandidate(c, m.offset);
+ LongCaptureHistoryPush(c, c->sampleIndex, std::move(currMatch),
+ c->tentativeContentTop, false);
+ LC_LOG("[LC#%d] decision=WEAK_PENDING d=%d overlap=%d conf=%.2f(need %.2f)",
+ c->sampleIndex, m.offset, m.overlap, m.confidence,
+ LongCaptureWeakRequiredConfidence(h, m.overlap));
+ return LCSampleOutcome::WeakPending;
+ }
+ int pendOff = c->pendingMatch.offset; // 先留档供日志,再决定去留
+ int diff = m.offset > pendOff ? m.offset - pendOff : pendOff - m.offset;
+ float need = LongCaptureWeakRequiredConfidence(h, m.overlap);
+ if (diff <= LC_WEAK_CONFIRM_OFFSET_TOL && m.confidence >= need) {
+ c->pendingMatch.valid = false; // 连续两次稳定采样独立复现:确认提交
+ int added = LongCaptureCommitStitch(c, curr, currMatch, m.offset);
+ LongCaptureUpdateWheelEstimate(c, m.offset);
+ LongCaptureAfterCommit(c, c->sampleIndex); // 已提交历史登记 + 弱候选簇清空
+ LC_LOG("[LC#%d] decision=WEAK_CONFIRMED d=%d added=%d overlap=%d conf=%.2f",
+ c->sampleIndex, m.offset, added, m.overlap, m.confidence);
+ return added > 0 ? LCSampleOutcome::Stitched : LCSampleOutcome::Repositioned;
+ }
+ c->pendingMatch.valid = false; // 第二次候选不一致/置信度不足:清除,不污染
+ LongCapturePushWeakCandidate(c, m.offset); // 仍是有效的弱候选目击样本
+ LongCaptureTrackingResetToCommitted(c); // 候选链被否决:跟踪退回已确认基准
+ LongCaptureHistoryPush(c, c->sampleIndex, std::move(currMatch),
+ c->committedContentTop + m.offset, false);
+ m.reason = LCFailReason::CandidateWeak; // 复核未复现一致候选(证据链断裂)
+ c->lastFailReason = m.reason;
+ c->lastReject = m;
+ LC_LOG("[LC#%d] decision=WEAK_REJECTED d=%d vs pending=%d (diff=%d) conf=%.2f(need %.2f)",
+ c->sampleIndex, m.offset, pendOff, diff, m.confidence, need);
+ return LCSampleOutcome::WeakRejected;
+}
+
diff --git a/src/screenshot/lc_toolbar_ui.cpp b/src/screenshot/lc_toolbar_ui.cpp
new file mode 100644
index 0000000..4b47da4
--- /dev/null
+++ b/src/screenshot/lc_toolbar_ui.cpp
@@ -0,0 +1,1421 @@
+// 长截图子系统:选区底部工具栏 UI(工具栏/popover/tooltip/图标缓存/自动滚动/裁剪菜单)。
+// CR-021 拆分自 long_capture_windows.cpp 的「长截图工具栏」段。
+// 工具栏是独立 TOPMOST 分层弹窗(WS_EX_LAYERED),消息由 RunLongCapture 泵循环分发,
+// 全部视觉内容由 LongCaptureToolbarRender 整幅渲染后 UpdateLayeredWindow 原子提交。
+#include "internal.h"
+#include "long_capture_internal.h"
+#include "../generated/icon_svgs.h" // 工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 生成)
+
+// ==================== 长截图工具栏(选区底部悬浮窗口) ====================
+// 小地图面板只保留预览;全部操作集中到本工具栏,从左到右(图标按钮):
+// [预览宽×高] | [方向▾] [自动滚动] [裁剪▾] | [保存到本地] [取消] [完成并复制]
+// 按钮样式与基础截图工具栏一致(白底圆角条 + 浅蓝 hover 高亮 + 图标三态)。
+// 方向与裁剪带二级菜单(悬停锚点按钮片刻即展开、移开自动收起,点击亦可开合;
+// 方向:纵向/横向,默认纵向,已拼接多帧后锁定;裁剪:丢弃选区上方/下方内容、重置),
+// 其余为直接动作。工具栏是独立 TOPMOST 弹窗(位于灰蒙版之上),消息由 RunLongCapture
+// 的泵循环分发,因此所有交互与采样主循环同线程执行,无需跨线程同步(finish/abort/save
+// 仍走原子量)。二级菜单展开方向永远避让选区(见 LongCaptureMenuOpenBelow),
+// 菜单浮层绝不进入选区画面——否则会被逐帧采样采进拼接内容、污染重叠识别基准。
+
+
+// 布局常量(逻辑像素,绘制/命中测试时按 dpiScale 缩放)
+
+static const int LC_BAR_H = 44; // 工具栏高度(图标按钮 32 + 上下内边距 6)
+
+static const int LC_BAR_BTN_W = 32; // 图标按钮宽度(正方形 cell)
+
+static const int LC_BAR_BTN_H = 32; // 图标按钮高度
+
+static const int LC_BAR_GAP = 2; // 相邻图标按钮间距
+
+static const int LC_SIZE_W = 104; // 预览宽×高标签占位宽
+
+static const int LC_BAR_PAD = 6; // 左右内边距
+
+static const int LC_BAR_SEP_W = 13; // 分隔线占位宽(含两侧间距)
+
+static const int LC_BAR_RADIUS = 8; // 圆角半径
+
+static const int LC_BAR_MARGIN = 8; // 选区到工具栏间距
+
+static const int LC_MENU_GAP = 6; // 子菜单与工具栏间距
+
+static const int LC_POP_H = 44; // 裁剪 popover 面板高(图标 cell 32 + 上下内边距 6)
+
+static const int LC_POP_PAD = 6; // 裁剪 popover 面板内边距
+
+static const int LC_POP_CELL_W = 32; // 裁剪 popover 图标 cell 宽(与工具栏按钮同尺寸)
+
+static const int LC_POP_CELL_GAP = 2; // 裁剪 popover 图标 cell 间距
+
+static const int LC_POP_OPEN_DWELL_MS = 300; // 方向/裁剪锚点按钮悬停多久后展开 popover(悬停意图判定,扫过不误触)
+
+static const int LC_POP_CLOSE_GRACE_MS = 250; // 鼠标离开「锚点按钮∪popover」多久后收起(跨间隙宽限)
+
+static const int LC_TIP_DELAY_MS = 500; // 图标悬停多久后显示 title 式 tooltip(网页 title 同款节奏)
+
+static const int LC_TIMER_AUTOSCROLL = 1; // 自动滚动定时器 id
+
+static const int LC_TIMER_UI = 2; // UI 维护定时器 id(子菜单外点关闭等)
+
+// 连续平滑滚动的注入参数:高频小步长取代旧「低频大跳步」(每 600~1200ms 注入一次
+// 两整档增量),页面不再一顿一顿地跳,而是持续匀速滑动。平均速度约 2.7 档/s,略低于
+// 旧的 3.3 档/s——用稍慢的速度换取连续性;想再慢/快只需同比例调整这两个常量。
+
+static const int LC_AUTOSCROLL_TICK_MS = 31; // 注入节拍 ms(落在系统定时器 ~15.6ms 粒度的两格上)
+
+static const int LC_AUTOSCROLL_STEP_DELTA = 10; // 单次注入滚轮增量(亚档位:menuKind == LCM_Direction) return 2;
+ return c->cropped ? 3 : 2; // LCM_Crop
+}
+
+// 二级菜单的锚点按钮(popover 水平居中对齐、悬停展开与离开收起均围绕锚点判定)
+
+static int LongCaptureMenuAnchorItem(LCMenuKind kind) {
+ return kind == LCM_Crop ? LTI_Crop : LTI_Direction;
+}
+
+// 悬停的底条按钮将展开的二级菜单:裁剪恒可展开;方向在已拼接多帧(frameCount>1)后
+// 锁定(两个方向的内容坐标系不同,混拼必然错位),锁定期间悬停/点击均不展开。
+
+static LCMenuKind LongCaptureHoverMenuKind(const LongCaptureContext* c, int hv) {
+ if (!c) return LCM_None;
+ if (hv == LTI_Crop) return LCM_Crop;
+ if (hv == LTI_Direction && c->frameCount.load() <= 1) return LCM_Direction;
+ return LCM_None;
+}
+
+// 二级菜单面板高度(逻辑像素):方向/裁剪均为单行图标 popover(cell 32 + 上下内边距 6)
+
+static int LongCaptureMenuHeightLogi(const LongCaptureContext* c) {
+ if (!c || c->menuKind == LCM_None) return 0;
+ return LC_POP_H;
+}
+
+// 二级菜单项文案(popover 图标 cell 的 tooltip 文本):方向=纵向/横向;
+// 裁剪=丢弃上方(纵向)/左侧(横向)、丢弃下方/右侧、重置。
+
+static const wchar_t* LongCaptureMenuRowLabel(const LongCaptureContext* c, int row) {
+ if (!c) return L"";
+ if (c->menuKind == LCM_Direction) {
+ static const wchar_t* dir[2] = {L"纵向", L"横向"};
+ return (row >= 0 && row < 2) ? dir[row] : L"";
+ }
+ static const wchar_t* cropV[3] = {L"丢弃选区上方内容", L"丢弃选区下方内容", L"重置裁剪"};
+ static const wchar_t* cropH[3] = {L"丢弃选区左侧内容", L"丢弃选区右侧内容", L"重置裁剪"};
+ const wchar_t* const* labels = c->horizontal ? cropH : cropV;
+ int n = c->cropped ? 3 : 2;
+ return (row >= 0 && row < n) ? labels[row] : L"";
+}
+
+// 二级菜单行可用性:方向菜单中「非当前方向」的行在已拼接多帧(frameCount>1)后禁用
+//(两个方向的内容坐标系不同,混拼必然错位,宁可要求用户取消重来);当前方向行保持
+// 可用(点击仅收起菜单)。裁剪行始终可用(只收紧输出行窗口,不碰拼接/匹配状态)。
+
+static bool LongCaptureMenuRowEnabled(const LongCaptureContext* c, int row) {
+ if (!c) return false;
+ if (c->menuKind == LCM_Direction) {
+ int current = c->horizontal ? 1 : 0;
+ return row == current || c->frameCount.load() <= 1;
+ }
+ return true;
+}
+
+// 预览宽×高标签(逻辑像素,含裁剪窗口;横向模式宽高已回转)
+// 显示空间逻辑尺寸换算统一为:固定轴取 cropRect(逻辑源,无 /ds 舍入往返误差),
+// 滚动轴取 rows / ds(物理行数 ÷ scale)。与 LongCapturePanelUpdate/PanelPreviewRect
+// 同一公式,消除原先三种写法并存(CR-023)。
+
+static void LongCaptureOutputSizeLabel(const LongCaptureContext* c, wchar_t* buf, size_t n) {
+ double ds = c ? c->dpiScale : 1.0;
+ int rowStart = 0, rowEnd = 0;
+ LongCaptureOutputRows(c, rowStart, rowEnd);
+ int rows = rowEnd - rowStart;
+ if (rows <= 0 || !c) { swprintf_s(buf, n, L"0 × 0"); return; }
+ // 固定轴(纵向=宽、横向=高)取 cropRect 逻辑尺寸;滚动轴取 rows/ds 逻辑尺寸。
+ double cropW = (double)(c->cropRect.right - c->cropRect.left);
+ double cropH = (double)(c->cropRect.bottom - c->cropRect.top);
+ double w = c->horizontal ? rows / ds : cropW;
+ double h = c->horizontal ? cropH : rows / ds;
+ swprintf_s(buf, n, L"%d × %d", (int)(w + 0.5), (int)(h + 0.5));
+}
+
+// 工具栏客户区布局(DPI 缩放):图标按钮从左到右排布,垂直居中于底条;
+// 二级菜单展开时窗口整体向避让选区的一侧扩展(menuBelow 决定菜单在底条上/下)。
+// 方向与裁剪均为紧凑图标 popover 面板,水平居中对齐各自的锚点按钮
+//(items 排布完成后计算,故面板水平位置在末尾收敛)。
+
+static LongToolbarLayout LongCaptureToolbarLayout(LongCaptureContext* c, int cw, int ch) {
+ LongToolbarLayout L = {};
+ double ds = c ? c->dpiScale : 1.0;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ int barH = sc(LC_BAR_H), pad = sc(LC_BAR_PAD), btnH = sc(LC_BAR_BTN_H);
+ bool popMenu = c && c->menuKind != LCM_None;
+ int menuRows = (c && c->menuKind != LCM_None) ? LongCaptureMenuRows(c) : 0;
+ int menuH = sc(LongCaptureMenuHeightLogi(c));
+ int menuGap = sc(LC_MENU_GAP);
+ L.menuRows = menuRows;
+ L.menuBelow = c ? c->menuBelow : false;
+ if (menuRows > 0 && !L.menuBelow) {
+ // 菜单在上、底条在下(窗口整体向上扩展)
+ L.menu = {0, 0, cw, menuH};
+ L.bar = {0, menuH + menuGap, cw, ch};
+ } else if (menuRows > 0) {
+ // 菜单在下、底条在上(窗口整体向下扩展)
+ L.bar = {0, 0, cw, barH};
+ L.menu = {0, barH + menuGap, cw, barH + menuGap + menuH};
+ } else {
+ L.bar = {0, 0, cw, ch};
+ }
+ // 各项目从左到右排布:图标按钮为等宽正方形 cell,图标间留小间距;
+ // 分隔线占位宽自带两侧间距,不再额外加 gap
+ int x = L.bar.left + pad;
+ auto place = [&](int idx, int w) {
+ L.items[idx].left = x;
+ L.items[idx].top = L.bar.top + (barH - btnH) / 2;
+ L.items[idx].right = x + w;
+ L.items[idx].bottom = L.items[idx].top + btnH;
+ x += w + sc(LC_BAR_GAP);
+ };
+ auto placeSep = [&](int idx) {
+ L.items[idx].left = x;
+ L.items[idx].top = L.bar.top + sc(10);
+ L.items[idx].right = x + sc(LC_BAR_SEP_W);
+ L.items[idx].bottom = L.bar.bottom - sc(10);
+ x += sc(LC_BAR_SEP_W);
+ };
+ place(LTI_Grip, sc(LC_BAR_BTN_W)); // 最左 6 点拖拽把手
+ place(LTI_Size, sc(LC_SIZE_W));
+ placeSep(LTI_Sep1);
+ place(LTI_Direction, sc(LC_BAR_BTN_W));
+ place(LTI_AutoScroll, sc(LC_BAR_BTN_W));
+ place(LTI_Crop, sc(LC_BAR_BTN_W));
+ placeSep(LTI_Sep3);
+ place(LTI_Save, sc(LC_BAR_BTN_W));
+ place(LTI_Cancel, sc(LC_BAR_BTN_W));
+ place(LTI_Finish, sc(LC_BAR_BTN_W));
+ // 图标 popover:面板水平居中于锚点按钮(方向→方向按钮,裁剪→裁剪按钮)并夹在
+ // 工具栏宽度内(垂直位置已由上方菜单带决定)
+ if (popMenu) {
+ int anchor = LongCaptureMenuAnchorItem(c->menuKind);
+ int pw = menuRows * sc(LC_POP_CELL_W) + (menuRows - 1) * sc(LC_POP_CELL_GAP)
+ + sc(LC_POP_PAD) * 2;
+ int cxBtn = (L.items[anchor].left + L.items[anchor].right) / 2;
+ int px = cxBtn - pw / 2;
+ if (px < 0) px = 0;
+ if (px + pw > cw) px = cw - pw;
+ L.menu.left = px;
+ L.menu.right = px + pw;
+ }
+ return L;
+}
+
+// 工具栏窗口总宽(逻辑像素 × dpiScale),与 LongCaptureToolbarLayout 的横向排布严格一致
+
+static int LongCaptureToolbarWindowWidth(LongCaptureContext* c) {
+ double ds = c ? c->dpiScale : 1.0;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ int cell = LC_BAR_BTN_W + LC_BAR_GAP;
+ int content = LC_BAR_BTN_W + LC_BAR_GAP // 最左 6 点拖拽把手格
+ + LC_SIZE_W + LC_BAR_GAP + LC_BAR_SEP_W
+ + cell * 2 + LC_BAR_BTN_W + LC_BAR_SEP_W
+ + cell * 2 + LC_BAR_BTN_W;
+ return sc(LC_BAR_PAD * 2 + content);
+}
+
+static int LongCaptureHitTestToolbar(int x, int y, const LongToolbarLayout& L) {
+ for (int i = 0; i < LTI_Count; i++) {
+ if (i == LTI_Sep1 || i == LTI_Sep3) continue;
+ if (PointInRect(x, y, L.items[i])) return i;
+ }
+ return -1;
+}
+
+// 二级菜单 popover 图标 cell 矩形(menu 面板内从左到右等宽排布,与工具栏按钮同尺寸;
+// 方向/裁剪共用同一套布局与命中机制)
+
+static RECT LongCapturePopoverCellRect(const LongCaptureContext* c, const LongToolbarLayout& L, int i) {
+ double ds = c ? c->dpiScale : 1.0;
+ int pad = (int)(LC_POP_PAD * ds + 0.5);
+ int cell = (int)(LC_POP_CELL_W * ds + 0.5);
+ int gap = (int)(LC_POP_CELL_GAP * ds + 0.5);
+ return {L.menu.left + pad + i * (cell + gap), L.menu.top + pad,
+ L.menu.left + pad + i * (cell + gap) + cell, L.menu.top + pad + cell};
+}
+
+// 命中二级菜单 popover 图标 cell(-1=不在 popover 内)
+
+static int LongCaptureHitTestPopover(LongCaptureContext* c, int x, int y, const LongToolbarLayout& L) {
+ if (L.menuRows <= 0) return -1;
+ for (int i = 0; i < L.menuRows; i++) {
+ if (PointInRect(x, y, LongCapturePopoverCellRect(c, L, i))) return i;
+ }
+ return -1;
+}
+
+
+// ---- 长截图工具栏图标缓存(dark/blue/white/gray 四色 × LCI_Count,按会话 DPI 光栅化)----
+
+enum LCIconId {
+ LCI_DirectionV = 0, // 纵向方向(竖版矩形+下箭头)
+ LCI_DirectionH, // 横向方向(横版矩形+右箭头)
+ LCI_AutoScrollV, // 自动滚动·纵向(鼠标+下箭头)
+ LCI_AutoScrollH, // 自动滚动·横向(鼠标+右箭头)
+ LCI_Crop, // 裁剪
+ LCI_Save, // 保存到本地(下载图标)
+ LCI_Cancel, // 取消(×)
+ LCI_Confirm, // 完成并复制(✓)
+ LCI_CropDiscardTop, // 丢弃上方(箭头抵上边界线)
+ LCI_CropDiscardBottom, // 丢弃下方
+ LCI_CropDiscardLeft, // 丢弃左侧(横向模式变体)
+ LCI_CropDiscardRight, // 丢弃右侧(横向模式变体)
+ LCI_CropReset, // 重置裁剪(逆时针还原)
+ LCI_Count
+};
+
+// 图标位图缓存:创建工具栏时按 DPI 光栅化一次(与主截图工具栏 SCIconCache 同思路)。
+// dark=常态,blue=悬停/开启态(与文字激活色一致),gray=禁用(方向锁定)。
+
+struct LCIconCache {
+ bool inited;
+ int px; // 光栅化边长(物理像素)
+ HBITMAP dark[LCI_Count];
+ HBITMAP blue[LCI_Count];
+ HBITMAP gray[LCI_Count];
+
+ LCIconCache() : inited(false), px(0) {
+ for (int i = 0; i < LCI_Count; i++)
+ dark[i] = blue[i] = gray[i] = NULL;
+ }
+
+ // 按物理像素边长光栅化全部图标(尺寸变化时先释放旧位图;光栅化实现见 icons_windows.cpp)
+ void Init(int physicalPx) {
+ if (inited && px == physicalPx) return;
+ Cleanup();
+ static const char* svgs[LCI_Count] = {
+ kIconSvg_DirectionV, kIconSvg_DirectionH,
+ kIconSvg_AutoScrollV, kIconSvg_AutoScrollH,
+ kIconSvg_CropIcon, kIconSvg_Save, kIconSvg_Cancel, kIconSvg_Confirm,
+ kIconSvg_CropDiscardTop, kIconSvg_CropDiscardBottom,
+ kIconSvg_CropDiscardLeft, kIconSvg_CropDiscardRight, kIconSvg_CropReset,
+ };
+ for (int i = 0; i < LCI_Count; i++) {
+ if (!svgs[i]) continue;
+ dark[i] = RenderSvgToBitmap(svgs[i], RGB(60, 60, 60), physicalPx);
+ blue[i] = RenderSvgToBitmap(svgs[i], RGB(9, 105, 218), physicalPx);
+ gray[i] = RenderSvgToBitmap(svgs[i], RGB(178, 178, 178), physicalPx);
+ }
+ px = physicalPx;
+ inited = true;
+ }
+
+ void Cleanup() {
+ for (int i = 0; i < LCI_Count; i++) {
+ if (dark[i]) { DeleteObject(dark[i]); dark[i] = NULL; }
+ if (blue[i]) { DeleteObject(blue[i]); blue[i] = NULL; }
+ if (gray[i]) { DeleteObject(gray[i]); gray[i] = NULL; }
+ }
+ px = 0;
+ inited = false;
+ }
+};
+
+static LCIconCache s_lcIcons; // 长截图工具栏图标(单会话独占使用,随工具栏窗口销毁释放)
+
+
+// 把缓存图标位图居中绘制到按钮矩形(位图为预乘 ARGB,AlphaBlend per-pixel alpha)
+
+static void LongCaptureDrawIcon(HDC hdc, const RECT& rc, HBITMAP bmp, int px) {
+ if (!bmp || px <= 0) return;
+ int x = (rc.left + rc.right - px) / 2;
+ int y = (rc.top + rc.bottom - px) / 2;
+ HDC srcDC = CreateCompatibleDC(hdc);
+ if (!srcDC) return;
+ HGDIOBJ old = SelectObject(srcDC, bmp);
+ BLENDFUNCTION blend = {};
+ blend.BlendOp = AC_SRC_OVER;
+ blend.SourceConstantAlpha = 255;
+ blend.AlphaFormat = AC_SRC_ALPHA;
+ AlphaBlend(hdc, x, y, px, px, srcDC, 0, 0, px, px, blend);
+ SelectObject(srcDC, old);
+ DeleteDC(srcDC);
+}
+
+// 绘制图标按钮三态:hover/active 浅蓝圆角底 + 蓝色图标,disabled 灰图标,常态深灰图标。
+// 分层渲染目标:圆角底画进 Graphics(32bpp 预乘 ARGB 表面),图标经 AlphaBlend 写入
+// iconDC(GDI AlphaBlend 对预乘 ARGB 源/目标均做正确 alpha 混合,GDI+ 位图拷贝会丢 alpha)。
+
+static void LongCaptureDrawIconButton(Gdiplus::Graphics* g, HDC iconDC, const RECT& rc, LCIconId id,
+ bool hover, bool active, bool disabled, double ds) {
+ if (!disabled && (hover || active)) {
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top,
+ (int)(6 * ds + 0.5));
+ Gdiplus::SolidBrush brush(Gdiplus::Color(255,
+ active ? 225 : 235, active ? 237 : 243, active ? 253 : 255));
+ g->FillPath(&brush, &path);
+ }
+ HBITMAP bmp = disabled ? s_lcIcons.gray[id]
+ : (hover || active) ? s_lcIcons.blue[id]
+ : s_lcIcons.dark[id];
+ LongCaptureDrawIcon(iconDC, rc, bmp, s_lcIcons.px);
+}
+
+// 绘制工具栏最左「6 点拖拽把手」:2 列 × 3 排共 6 个小圆点,居中于把手单元格。
+// hover/拖拽中铺与按钮一致的浅蓝圆角底、圆点转主题蓝;可拖光标由 WM_SETCURSOR 切换。
+
+static void LongCaptureDrawGrip(Gdiplus::Graphics* g, const RECT& rc, bool hot, double ds) {
+ if (hot) {
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top,
+ (int)(6 * ds + 0.5));
+ Gdiplus::SolidBrush brush(ScOpaqueColor(SC_THEME_HOVER_BG));
+ g->FillPath(&brush, &path);
+ }
+ // 圆点几何随单元格尺寸(DPI)缩放:列距 ±11%、行距 0/±16%、点半径 ~5%
+ float cw = (float)(rc.right - rc.left);
+ float cx = ((float)rc.left + (float)rc.right) * 0.5f;
+ float cy = ((float)rc.top + (float)rc.bottom) * 0.5f;
+ float colGap = cw * 0.11f;
+ float rowGap = cw * 0.16f;
+ float r = (std::max)(1.2f, cw * 0.05f);
+ Gdiplus::SolidBrush dot(hot ? Gdiplus::Color(255, 9, 105, 218)
+ : Gdiplus::Color(255, 165, 165, 165));
+ for (int row = -1; row <= 1; row++) {
+ for (int col = -1; col <= 1; col += 2) {
+ float dx = cx + col * colGap;
+ float dy = cy + row * rowGap;
+ g->FillEllipse(&dot, dx - r, dy - r, r * 2, r * 2);
+ }
+ }
+}
+
+// 二级菜单 popover 第 i 个 cell 的图标:方向 = 纵向/横向变体;裁剪 = 丢弃起点/终点
+//(纵向=上方/下方,横向=左侧/右侧),已裁剪时末位追加重置(与 cell 序/tooltip 一致)
+
+static LCIconId LongCaptureMenuCellIcon(const LongCaptureContext* c, int i) {
+ if (c && c->menuKind == LCM_Direction) return i == 0 ? LCI_DirectionV : LCI_DirectionH;
+ if (c && c->cropped && i == LongCaptureMenuRows(c) - 1) return LCI_CropReset;
+ if (c && c->horizontal) return i == 0 ? LCI_CropDiscardLeft : LCI_CropDiscardRight;
+ return i == 0 ? LCI_CropDiscardTop : LCI_CropDiscardBottom;
+}
+
+
+// ---- 工具栏分层渲染(WS_EX_LAYERED + UpdateLayeredWindow)----
+// 旧实现直接在 WM_PAINT 里把底条/菜单逐元素画到屏幕 DC:鼠标扫过按钮时整窗重绘
+// 可见中间态(闪动);窗口外形用 SetWindowRgn 圆角区域裁剪,1bit 掩码无抗锯齿
+//(圆角锯齿)。改为整幅渲染进 32bpp 预乘 ARGB 后备 DIB 后一次 UpdateLayeredWindow
+// 原子提交:提交瞬间完成、画面无中间帧(消闪动),圆角/边缘经 alpha 通道抗锯齿
+//(消锯齿),底条与 popover 之间的条带保持全透明(透出桌面且鼠标穿透,等价于旧的
+// 联合窗口区域)。灰蒙版窗口(EnterLongCaptureMask)即同款分层渲染先例。
+
+// 工具栏后备表面(随工具栏窗口销毁释放,见 LongCaptureToolbarWndProc 的 WM_DESTROY)
+static HDC s_lcTbSurfDC = NULL;
+static HBITMAP s_lcTbSurfBmp = NULL;
+static void* s_lcTbSurfBits = nullptr;
+static int s_lcTbSurfW = 0, s_lcTbSurfH = 0;
+
+
+
+// 分层表面上绘制单行文本(GDI 文本函数不写 alpha 通道,分层窗口必须走 GDI+):
+// 水平/垂直居中于 rc,不换行;默认超宽省略号截断(宽×高标签固定槽位的兜底),
+// allowEllipsis=false 时关闭截断——tooltip 全文展示用,气泡尺寸已按同款测量预留,
+// 不应出现「…」。字体微软雅黑 12px×ds,抗锯齿网格适配。
+static void LongCaptureDrawSurfaceText(Gdiplus::Graphics* g, const wchar_t* text,
+ const RECT& rc, double ds, const Gdiplus::Color& color,
+ bool allowEllipsis = true) {
+ if (!g || !text || !*text) return;
+ Gdiplus::FontFamily ff(SC_FONT_FACE);
+ Gdiplus::Font fnt(&ff, (Gdiplus::REAL)(12 * ds + 0.5),
+ Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
+ Gdiplus::StringFormat sf;
+ sf.SetAlignment(Gdiplus::StringAlignmentCenter);
+ sf.SetLineAlignment(Gdiplus::StringAlignmentCenter);
+ sf.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap);
+ sf.SetTrimming(allowEllipsis ? Gdiplus::StringTrimmingEllipsisCharacter
+ : Gdiplus::StringTrimmingNone);
+ Gdiplus::RectF layout((Gdiplus::REAL)rc.left, (Gdiplus::REAL)rc.top,
+ (Gdiplus::REAL)(rc.right - rc.left),
+ (Gdiplus::REAL)(rc.bottom - rc.top));
+ Gdiplus::SolidBrush br(color);
+ g->SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
+ g->DrawString(text, -1, &fnt, layout, &sf, &br);
+}
+
+
+// 整幅渲染工具栏(底条 + 展开中的二级菜单 popover)并以 ULW 原子提交。
+// dstX/dstY/w/h 为目标窗口矩形(屏幕物理坐标):ULW 的 pptDst/psize 与内容一次提交,
+// 菜单开合改变窗口尺寸时不存在「先伸缩后绘制」的中间帧。hover/active 状态全部取自
+// LongCaptureContext,任何状态变化后重调本函数即可(经 LongCaptureToolbarRepaint)。
+void LongCaptureToolbarRender(LongCaptureContext* c, int dstX, int dstY, int w, int h) {
+ HWND tb = g_longToolbarWindow;
+ if (!c || !tb || w <= 0 || h <= 0) return;
+ if (!EnsureArgbSurface(s_lcTbSurfDC, s_lcTbSurfBmp, s_lcTbSurfBits,
+ s_lcTbSurfW, s_lcTbSurfH, w, h))
+ return;
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, w, h);
+ double ds = c->dpiScale;
+ int radius = (int)(LC_BAR_RADIUS * ds + 0.5);
+ {
+ Gdiplus::Bitmap surf(w, h, w * 4, PixelFormat32bppPARGB, (BYTE*)s_lcTbSurfBits);
+ Gdiplus::Graphics g(&surf);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ // 1) 清全透明(底条 ∪ popover 之外 alpha=0:透出桌面且鼠标穿透)
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ Gdiplus::SolidBrush clear(Gdiplus::Color(0, 0, 0, 0));
+ g.FillRectangle(&clear, 0, 0, w, h);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ // 2) 二级菜单 popover 面板:白色圆角底 + 浅灰描边,内为单行图标 cell
+ // (方向=纵向/横向,当前方向 active 蓝底、锁定方向灰显;裁剪=丢弃/重置)
+ if (L.menuRows > 0) {
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, L.menu.left, L.menu.top,
+ L.menu.right - L.menu.left, L.menu.bottom - L.menu.top, radius);
+ Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
+ g.FillPath(&white, &path);
+ Gdiplus::Pen border(Gdiplus::Color(255, 210, 210, 210), 1.0f);
+ g.DrawPath(&border, &path);
+ int curDir = c->horizontal ? 1 : 0;
+ for (int i = 0; i < L.menuRows; i++) {
+ bool enabled = LongCaptureMenuRowEnabled(c, i);
+ bool active = c->menuKind == LCM_Direction && i == curDir;
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, LongCapturePopoverCellRect(c, L, i),
+ LongCaptureMenuCellIcon(c, i),
+ enabled && i == c->menuHover, active, !enabled, ds);
+ }
+ }
+ // 3) 底条背景:白色圆角 + 浅灰描边
+ {
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, L.bar.left, L.bar.top,
+ L.bar.right - L.bar.left, L.bar.bottom - L.bar.top, radius);
+ Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
+ g.FillPath(&white, &path);
+ Gdiplus::Pen border(Gdiplus::Color(255, 210, 210, 210), 1.0f);
+ g.DrawPath(&border, &path);
+ }
+ // 4) 分隔线(GDI+ 1px 竖线;x+0.5 偏移使整数坐标下恰好落在单像素列)
+ {
+ Gdiplus::Pen sepPen(Gdiplus::Color(255, 230, 230, 230), 1.0f);
+ const int seps[2] = {LTI_Sep1, LTI_Sep3};
+ for (int s = 0; s < 2; s++) {
+ const RECT& r = L.items[seps[s]];
+ if (r.right <= r.left) continue;
+ Gdiplus::REAL x = (Gdiplus::REAL)((r.left + r.right) / 2) + 0.5f;
+ g.DrawLine(&sepPen, x, (Gdiplus::REAL)r.top, x, (Gdiplus::REAL)r.bottom);
+ }
+ }
+ // 5) 尺寸标签(预览宽×高,随拼接/裁剪实时变化)
+ {
+ wchar_t label[64];
+ LongCaptureOutputSizeLabel(c, label, 64);
+ LongCaptureDrawSurfaceText(&g, label, L.items[LTI_Size], ds,
+ Gdiplus::Color(255, 130, 130, 130));
+ }
+ // 6) 图标按钮(hover/active/disabled 三态;方向与自动滚动图标随当前方向切换变体)
+ // 最左先画「6 点拖拽把手」(拖拽中保持热态高亮)
+ bool dirLocked = c->frameCount.load() > 1; // 已拼接多帧:方向锁定(坐标系不同不可混拼)
+ LongCaptureDrawGrip(&g, L.items[LTI_Grip],
+ c->tbDragging || c->tbHover == LTI_Grip, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_Direction],
+ c->horizontal ? LCI_DirectionH : LCI_DirectionV,
+ c->tbHover == LTI_Direction || c->menuKind == LCM_Direction,
+ false, dirLocked, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_AutoScroll],
+ c->horizontal ? LCI_AutoScrollH : LCI_AutoScrollV,
+ c->tbHover == LTI_AutoScroll, c->autoScroll, false, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_Crop], LCI_Crop,
+ c->tbHover == LTI_Crop || c->menuKind == LCM_Crop,
+ c->cropped, false, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_Save], LCI_Save,
+ c->tbHover == LTI_Save, false, false, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_Cancel], LCI_Cancel,
+ c->tbHover == LTI_Cancel, false, false, ds);
+ LongCaptureDrawIconButton(&g, s_lcTbSurfDC, L.items[LTI_Finish], LCI_Confirm,
+ c->tbHover == LTI_Finish, false, false, ds);
+ }
+ POINT src = {0, 0};
+ SIZE sz = {w, h};
+ POINT dst = {dstX, dstY};
+ BLENDFUNCTION bf = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
+ UpdateLayeredWindow(tb, NULL, &dst, &sz, s_lcTbSurfDC, &src, 0, &bf, ULW_ALPHA);
+}
+
+
+// 按当前窗口矩形重绘工具栏(所有视觉状态变化的统一重绘入口:
+// hover 跟踪、菜单开合、宽×高标签刷新、自动滚动开关等)
+
+void LongCaptureToolbarRepaint() {
+ LongCaptureContext* c = g_longCtx.load();
+ HWND tb = g_longToolbarWindow;
+ if (!c || !tb) return;
+ RECT wr;
+ GetWindowRect(tb, &wr);
+ LongCaptureToolbarRender(c, wr.left, wr.top, wr.right - wr.left, wr.bottom - wr.top);
+}
+
+
+// 工具栏底条按钮的 title 式 tooltip 文案(返回 nullptr 表示该项无 tooltip,如宽×高标签)
+
+static const wchar_t* LongCaptureToolbarItemTip(const LongCaptureContext* c, int item) {
+ if (!c) return nullptr;
+ switch (item) {
+ case LTI_Grip: return L"拖动工具栏";
+ case LTI_Direction:
+ return c->frameCount.load() > 1 ? L"滚动方向(已拼接多帧后锁定)" : L"滚动方向";
+ case LTI_AutoScroll: return L"自动滚动";
+ case LTI_Crop: return L"裁剪";
+ case LTI_Save: return L"保存到本地";
+ case LTI_Cancel: return L"取消";
+ case LTI_Finish: return L"完成并复制";
+ default: return nullptr;
+ }
+}
+
+
+// 临时摘除/恢复长截图窗口组的置顶(保存对话框等系统弹窗需要真正置顶)
+
+void LongCaptureSetTopmost(bool topmost) {
+ HWND targets[3] = { g_longMaskWindow, g_longControlWindow, g_longToolbarWindow };
+ for (HWND h : targets) {
+ if (h) SetWindowPos(h, topmost ? HWND_TOPMOST : HWND_NOTOPMOST, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
+ }
+}
+
+
+// 自动滚动注入:以固定节拍发送小幅滚轮增量实现连续平滑滚动、完全不移动光标。
+// 光标已在开启时一次性移到选区中心(LongCaptureSetAutoScroll),SendInput 按
+// 光标下方窗口路由(Win10+ 默认「滚动非活动窗口」),因此选区下的目标窗口持续被
+// 滚动而鼠标位置全程不变。增量小于一个 notch(WHEEL_DELTA):支持高分辨率滚轮的
+// 应用(浏览器/Electron/现代 UI 均属此类)会按比例精确累积,视觉上即为连续滚动;
+// 传统应用按内部余量规整累计,也不会再出现大跳步。注入的滚轮同样会被本会话的
+// Raw Input 观察捕获,采样主循环走既有的「滚动中主动采样」路径,无需停稳。
+static void LongCaptureAutoScrollTick(LongCaptureContext* c) {
+ if (c->reachedBottom || c->abortFlag.load() || c->finishFlag.load() || c->saveFlag.load()) {
+ LongCaptureSetAutoScroll(c, false);
+ return;
+ }
+ INPUT inp = {};
+ inp.type = INPUT_MOUSE;
+ if (c->horizontal) {
+ inp.mi.dwFlags = MOUSEEVENTF_HWHEEL;
+ inp.mi.mouseData = LC_AUTOSCROLL_STEP_DELTA; // 向右滚 = 追加尾部
+ } else {
+ inp.mi.dwFlags = MOUSEEVENTF_WHEEL;
+ inp.mi.mouseData = -LC_AUTOSCROLL_STEP_DELTA; // 向下滚 = 追加尾部
+ }
+ SendInput(1, &inp, sizeof(INPUT));
+}
+
+
+void LongCaptureSetAutoScroll(LongCaptureContext* c, bool on) {
+ if (!c || c->autoScroll == on) return;
+ c->autoScroll = on;
+ c->autoFailStreak = 0;
+ if (on) {
+ c->noChangeCount = 0;
+ c->reachedBottom = false;
+ // 开启时一次性把光标移到选区中心:后续每拍只注入滚轮、不再改动鼠标位置
+ //(光标停驻选区中心 = SendInput 持续路由到选区下的目标窗口;用户仍可随时
+ // 手动移开鼠标接管操作)。
+ int px = c->physOriginX + c->physX + c->capW / 2;
+ int py = c->physOriginY + c->physY + c->capH / 2;
+ SetCursorPos(px, py);
+ // 注入节拍:固定的高频小步长(连续平滑滚动)。用户 interval 只继续作为采样
+ // 防抖参数使用——拼接管线按实际位移自适应,与注入节奏彻底解耦。
+ SetTimer(g_longToolbarWindow, LC_TIMER_AUTOSCROLL, LC_AUTOSCROLL_TICK_MS, NULL);
+ } else {
+ KillTimer(g_longToolbarWindow, LC_TIMER_AUTOSCROLL);
+ }
+ LongCaptureToolbarRepaint();
+}
+
+
+// 裁剪:收紧输出行窗口并登记「待剔除区间」(以当前 committed 视口为基准的内容坐标)。
+// 此刻拼接缓冲与匹配基准完全不动——被裁掉的内容仍在缓冲里,「重置裁剪」可完整恢复;
+// 此后第一次朝被裁方向的成功提交会物理删除该区间并把该侧边界重新开放,继续滚动的
+// 新增内容从裁剪线直接续接拼图(见 CommitStitch 入口的延迟剔除)。横向模式天然成立:
+// 帧缓冲转置复用纵向管线,「丢弃上方/下方」即「丢弃左侧/右侧」。
+// 反复裁剪按「只收紧不放宽」合并:同侧锚点取更紧者,且每次都按最终生效锚点重新推导
+// 待剔除区间(恒等闭包 = 捕获外沿 ↔ 裁剪线),不会因连续裁剪产生重叠或遗漏。
+
+void LongCaptureApplyCrop(LongCaptureContext* c, int row) {
+ if (!c || c->stitchH <= 0) return;
+ int n = c->cropped ? 3 : 2; // 裁剪菜单行数(末行=重置,仅已裁剪时存在)
+ if (row < 0 || row >= n) return;
+ if (row == n - 1 && c->cropped) {
+ // 重置裁剪:清空全部锚点与待剔除区间(尚未执行的剔除随之作废、内容完整恢复;
+ // 已触发执行的删除不可逆)
+ c->cropPendTop = false;
+ c->cropPendBottom = false;
+ c->cropTopY = INT64_MIN;
+ c->cropBottomY = INT64_MAX;
+ c->cropped = false;
+ } else {
+ // 裁剪线锚定在当前视口边沿(内容坐标);反向有效边界 = 已设置的锚点或捕获
+ // 外沿中的更紧者,保证窗口至少保留一行
+ int64_t vpTopY = c->committedContentTop;
+ int64_t vpBottomY = vpTopY + c->physH;
+ int64_t capTopY = -(int64_t)c->headRows;
+ int64_t capBottomY = (int64_t)c->bodyRows;
+ if (row == 0) {
+ // 丢弃上方(横向 = 左侧):窗口顶收到当前视口顶(越靠下 = 收得越紧)
+ int64_t botEff = (std::min)(c->cropBottomY, capBottomY);
+ int64_t cut = vpTopY < botEff - 1 ? vpTopY : botEff - 1;
+ if (c->cropTopY == INT64_MIN || cut > c->cropTopY) c->cropTopY = cut;
+ int64_t pendHi = c->cropTopY;
+ c->cropPendTop = pendHi > capTopY;
+ if (c->cropPendTop) {
+ c->cropPendTopLo = capTopY;
+ c->cropPendTopHi = pendHi;
+ }
+ } else {
+ // 丢弃下方(横向 = 右侧):窗口底收到当前视口底(越靠上 = 收得越紧)
+ int64_t topEff = (std::max)(c->cropTopY, capTopY);
+ int64_t cut = vpBottomY > topEff + 1 ? vpBottomY : topEff + 1;
+ if (c->cropBottomY == INT64_MAX || cut < c->cropBottomY) c->cropBottomY = cut;
+ int64_t pendLo = c->cropBottomY;
+ c->cropPendBottom = capBottomY > pendLo;
+ if (c->cropPendBottom) {
+ c->cropPendBottomLo = pendLo;
+ c->cropPendBottomHi = capBottomY;
+ }
+ }
+ c->cropped = true;
+ }
+ LongCapturePanelUpdate(c);
+ if (g_longControlWindow) InvalidateRect(g_longControlWindow, NULL, FALSE);
+ LongCaptureToolbarRepaint();
+}
+
+
+// ---- title 式 tooltip(网页 title 属性同款交互:图标悬停 ~0.5s 出现,深色圆角小气泡,
+// 锚定目标按钮上方/下方;工具栏按钮与裁剪 popover 图标共用;随工具栏窗口销毁释放)----
+
+static HWND g_lcTooltipWindow = NULL;
+static std::wstring s_lcTooltipText; // 当前显示文本(窗口过程绘制时读取)
+
+// tooltip 气泡内边距(逻辑像素,随 DPI 缩放;Show 与 WndProc 各自按同式计算)
+
+static void LongCaptureTipPadding(double ds, int& padX, int& padY) {
+ padX = (int)(8 * ds + 0.5);
+ padY = (int)(5 * ds + 0.5);
+}
+
+// tooltip 窗口过程:分层窗口(WS_EX_LAYERED),内容由 LongCaptureTooltipRender 整幅
+// 经 UpdateLayeredWindow 维护,WM_PAINT 仅清验证区(深色圆角底 + 白色文本在渲染函数里画)
+
+static LRESULT CALLBACK LongCaptureTooltipWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
+ switch (msg) {
+ case WM_PAINT: {
+ PAINTSTRUCT ps;
+ BeginPaint(hwnd, &ps);
+ EndPaint(hwnd, &ps);
+ return 0;
+ }
+ case WM_SETCURSOR: { SetCursor(LoadCursor(NULL, IDC_ARROW)); return TRUE; }
+ case WM_NCHITTEST: return HTCLIENT;
+ case WM_ERASEBKGND: return 1;
+ }
+ return DefWindowProc(hwnd, msg, wp, lp);
+}
+
+// tooltip 后备表面(与工具栏表面同款机制;随工具栏销毁一并释放)
+static HDC s_lcTipSurfDC = NULL;
+static HBITMAP s_lcTipSurfBmp = NULL;
+static void* s_lcTipSurfBits = nullptr;
+static int s_lcTipSurfW = 0, s_lcTipSurfH = 0;
+
+// tooltip 分层渲染:清透明 → 深色圆角底(圆角经 alpha 抗锯齿,无区域裁剪锯齿)→
+// 白色居中文本(GDI+,GDI 文本不写 alpha 通道)→ ULW 原子提交。
+// 注意:ULW 必须显式携带目标几何(dstX/dstY/w/h)——实测 NULL 几何(仅靠此前
+// SetWindowPos 定位)时返回成功但 DWM 不合成该窗口,屏幕上不可见;
+// 工具栏同款显式几何路径则始终正常。
+static void LongCaptureTooltipRender(LongCaptureContext* c, int dstX, int dstY, int w, int h) {
+ if (!g_lcTooltipWindow) return;
+ if (!EnsureArgbSurface(s_lcTipSurfDC, s_lcTipSurfBmp, s_lcTipSurfBits,
+ s_lcTipSurfW, s_lcTipSurfH, w, h))
+ return;
+ double ds = c ? c->dpiScale : 1.0;
+ {
+ Gdiplus::Bitmap surf(w, h, w * 4, PixelFormat32bppPARGB, (BYTE*)s_lcTipSurfBits);
+ Gdiplus::Graphics g(&surf);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceCopy);
+ Gdiplus::SolidBrush clear(Gdiplus::Color(0, 0, 0, 0));
+ g.FillRectangle(&clear, 0, 0, w, h);
+ g.SetCompositingMode(Gdiplus::CompositingModeSourceOver);
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, 0, 0, w, h, (int)(4 * ds + 0.5));
+ Gdiplus::SolidBrush bg(Gdiplus::Color(255, 41, 41, 41));
+ g.FillPath(&bg, &path);
+ if (!s_lcTooltipText.empty()) {
+ int padX, padY;
+ LongCaptureTipPadding(ds, padX, padY);
+ RECT tr = {padX, padY, w - padX, h - padY};
+ LongCaptureDrawSurfaceText(&g, s_lcTooltipText.c_str(), tr, ds,
+ Gdiplus::Color(255, 255, 255, 255), false);
+ }
+ }
+ POINT src = {0, 0};
+ SIZE sz = {w, h};
+ POINT dst = {dstX, dstY};
+ BLENDFUNCTION bf = {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA};
+ UpdateLayeredWindow(g_lcTooltipWindow, NULL, &dst, &sz, s_lcTipSurfDC, &src, 0, &bf, ULW_ALPHA);
+}
+
+// tooltip 是否正在显示(UiTick 判定「目标稳定且尚未显示」时用)
+
+static bool LongCaptureTooltipVisible() {
+ return g_lcTooltipWindow && IsWindowVisible(g_lcTooltipWindow);
+}
+
+// 显示 tooltip:按文本测量定尺寸,锚定目标矩形(屏幕坐标)——优先上方,放不下转下方;
+// 水平居中于锚点并夹在虚拟屏幕内。窗口懒创建,复用至工具栏销毁。
+
+static void LongCaptureTooltipShow(LongCaptureContext* c, const wchar_t* text, const RECT& anchor) {
+ if (!c || !text || !*text) return;
+ double ds = c->dpiScale;
+ int padX, padY;
+ LongCaptureTipPadding(ds, padX, padY);
+ // 文本测量:必须与绘制同为 GDI+(MeasureString + 同款 Font/StringFormat)。
+ // 同字号下 GDI DrawText 的测宽小于 GDI+ DrawString 的实际渲染宽度(GDI 无
+ // overhang),按 GDI 测量定的气泡曾量窄,绘制端省略号把文案截成「…」。
+ HDC screen = GetDC(NULL);
+ if (!screen) return;
+ Gdiplus::Graphics g(screen);
+ Gdiplus::FontFamily ff(SC_FONT_FACE);
+ Gdiplus::Font fnt(&ff, (Gdiplus::REAL)(12 * ds + 0.5),
+ Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
+ Gdiplus::StringFormat sf;
+ sf.SetFormatFlags(Gdiplus::StringFormatFlagsNoWrap);
+ Gdiplus::RectF layout(0.0f, 0.0f, 65536.0f, 65536.0f), bound;
+ g.MeasureString(text, -1, &fnt, layout, &sf, &bound);
+ ReleaseDC(NULL, screen);
+ int w = (int)(bound.Width + 0.5f) + padX * 2 + 2; // +2 抗锯齿边缘余量,杜绝触发截断
+ int h = (int)(bound.Height + 0.5f) + padY * 2 + 2;
+ int gap = (int)(6 * ds + 0.5);
+ int vx = GetSystemMetrics(SM_XVIRTUALSCREEN), vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
+ int vw = GetSystemMetrics(SM_CXVIRTUALSCREEN), vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
+ int x = (anchor.left + anchor.right) / 2 - w / 2;
+ if (x < vx + 4) x = vx + 4;
+ if (x + w > vx + vw - 4) x = vx + vw - 4 - w;
+ int y = anchor.top - h - gap;
+ if (y < vy + 4) y = anchor.bottom + gap;
+ if (!g_lcTooltipWindow) {
+ WNDCLASSEXW wc = {};
+ wc.cbSize = sizeof(wc);
+ wc.lpfnWndProc = LongCaptureTooltipWndProc;
+ wc.hInstance = GetModuleHandle(NULL);
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW);
+ wc.lpszClassName = L"ZToolsLcTooltip";
+ RegisterClassExW(&wc);
+ g_lcTooltipWindow = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED,
+ L"ZToolsLcTooltip", L"", WS_POPUP, x, y, w, h, NULL, NULL, GetModuleHandle(NULL), NULL);
+ if (!g_lcTooltipWindow) return;
+ }
+ s_lcTooltipText = text;
+ // 与工具栏同款提交流程:ULW 原子提交内容+几何(必须显式携带,见 Render 注释),
+ // 隐藏状态下先提交再显示;已显示时 ULW 即时更新位置/尺寸/内容。
+ LongCaptureTooltipRender(c, x, y, w, h);
+ if (!IsWindowVisible(g_lcTooltipWindow))
+ ShowWindow(g_lcTooltipWindow, SW_SHOWNOACTIVATE);
+}
+
+// 收起 tooltip(不销毁窗口;下次满足停顿时重新显示)
+
+static void LongCaptureTooltipHide() {
+ if (LongCaptureTooltipVisible()) ShowWindow(g_lcTooltipWindow, SW_HIDE);
+}
+
+// tooltip 停顿判定状态(UiTick 轮询维护;Cancel 供点击路径重置,点击后需重新停顿才再显示)
+// 目标编码:0..LTI_Count-1 = 底条按钮;100 + menuKind*10 + cell = 二级菜单 popover 的
+// 图标 cell(方向/裁剪各自独立编号,跨菜单切换后同号 cell 不会误判为同一目标)
+
+static int s_lcTipTarget = -1;
+static DWORD s_lcTipSince = 0;
+
+// 立即收起 tooltip 并清零停顿(点击任何位置后调用,网页 title 点击即隐藏同款)
+
+static void LongCaptureTooltipCancel() {
+ LongCaptureTooltipHide();
+ s_lcTipTarget = -1;
+ s_lcTipSince = 0;
+}
+
+
+// 计算二级菜单展开方向:朝「避让选区」的一侧展开。菜单浮层绝不能盖进选区——
+// 选区内容正被逐帧采样做重叠识别拼接,浮层一旦入画即污染匹配基准(重叠识别对画面
+// 突变极其敏感,污染帧虽会被安全拒绝,但会拖垮采样成功率甚至触发误判)。
+// 规则:底条在选区下方 → 菜单向下展开;底条在选区上方 → 向上展开;底条与选区重叠的
+// 兜底形态(选区几乎全屏时)选屏幕空余较大的一侧。仅当远离侧放不下时才翻到另一侧
+//(此时可能盖到选区边角:菜单是点击即散的瞬态浮层,污染帧会被匹配管线安全拒绝)。
+
+static bool LongCaptureMenuOpenBelow(LongCaptureContext* c, const RECT& bar) {
+ double ds = c->dpiScale;
+ int gap = (int)(LC_MENU_GAP * ds + 0.5);
+ int menuH = (int)(LongCaptureMenuHeightLogi(c) * ds + 0.5);
+ bool below;
+ if (bar.top >= c->selection.bottom - 2) below = true;
+ else if (bar.bottom <= c->selection.top + 2) below = false;
+ else below = (bar.top - c->vy) < (c->vy + c->vh - bar.bottom);
+ // 屏幕空余判定用「工具栏所在显示器」边界:多屏异分辨率时整虚拟屏幕包围盒
+ // 被高分屏拉大,低分屏上会误判空余方向/放不下(与 CalcToolbarPosition 同因同修)
+ RECT mon = {};
+ int scrTop = c->vy, scrBottom = c->vy + c->vh;
+ if (GetMonitorBoundsForRect(bar, mon)) { scrTop = mon.top; scrBottom = mon.bottom; }
+ if (below && bar.bottom + menuH + gap > scrBottom) below = false;
+ else if (!below && bar.top - menuH - gap < scrTop) below = true;
+ return below;
+}
+
+
+// 展开二级菜单(LCM_Direction/LCM_Crop)或收起(LCM_None):通过扩展/收缩工具栏窗口
+// 实现,底条屏幕位置保持不动、只有菜单区伸缩;展开方向由 LongCaptureMenuOpenBelow
+// 决定。菜单间直接切换(方向↔裁剪)时先按旧形态还原底条位置。
+// 窗口伸缩与内容渲染由 LongCaptureToolbarRender 里的 UpdateLayeredWindow 一次原子提交
+//(pptDst/psize 与位图同时更新,无「先伸缩后绘制」的中间帧);底条与 popover 面板
+// 之外的条带保持全透明(透底、鼠标穿透),popover 视觉上是脱离底条的独立浮层。
+// 菜单开/关/切换同时收起 tooltip 并重置停顿(布局已变,旧锚点不再有效——尤其跨菜单
+// 切换后 cell 编号相同的目标必须重新计时,避免陈旧目标吞掉新 tooltip)。
+
+void LongCaptureSetMenu(LongCaptureContext* c, LCMenuKind kind) {
+ if (!c || c->menuKind == kind) return;
+ HWND tb = g_longToolbarWindow;
+ LCMenuKind oldKind = c->menuKind;
+ bool oldBelow = c->menuBelow;
+ c->menuKind = kind;
+ c->menuHover = -1;
+ LongCaptureTooltipCancel();
+ if (!tb) return;
+ double ds = c->dpiScale;
+ int barH = (int)(LC_BAR_H * ds + 0.5);
+ int gap = (int)(LC_MENU_GAP * ds + 0.5);
+ RECT wr;
+ GetWindowRect(tb, &wr);
+ // 底条在屏幕上的固定矩形(当前窗口可能含旧菜单区:menuBelow 时底条是顶部切片,
+ // 否则是底部切片)
+ RECT bar = wr;
+ if (oldKind != LCM_None) {
+ if (oldBelow) bar.bottom = wr.top + barH;
+ else bar.top = wr.bottom - barH;
+ }
+ int newW = wr.right - wr.left;
+ if (kind == LCM_None) {
+ LongCaptureToolbarRender(c, bar.left, bar.top, newW, barH);
+ return;
+ }
+ int menuH = (int)(LongCaptureMenuHeightLogi(c) * ds + 0.5);
+ c->menuBelow = LongCaptureMenuOpenBelow(c, bar);
+ int y = c->menuBelow ? bar.top : bar.top - menuH - gap;
+ int newH = barH + menuH + gap;
+ LongCaptureToolbarRender(c, bar.left, y, newW, newH);
+}
+
+
+// 方向切换后的完全重置:清空全部拼接/跟踪/历史状态并重抓首帧(首帧坐标系随方向改变)。
+
+void LongCaptureResetSession(LongCaptureContext* c) {
+ c->headRev.clear();
+ c->body.clear();
+ c->headRows = c->bodyRows = c->stitchH = 0;
+ c->thumbHeadRev.clear();
+ c->thumbBody.clear();
+ c->thumbMerged.clear();
+ c->thumbHeadH = c->thumbH = 0;
+ c->thumbDirty = false;
+ c->thumbDisplay.clear();
+ c->thumbDisplayW = c->thumbDisplayH = 0;
+ c->thumbDisplayDirty = false;
+ c->lastFrame.clear();
+ c->lastMatch = LongMatchData();
+ c->frameHistory.clear();
+ c->weakCandidateOffsets.clear();
+ c->pendingMatch.valid = false;
+ c->offsetHistory.clear();
+ c->noChangeCount = 0;
+ c->reachedBottom = false;
+ c->weakTries = 0;
+ c->wheelAccumDelta = 0;
+ c->pixelsPerWheelNotch = 0.0f;
+ c->wheelPending = false;
+ c->stableRefValid = false;
+ c->stableRefGray.clear();
+ c->stableRefCols = c->stableRefH = 0;
+ c->lastFailReason = LCFailReason::None;
+ c->lastReject = LongMatchOutcome();
+ c->cropPendTop = false;
+ c->cropPendTopLo = c->cropPendTopHi = 0;
+ c->cropPendBottom = false;
+ c->cropPendBottomLo = c->cropPendBottomHi = 0;
+ c->cropTopY = INT64_MIN;
+ c->cropBottomY = INT64_MAX;
+ c->cropped = false;
+ c->autoFailStreak = 0;
+ std::vector buf;
+ if (LongCaptureInitFirstFrame(c, buf)) {
+ c->frameCount.store(1);
+ c->lastSampleTick = GetTickCount();
+ }
+ LongCapturePanelUpdate(c);
+ if (g_longControlWindow) InvalidateRect(g_longControlWindow, NULL, FALSE);
+ LongCaptureToolbarRepaint();
+}
+
+
+// 切换长截图方向(纵向 ↔ 横向)。已拼接多帧(frameCount > 1)后禁用:
+// 两个方向的内容坐标系不同,混拼必然产生错位,宁可要求用户取消重来。
+
+void LongCaptureSwitchDirection(LongCaptureContext* c) {
+ if (!c || c->frameCount.load() > 1) return;
+ c->horizontal = !c->horizontal;
+ // 帧缓冲转置复用纵向管线:physW/physH 交换(capW/capH 的屏幕采样 DIB 不变)
+ c->physW = c->horizontal ? c->capH : c->capW;
+ c->physH = c->horizontal ? c->capW : c->capH;
+ // 缩略图列宽随新帧宽重算(面板预览内宽,不超过帧宽)
+ int previewPx = (int)((LC_PANEL_W - 2 * LC_PANEL_PAD) * c->dpiScale + 0.5);
+ c->thumbW = (std::min)((std::max)(1, previewPx), c->physW);
+ LongCaptureResetSession(c);
+}
+
+
+// UI 维护节拍(100ms 轮询,NOACTIVATE 弹窗无焦点,只能轮询):
+// 1) 二级菜单展开时检测「窗口外左键按下」并关闭(按底条∪菜单面板区域精确判定,
+// 联合区域外的透底条带视为窗外);
+// 2) 悬停意图:方向/裁剪锚点按钮停留 LC_POP_OPEN_DWELL_MS 后展开对应 popover(另一
+// 菜单已展开时直接切换;扫过不误触);鼠标离开「锚点按钮∪popover」超过
+// LC_POP_CLOSE_GRACE_MS 后收起(宽限期足够跨过按钮与浮层之间的透底间隙);
+// 3) title 式 tooltip:悬停目标稳定 LC_TIP_DELAY_MS 后显示(目标切换即重置停顿)。
+
+// UiTick 的鼠标/二级菜单悬停计时状态:原为函数内 static,跨会话残留旧值会导致下一次
+// 长截图复用上次状态(如左键按下标志误以为仍按下、popover 误计悬停/离开时长)。
+// 提升为文件级 static 以便在工具栏窗口 WM_DESTROY 时复位(见 LongCaptureToolbarWndProc)。
+static bool s_lcTbLDown = false;
+static DWORD s_lcPopHoverSince = 0; // 光标连续悬在某个二级菜单锚点按钮上的起始时刻(0=不在)
+static DWORD s_lcPopLeaveSince = 0; // 光标离开「锚点按钮∪popover」的起始时刻(0=未离开)
+
+static void LongCaptureToolbarUiTick(LongCaptureContext* c, HWND tb) {
+ bool down = (GetAsyncKeyState(VK_LBUTTON) & 0x8000) != 0;
+ bool pressed = down && !s_lcTbLDown;
+ s_lcTbLDown = down;
+ if (!c || !tb) return;
+ POINT pt;
+ GetCursorPos(&pt);
+ POINT cl = pt;
+ ScreenToClient(tb, &cl);
+ RECT wr;
+ GetWindowRect(tb, &wr);
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, wr.right - wr.left, wr.bottom - wr.top);
+ int hv = LongCaptureHitTestToolbar(cl.x, cl.y, L);
+ int cell = c->menuKind != LCM_None ? LongCaptureHitTestPopover(c, cl.x, cl.y, L) : -1;
+ DWORD now = GetTickCount();
+ // 光标是否在窗口可见区域内(底条 ∪ 菜单面板;联合区域外的透底条带不算)
+ RECT barSc = L.bar, menuSc = L.menu;
+ MapWindowPoints(tb, NULL, (POINT*)&barSc, 2);
+ MapWindowPoints(tb, NULL, (POINT*)&menuSc, 2);
+ bool inside = PtInRect(&barSc, pt) || (L.menuRows > 0 && PtInRect(&menuSc, pt));
+ if (pressed && c->menuKind != LCM_None && !inside) {
+ LongCaptureSetMenu(c, LCM_None);
+ return;
+ }
+ // —— 二级 popover(方向/裁剪同款交互):悬停展开 / 悬停切换 / 离开收起 ——
+ // 点击收起过的锚点在光标移出该按钮前不再因悬停重开(popHoverDisarm,见 internal.h)
+ LCMenuKind hoverKind = LongCaptureHoverMenuKind(c, hv);
+ if (c->popHoverDisarm != LCM_None
+ && hv != LongCaptureMenuAnchorItem(c->popHoverDisarm)) {
+ c->popHoverDisarm = LCM_None; // 离开被解除武装的锚点按钮即恢复悬停展开
+ }
+ if (hoverKind != LCM_None && hoverKind != c->menuKind
+ && hoverKind != c->popHoverDisarm) {
+ if (!s_lcPopHoverSince) s_lcPopHoverSince = now;
+ if (now - s_lcPopHoverSince >= (DWORD)LC_POP_OPEN_DWELL_MS) {
+ LongCaptureSetMenu(c, hoverKind);
+ s_lcPopHoverSince = 0;
+ }
+ } else {
+ s_lcPopHoverSince = 0;
+ }
+ if (c->menuKind != LCM_None) {
+ // 「使用中」判定:popover cell、当前锚点,或正悬停准备切换的另一锚点,都不算离开
+ bool usingPop = cell >= 0 || hv == LongCaptureMenuAnchorItem(c->menuKind)
+ || hoverKind != LCM_None;
+ if (usingPop) {
+ s_lcPopLeaveSince = 0;
+ } else {
+ if (!s_lcPopLeaveSince) s_lcPopLeaveSince = now;
+ if (now - s_lcPopLeaveSince >= (DWORD)LC_POP_CLOSE_GRACE_MS) {
+ LongCaptureSetMenu(c, LCM_None);
+ s_lcPopLeaveSince = 0;
+ }
+ }
+ } else {
+ s_lcPopLeaveSince = 0;
+ }
+ // —— title 式 tooltip:底条按钮(无菜单时)或二级菜单 popover 图标 cell ——
+ int tipTarget = -1;
+ if (c->menuKind == LCM_None && hv >= 0 && hv != LTI_Size) tipTarget = hv;
+ else if (c->menuKind != LCM_None && cell >= 0)
+ tipTarget = 100 + (int)c->menuKind * 10 + cell;
+ if (tipTarget != s_lcTipTarget) {
+ s_lcTipTarget = tipTarget;
+ s_lcTipSince = now;
+ LongCaptureTooltipHide();
+ } else if (s_lcTipTarget >= 0 && !LongCaptureTooltipVisible()
+ && now - s_lcTipSince >= (DWORD)LC_TIP_DELAY_MS) {
+ int cellIdx = (s_lcTipTarget - 100) % 10;
+ RECT anchor = s_lcTipTarget >= 100
+ ? LongCapturePopoverCellRect(c, L, cellIdx)
+ : L.items[s_lcTipTarget];
+ MapWindowPoints(tb, NULL, (POINT*)&anchor, 2);
+ const wchar_t* text = s_lcTipTarget >= 100
+ ? LongCaptureMenuRowLabel(c, cellIdx)
+ : LongCaptureToolbarItemTip(c, s_lcTipTarget);
+ if (text && *text) LongCaptureTooltipShow(c, text, anchor);
+ }
+}
+
+
+// 工具栏窗口过程:hover 跟踪、点击分发、自动滚动/UI 定时器。窗口为分层
+//(WS_EX_LAYERED),全部视觉内容由 LongCaptureToolbarRender 整幅渲染并经
+// UpdateLayeredWindow 原子提交——WM_PAINT 不承载绘制(仅清验证区),
+// 消除逐元素直接画屏的中间态闪动。
+
+LRESULT CALLBACK LongCaptureToolbarWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
+ switch (msg) {
+ case WM_PAINT: {
+ // 分层窗口内容由 ULW 维护,这里只需清掉系统验证区
+ PAINTSTRUCT ps;
+ BeginPaint(hwnd, &ps);
+ EndPaint(hwnd, &ps);
+ return 0;
+ }
+ case WM_MOUSEMOVE: {
+ LongCaptureContext* c = g_longCtx.load();
+ if (!c) break;
+ // 把手拖拽中(鼠标捕获在本窗口):跟随鼠标平移工具栏窗口并钳制在虚拟
+ // 屏幕内。分层窗口内容与位置无关,SetWindowPos 移动即可,无需重渲染。
+ if (c->tbDragging && GetCapture() == hwnd) {
+ POINT pt = { GET_X_LPARAM(lp), GET_Y_LPARAM(lp) };
+ ClientToScreen(hwnd, &pt);
+ double ds = c->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ RECT wr;
+ GetWindowRect(hwnd, &wr);
+ int w = wr.right - wr.left, h = wr.bottom - wr.top;
+ int nx = pt.x - c->tbDragGrabDX, ny = pt.y - c->tbDragGrabDY;
+ int minX = c->vx + sc(4), minY = c->vy;
+ int maxX = (std::max)(minX, c->vx + c->vw - sc(4) - w);
+ int maxY = (std::max)(minY, c->vy + c->vh - h);
+ if (nx < minX) nx = minX;
+ if (nx > maxX) nx = maxX;
+ if (ny < minY) ny = minY;
+ if (ny > maxY) ny = maxY;
+ if (nx != wr.left || ny != wr.top)
+ SetWindowPos(hwnd, NULL, nx, ny, 0, 0,
+ SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
+ return 0;
+ }
+ TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hwnd, 0};
+ TrackMouseEvent(&tme);
+ int x = GET_X_LPARAM(lp), y = GET_Y_LPARAM(lp);
+ RECT cr;
+ GetClientRect(hwnd, &cr);
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, cr.right, cr.bottom);
+ int hv = LongCaptureHitTestToolbar(x, y, L);
+ int mh = c->menuKind != LCM_None ? LongCaptureHitTestPopover(c, x, y, L) : -1;
+ if (hv != c->tbHover || mh != c->menuHover) {
+ c->tbHover = hv;
+ c->menuHover = mh;
+ LongCaptureTooltipHide(); // 悬停目标切换立即收起 tooltip(停顿重置由 UiTick 轮询完成)
+ LongCaptureToolbarRepaint();
+ }
+ return 0;
+ }
+ case WM_MOUSELEAVE: {
+ LongCaptureContext* c = g_longCtx.load();
+ if (c && (c->tbHover != -1 || c->menuHover != -1)) {
+ c->tbHover = -1;
+ c->menuHover = -1;
+ LongCaptureToolbarRepaint();
+ }
+ return 0;
+ }
+ case WM_LBUTTONDOWN: {
+ // 记录按下目标:随后的 WM_LBUTTONUP 必须命中同一目标才触发动作(标准按钮
+ // 语义)。关键防误触场景:编辑工具栏「长截图」按钮在鼠标按下瞬间进入长截图,
+ // 本工具栏立即在附近生成——若「自动滚动」等按钮恰与原按钮屏幕同位,先前那次
+ // 点击残留的松开事件会直接落在它上面;没有先行 DOWN 记录的 UP 一律吞掉,
+ // 自动滚动因此绝不会被「进入长截图即选中」。
+ LongCaptureContext* c = g_longCtx.load();
+ if (!c) break;
+ int x = GET_X_LPARAM(lp), y = GET_Y_LPARAM(lp);
+ RECT cr;
+ GetClientRect(hwnd, &cr);
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, cr.right, cr.bottom);
+ // 按下最左「6 点把手」:收起二级菜单并进入工具栏窗口拖拽(捕获鼠标,
+ // UP 时结束)。拖拽不是点击,清空按下记录避免残留配对误触按钮;
+ // 抓取偏移必须在收起菜单之后计算——收起会同步收缩窗口(ULW 原子提交),
+ // 菜单在上侧时窗口顶边位置会变,先取偏移会把菜单高度算进抓取点。
+ if (LongCaptureHitTestToolbar(x, y, L) == LTI_Grip) {
+ if (c->menuKind != LCM_None) LongCaptureSetMenu(c, LCM_None);
+ RECT wr;
+ GetWindowRect(hwnd, &wr);
+ POINT pt = { x, y };
+ ClientToScreen(hwnd, &pt);
+ c->tbDragGrabDX = pt.x - wr.left;
+ c->tbDragGrabDY = pt.y - wr.top;
+ c->tbDragging = true;
+ c->tbPressItem = -1;
+ c->tbPressMenuRow = -1;
+ LongCaptureTooltipCancel();
+ SetCapture(hwnd);
+ return 0;
+ }
+ c->tbPressItem = LongCaptureHitTestToolbar(x, y, L);
+ c->tbPressMenuRow = c->menuKind != LCM_None ? LongCaptureHitTestPopover(c, x, y, L) : -1;
+ return 0;
+ }
+ case WM_LBUTTONUP: {
+ LongCaptureContext* c = g_longCtx.load();
+ if (!c) break;
+ // 把手拖拽结束:释放捕获并退出拖拽态,不落入按钮点击逻辑
+ //(位置已由 MOUSEMOVE 的 SetWindowPos 固化)
+ if (c->tbDragging) {
+ c->tbDragging = false;
+ c->tbPressItem = -1;
+ c->tbPressMenuRow = -1;
+ if (GetCapture() == hwnd) ReleaseCapture();
+ return 0;
+ }
+ int x = GET_X_LPARAM(lp), y = GET_Y_LPARAM(lp);
+ RECT cr;
+ GetClientRect(hwnd, &cr);
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, cr.right, cr.bottom);
+ LongCaptureTooltipCancel(); // 任何点击立即收起 tooltip 并清零停顿(网页 title 同款)
+ // 按下-抬起同目标校验(DOWN 记录见 WM_LBUTTONDOWN):菜单 cell 要求同 cell,
+ // 工具栏按钮要求同按钮且按下时不在 popover 上;不匹配直接吞掉本次 UP。
+ int hit = LongCaptureHitTestToolbar(x, y, L);
+ int menuRow = c->menuKind != LCM_None ? LongCaptureHitTestPopover(c, x, y, L) : -1;
+ bool sameTarget = menuRow >= 0 ? menuRow == c->tbPressMenuRow
+ : hit >= 0 && hit == c->tbPressItem && c->tbPressMenuRow < 0;
+ c->tbPressItem = -1; // 一次按下-抬起周期结束,防止陈旧记录误配后续点击
+ c->tbPressMenuRow = -1;
+ if (!sameTarget) return 0;
+ // 二级菜单项点击:popover 图标 cell → 裁剪立即应用对应项并收起;方向 → 切换
+ // 纵向/横向(禁用 cell 仅收起),随后收起
+ if (menuRow >= 0) {
+ if (c->menuKind == LCM_Crop) {
+ LongCaptureApplyCrop(c, menuRow);
+ } else if (LongCaptureMenuRowEnabled(c, menuRow)) {
+ bool wantHorizontal = (menuRow == 1);
+ if (wantHorizontal != c->horizontal) LongCaptureSwitchDirection(c);
+ }
+ LongCaptureSetMenu(c, LCM_None);
+ return 0;
+ }
+ // 点击任一直接动作按钮时收起展开中的菜单(方向/裁剪按钮自身负责切换菜单状态)
+ if (hit >= 0 && hit != LTI_Direction && hit != LTI_Crop && c->menuKind != LCM_None) {
+ LongCaptureSetMenu(c, LCM_None);
+ }
+ switch (hit) {
+ case LTI_Finish: c->finishFlag.store(true); break;
+ case LTI_Cancel: c->abortFlag.store(true); break;
+ case LTI_Save: if (!c->saveFlag.load()) c->saveFlag.store(true); break;
+ case LTI_Direction:
+ // 点击方向:与裁剪同款开合(悬停展开见 UiTick)。已拼接多帧后方向锁定
+ //(两个方向的内容坐标系不可混拼),点击不展开菜单
+ if (c->frameCount.load() <= 1) {
+ if (c->menuKind == LCM_Direction) {
+ LongCaptureSetMenu(c, LCM_None);
+ c->popHoverDisarm = LCM_Direction;
+ } else {
+ LongCaptureSetMenu(c, LCM_Direction);
+ }
+ }
+ break;
+ case LTI_AutoScroll: LongCaptureSetAutoScroll(c, !c->autoScroll); break;
+ case LTI_Crop:
+ // 点击裁剪:未展开则立即展开;已展开则收起并解除悬停武装(需移出按钮再
+ // 进入才会因悬停重开,防止点击收起与悬停展开互相打架)
+ if (c->menuKind == LCM_Crop) {
+ LongCaptureSetMenu(c, LCM_None);
+ c->popHoverDisarm = LCM_Crop;
+ } else {
+ LongCaptureSetMenu(c, LCM_Crop);
+ }
+ break;
+ default: break;
+ }
+ return 0;
+ }
+ case WM_TIMER: {
+ LongCaptureContext* c = g_longCtx.load();
+ if (!c) break;
+ if (wp == LC_TIMER_AUTOSCROLL) {
+ if (c->autoScroll) LongCaptureAutoScrollTick(c);
+ } else if (wp == LC_TIMER_UI) {
+ LongCaptureToolbarUiTick(c, hwnd);
+ }
+ return 0;
+ }
+ case WM_SETCURSOR: {
+ // 拖拽中或悬停「6 点把手」单元格:四向箭头提示可拖动;其余箭头
+ bool sizeAll = false;
+ POINT ptCursor;
+ {
+ LongCaptureContext* c = g_longCtx.load();
+ if (c) {
+ if (c->tbDragging) {
+ sizeAll = true;
+ } else if (GetCursorPos(&ptCursor)) {
+ POINT cl = ptCursor;
+ ScreenToClient(hwnd, &cl);
+ RECT cr;
+ GetClientRect(hwnd, &cr);
+ LongToolbarLayout L = LongCaptureToolbarLayout(c, cr.right, cr.bottom);
+ sizeAll = (LongCaptureHitTestToolbar(cl.x, cl.y, L) == LTI_Grip);
+ }
+ }
+ }
+ SetCursor(LoadCursor(NULL, sizeAll ? IDC_SIZEALL : IDC_ARROW));
+ return TRUE;
+ }
+ case WM_CAPTURECHANGED: {
+ // 捕获被系统夺走(弹窗等):安全退出拖拽态
+ LongCaptureContext* c = g_longCtx.load();
+ if (c && c->tbDragging && (HWND)lp != hwnd) {
+ c->tbDragging = false;
+ c->tbPressItem = -1;
+ c->tbPressMenuRow = -1;
+ }
+ return 0;
+ }
+ case WM_NCHITTEST: return HTCLIENT;
+ case WM_ERASEBKGND: return 1;
+ case WM_DESTROY:
+ KillTimer(hwnd, LC_TIMER_AUTOSCROLL);
+ KillTimer(hwnd, LC_TIMER_UI);
+ s_lcIcons.Cleanup(); // 工具栏图标缓存随窗口销毁释放(下次会话按新 DPI 重建)
+ FreeArgbSurface(s_lcTbSurfDC, s_lcTbSurfBmp, s_lcTbSurfBits, s_lcTbSurfW, s_lcTbSurfH);
+ if (g_lcTooltipWindow) { // title 式 tooltip 窗口随工具栏一并销毁
+ DestroyWindow(g_lcTooltipWindow);
+ g_lcTooltipWindow = NULL;
+ }
+ FreeArgbSurface(s_lcTipSurfDC, s_lcTipSurfBmp, s_lcTipSurfBits, s_lcTipSurfW, s_lcTipSurfH);
+ s_lcTooltipText.clear();
+ s_lcTipTarget = -1;
+ s_lcTipSince = 0;
+ // 复位 UiTick 鼠标/二级菜单悬停计时状态(原为函数级 static 会跨会话残留,致使下次
+ // 长截图复用旧状态),随工具栏窗口销毁清零
+ s_lcTbLDown = false;
+ s_lcPopHoverSince = 0;
+ s_lcPopLeaveSince = 0;
+ return 0;
+ }
+ return DefWindowProc(hwnd, msg, wp, lp);
+}
+
+
+// 创建工具栏窗口:选区下方居中(放不下退上方、再退选区内底部),避让右侧小地图面板。
+
+HWND LongCaptureCreateToolbar(CaptureContext* ctx, LongCaptureContext* c) {
+ static bool registered = false;
+ if (!registered) {
+ WNDCLASSEXW wc = {};
+ wc.cbSize = sizeof(wc);
+ wc.lpfnWndProc = LongCaptureToolbarWndProc;
+ wc.hInstance = GetModuleHandle(NULL);
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW);
+ wc.lpszClassName = L"ZToolsLongCaptureToolbar";
+ RegisterClassExW(&wc);
+ registered = true;
+ }
+ double ds = ctx->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ // 图标缓存:按当前 DPI 光栅化(与主截图工具栏同比例:按钮边长 − 8 后缩放)
+ s_lcIcons.Init(sc(LC_BAR_BTN_W - 8) + 2);
+ int w = LongCaptureToolbarWindowWidth(c);
+ int h = sc(LC_BAR_H);
+ int margin = sc(LC_BAR_MARGIN);
+ // 放置边界取「选区所在显示器」(多屏异分辨率:整虚拟屏幕包围盒会被高分屏拉大,
+ // 低分屏选区已触本屏底边仍会误判"下方放得下"而不上置——与编辑态
+ // CalcToolbarPosition 同因同修)。下→上→贴近底部三级回退同款规则。
+ RECT mon = {};
+ int bLeft = ctx->virtualX, bTop = ctx->virtualY;
+ int bRight = ctx->virtualX + ctx->virtualW, bBottom = ctx->virtualY + ctx->virtualH;
+ if (GetMonitorBoundsForRect(c->selection, mon)) {
+ bLeft = mon.left; bTop = mon.top; bRight = mon.right; bBottom = mon.bottom;
+ }
+ int x = (c->selection.left + c->selection.right) / 2 - w / 2;
+ int y = c->selection.bottom + margin;
+ // 选区下方放不下 -> 上方
+ if (y + h > bBottom) y = c->selection.top - margin - h;
+ // 上方也放不下 -> 贴近底部(选区内底边),并钳回显示器范围兜底
+ if (y < bTop) {
+ y = c->selection.bottom - margin - h;
+ if (y < c->selection.top) y = c->selection.top + margin;
+ if (y + h > bBottom) y = bBottom - h;
+ if (y < bTop) y = bTop;
+ }
+ if (x + w > bRight - sc(4)) x = bRight - w - sc(4);
+ if (x < bLeft + sc(4)) x = bLeft + sc(4);
+ // 小地图面板避让:面板与工具栏矩形重叠时把工具栏左移到面板左侧
+ if (g_longControlWindow) {
+ RECT pr;
+ if (GetWindowRect(g_longControlWindow, &pr)
+ && pr.left < x + w && pr.right > x && pr.top < y + h && pr.bottom > y) {
+ int nx = pr.left - margin - w;
+ if (nx >= ctx->virtualX + sc(4)) x = nx;
+ }
+ }
+ // 分层窗口(WS_EX_LAYERED):内容整幅渲染后 UpdateLayeredWindow 提交(见
+ // LongCaptureToolbarRender),圆角经 alpha 抗锯齿;不再用 SetWindowRgn 裁剪外形
+ HWND tb = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_LAYERED,
+ L"ZToolsLongCaptureToolbar", L"LongCaptureToolbar", WS_POPUP, x, y, w, h,
+ NULL, NULL, GetModuleHandle(NULL), NULL);
+ if (tb) {
+ ShowWindow(tb, SW_SHOWNOACTIVATE);
+ LongCaptureToolbarRender(c, x, y, w, h); // 分层窗口首帧内容(此前不可见)
+ SetTimer(tb, LC_TIMER_UI, 100, NULL);
+ }
+ return tb;
+}
+
diff --git a/src/screenshot/long_capture_internal.h b/src/screenshot/long_capture_internal.h
new file mode 100644
index 0000000..562cfeb
--- /dev/null
+++ b/src/screenshot/long_capture_internal.h
@@ -0,0 +1,312 @@
+// 长截图子系统内部头:LongCaptureContext 及其子结构、子系统内共享的常量与
+// 跨文件函数声明(供 lc_match_core / lc_stitch_state / lc_frame_io / lc_panel_ui /
+// lc_toolbar_ui / lc_session 六个拆分块共用,不对外暴露)。
+//
+// 本头是 long_capture_windows.cpp 二次拆分(CR-021)的产物:原 LongCaptureContext
+// ~160 字段 God-struct 按「拼接累计状态 / 裁剪状态 / 视觉跟踪状态 / 工具栏 UI 状态」
+// 四职责拆为 StitchState / CropState / TrackState / LcUiState 子结构;会话级配置、
+// 屏幕采样参数、控制信号与输出结果保留为 LongCaptureContext 的直接成员。
+// 拆分为纯移动不改逻辑:子结构仅按字段原用途分组,所有字段类型/默认值/语义完全不变,
+// 跨文件函数原 static 需跨块共享者改为非 static 并在此声明。
+//
+// 包含约定:本头依赖 internal.h 已定义的 LongMatchData / LongMatchOutcome /
+// LongCapturePendingMatch / LongCaptureFrameHistory / LCMenuKind / CaptureContext 等
+// 类型与全局声明,故各拆分块应先包含 internal.h 再包含本头。
+
+#pragma once
+
+#include "internal.h"
+
+// ==================== 长截图子系统内共享类型(原 .cpp 文件内 static struct,跨块共享者)====================
+
+// 轻量位移先验(滚轮累计增量 + 在线 px/notch 估计,仅用于候选排序加分与 tentative 预测幅度)。
+struct LongCaptureOffsetPrior {
+ bool valid = false;
+ int expectedAbsOffset = 0; // 期望 |d|(px);仅用于排序加分与 tentative 预测幅度
+};
+
+// Weak 时间一致性上下文(短时序列共识,只给弱档置信度加分,不改任何验收门槛)。
+struct LCWeakTemporal {
+ bool active = false;
+ int refOffset = 0; // 最近弱候选簇中位数(带符号)
+ float bonus = 0.0f; // 共识加分(≤ LC_WEAK_TEMPORAL_BONUS_MAX)
+};
+
+// 全宽重叠区富验证证据:overall/seam/三段分布/连续段/profile/edge/纹理占比/动态屏蔽行数。
+struct LCOverlapEvidence {
+ bool valid = false;
+ float overall = 0.0f; // 全区加权匹配率
+ float seam = 0.0f; // 接缝窗加权匹配率
+ float part[3] = {0.0f, 0.0f, 0.0f}; // top/middle/bottom 匹配率(-1 = 该段无有效行)
+ float spatial = 0.0f; // 空间一致性:多段有证据取「最差段与均值折中」,单段打折
+ float continuity = 0.0f; // 连续性 = 0.6×最长连续段 + 0.4×匹配率 − 断点罚
+ float longestRunRatio = 0.0f; // 最长连续匹配段占有效行比例
+ int gapCount = 0; // 匹配→失配跳变次数(匹配分布的碎片度)
+ float profileScore = 0.0f; // 4/8 行聚合 profile 相关度均值
+ float edgeScore = 0.0f; // 行边缘结构强度相关度
+ float textureRatio = 0.0f; // 重叠区有效纹理行占比(未屏蔽有效行 / 重叠行数):
+ // 大面积纯色重叠的证据量计价,进入综合置信度
+ int dynamicMaskedRows = 0; // 被识别为动态变化区而从统计中剔除的行数
+ //(受单段行数与总量占比双重硬上限,见常量块)
+};
+
+// 单 ROI 候选(Top-N 证据之一):只携带该列区域在此位移处的整段匹配率。
+struct LCBandCandidate {
+ int d = 0; // 候选位移(含符号)
+ float overall = 0.0f; // 该 ROI 限定列区间的整段加权匹配率
+ bool strict = false; // 是否达到严格档验收(否则为宽松档)
+};
+
+// 多跳匹配恢复结果:matched 为真时 offset 是基准帧 H → 当前帧的已验证位移。
+struct LCMultihopResult {
+ bool matched = false;
+ bool hopCommitted = false; // 基准为已提交帧(位置精确,可推导提交位移)
+ LongCaptureMatchMode mode = LongCaptureMatchMode::Normal;
+ int offset = 0; // 已验证的 H→curr 位移(带符号)
+ float confidence = 0.0f; // 该匹配的综合置信度
+ int64_t hopContentY = 0; // 基准帧视口顶(内容坐标)
+ int hopFrameId = -1; // 基准帧采样序号(日志关联)
+};
+
+// 失败帧的 TrackingEstimate(只服务 tentative 跟踪与下一帧先验,绝不进入提交)。
+struct LongCaptureTrackingEstimate {
+ bool valid = false;
+ int direction = 0; // +1 向下滚 / -1 向上滚
+ double predictedOffset = 0.0; // 预计 |位移|(px)
+ double confidence = 0.0; // 低置信度(纯预测无视觉验证)
+};
+
+// ==================== 长截图子系统内共享常量(原 long_capture_windows.cpp 文件级 static)====================
+// 这些常量原本为单文件 static,拆分后需跨 lc_* 块共享,改为非 static 在此集中声明,
+// 定义保留在对应块(算法调参 → lc_match_core.cpp)。
+
+// 全位移搜索的采样列上限与单列容忍差(量化灰度容差行匹配)。
+extern const int LONG_MATCH_MAX_COLS;
+extern const int LONG_MATCH_TOL;
+
+// 全位移扫描(探针粗筛→候选峰收集)参数。
+extern const int LC_SCAN_PROBES;
+extern const int LC_SCAN_MIN_WEIGHT;
+extern const int LC_PEAK_WIN;
+extern const int LC_MAX_CANDIDATES;
+
+// 最小可信重叠(绝对下限 + 视口高比例)。
+extern const int LC_MIN_OVERLAP;
+extern const float LC_MIN_OVERLAP_RATIO;
+
+// 跨 ROI 位移一致性容差。
+extern const int LC_ROI_OFFSET_TOLERANCE;
+
+// 综合置信度下限与 offset 跳变比率校验参数。
+extern const float LC_MIN_CONFIDENCE;
+extern const float LC_OFFSET_JUMP_RATIO;
+extern const int LC_OFFSET_HISTORY_LEN;
+extern const int LC_OFFSET_HISTORY_MIN;
+
+// 采样尝试总数与重试间隔档。
+extern const int LC_SAMPLE_ATTEMPTS;
+extern const int LC_RETRY_DELAY_NORMAL[]; // 长度 = LC_SAMPLE_ATTEMPTS - 1
+extern const int LC_RETRY_DELAY_WEAK[]; // 长度 = LC_WEAK_RETRY_ATTEMPTS
+
+// 帧稳定性检测参数。
+extern const DWORD LC_STABLE_REF_MAX_GAP;
+extern const float LC_STABLE_CHANGED_ROW_FRAC;
+extern const int LC_STABLE_MIN_WEIGHT;
+extern const int LC_STABLE_MAX_WAITS;
+extern const int LC_STABLE_RETRY_DELAY[]; // 长度 = LC_STABLE_MAX_WAITS
+
+// 瞬态快重采样参数。
+extern const int LC_QUICK_RESAMPLES;
+extern const int LC_RESAMPLE_DELAY_QUICK[]; // 长度 = LC_QUICK_RESAMPLES
+
+// 滚动中主动采样最大间隔与到底确认采样数。
+extern const int LC_SCROLL_SAMPLE_MAX_GAP;
+extern const int LC_BOTTOM_CONFIRM_SAMPLES;
+
+// Weak(低重叠大跳变)匹配参数。
+extern const int LC_WEAK_MIN_OVERLAP;
+extern const float LC_WEAK_MIN_OVERLAP_RATIO;
+extern const int LC_WEAK_ROI_TOLERANCE;
+extern const float LC_WEAK_ACCEPT_OVERALL;
+extern const float LC_WEAK_ACCEPT_SEAM;
+extern const float LC_WEAK_ACCEPT_EDGE;
+extern const float LC_WEAK_MIN_CONFIDENCE;
+extern const float LC_WEAK_CONF_EXTRA_PENALTY;
+extern const float LC_WEAK_OVERLAP_RATIO_LIMIT;
+extern const int LC_WEAK_CONFIRM_OFFSET_TOL;
+extern const int LC_WEAK_RETRY_ATTEMPTS;
+extern const int LC_WEAK_MAX_TRIES;
+
+// 滚轮 delta 软先验加分。
+extern const float LC_WHEEL_PRIOR_BONUS;
+
+// Tentative 视觉跟踪 / 多跳恢复 / Weak 时间一致性参数。
+extern const int LC_HISTORY_FRAMES;
+extern const int LC_HISTORY_HOPS;
+extern const int LC_TRACK_FREEZE_FRAMES;
+extern const int LC_TRACK_MIN_STEP;
+extern const float LC_TRACK_PREDICT_CONFIDENCE;
+extern const float LC_TRACK_CONFIDENCE_DECAY;
+extern const int LC_WEAK_TEMPORAL_TOL;
+extern const int LC_WEAK_TEMPORAL_MIN_SAMPLES;
+extern const int LC_WEAK_TEMPORAL_MAX_HISTORY;
+extern const float LC_WEAK_TEMPORAL_BONUS_MAX;
+
+// 接缝窗行数与全量验证有效权重下限。
+extern const int LC_SEAM_ROWS;
+extern const int LC_VERIFY_MIN_WEIGHT;
+
+// 纹理置信度折扣参数。
+extern const float LC_TEXTURE_CONF_FLOOR;
+extern const float LC_TEXTURE_CONF_SCALE;
+
+// 验收阈值(严格档 / 宽松档)。
+extern const float LC_ACCEPT_STRICT_OVERALL;
+extern const float LC_ACCEPT_STRICT_SEAM;
+extern const float LC_ACCEPT_LOOSE_OVERALL;
+extern const float LC_ACCEPT_LOOSE_SEAM;
+
+// 方向罚与歧义裕度。
+extern const float LC_DIR_PENALTY;
+extern const float LC_AMBIGUITY_MARGIN;
+
+// ROI 证据融合参数。
+extern const int LC_BAND_TOP_N;
+extern const int LC_MAX_VERIFY_CANDIDATES;
+extern const float LC_ROI_MIN_INFO_WEIGHT;
+extern const int LC_BAND_MIN_COLS;
+extern const float LC_PEAK_SEP_FULL;
+extern const float LC_AMBIGUITY_CONF_PENALTY;
+extern const float LC_CONTINUITY_GAP_MAX;
+
+// 动态变化区局部降权参数。
+extern const int LC_DYNAMIC_MIN_ROWS;
+extern const int LC_DYNAMIC_MAX_ROWS;
+extern const int LC_DYNAMIC_BRIDGE;
+extern const float LC_DYNAMIC_MAX_FRACTION;
+
+// offset basin 合并半径。
+extern const int LC_BASIN_RADIUS;
+
+// Weak 档额外证据下限。
+extern const float LC_WEAK_ACCEPT_SPATIAL;
+extern const float LC_WEAK_ACCEPT_CONTINUITY;
+extern const float LC_WEAK_ACCEPT_PROFILE;
+extern const float LC_WEAK_MIN_PEAK_SEP;
+
+// 跨文件的常量:原定义归属不同块但被另一块使用,故在此声明为 extern。
+// LC_TRACK_MIN_STEP 定义于 lc_stitch_state.cpp(跟踪逻辑用),panel UI 也读它判定 tentative 框显示。
+extern const int LC_TRACK_MIN_STEP;
+// LC_AUTOSCROLL_STOP_FAILS 定义于 lc_toolbar_ui.cpp(自动滚动参数),session 主循环读它决定自动停止。
+extern const int LC_AUTOSCROLL_STOP_FAILS;
+
+// ==================== 二级常量(原文件级 static,归各使用块)====================
+
+// 蒙版样式(预乘 ARGB):整屏半透明灰,采样裁剪区整透明透出实况桌面。
+// 定义于 lc_panel_ui.cpp(蒙版绘制唯一使用方)。
+extern const int LONG_MASK_GRAY;
+extern const BYTE LONG_MASK_ALPHA;
+
+// 面板布局常量(逻辑像素):面板总宽与内边距被 toolbar(缩略图列宽)与 session(缩略图列宽)
+// 共用,故在此声明为 extern;定义于 lc_panel_ui.cpp。
+extern const int LC_PANEL_W;
+extern const int LC_PANEL_PAD;
+
+// 拼接总像素上限(超过即自动完成,防内存雪崩)。定义于 lc_session.cpp。
+extern const long long LONG_CAPTURE_MAX_PIXELS;
+
+// ==================== 长截图子系统内跨文件函数声明 ====================
+
+// 失败分类名(LCFailReason → 日志字符串,仅可观测性用)。定义于 lc_match_core.cpp。
+const char* LcFailReasonName(LCFailReason r);
+
+// —— 纯算法(lc_match_core.cpp)——
+void LongCaptureBuildMatchData(const std::vector& frame, int w, int h, LongMatchData& m);
+bool LongCaptureRowMatchesRange(const uint8_t* a, const uint8_t* b, int c0, int c1);
+bool LongCaptureRowMatches(const uint8_t* a, const uint8_t* b, int cols);
+LongMatchOutcome LongCaptureDetectMatch(const LongMatchData& prevM, const LongMatchData& currM,
+ int dir, const LongCaptureOffsetPrior& prior,
+ const LCWeakTemporal& wt, int logId);
+LongMatchOutcome LongCaptureDetectPass(const LongMatchData& prevM, const LongMatchData& currM,
+ int dir, const LongCaptureOffsetPrior& prior, bool weak,
+ const LCWeakTemporal& wt, int logId);
+bool LongCaptureVerifyCandidate(const LongMatchData& prevM, const LongMatchData& currM,
+ int d, LCOverlapEvidence& ev);
+float LongCaptureWeakRequiredConfidence(int viewportH, int overlap);
+
+// —— 可写累计状态层(lc_stitch_state.cpp)——
+LongCaptureOffsetPrior LongCaptureBuildOffsetPrior(const LongCaptureContext* c);
+LCWeakTemporal LongCaptureWeakTemporalContext(const LongCaptureContext* c);
+void LongCapturePushWeakCandidate(LongCaptureContext* c, int offset);
+bool LongCaptureOffsetPlausible(const LongCaptureContext* c, int d,
+ LongCaptureMatchMode mode = LongCaptureMatchMode::Normal);
+LCMultihopResult LongCaptureMultihopDetect(const LongCaptureContext* c,
+ const LongMatchData& currMatch, int dir,
+ const LongCaptureOffsetPrior& prior);
+LongCaptureTrackingEstimate LongCaptureBuildTrackingEstimate(const LongCaptureContext* c, int dir,
+ const LongCaptureOffsetPrior& prior);
+int64_t LongCaptureTrackingDriftLimit(const LongCaptureContext* c);
+void LongCaptureTrackingSetVisual(LongCaptureContext* c, int64_t contentY, float confidence);
+void LongCaptureTrackingResetToCommitted(LongCaptureContext* c);
+void LongCaptureTrackingAdvancePredicted(LongCaptureContext* c, int direction, double magnitude);
+void LongCaptureHistoryPush(LongCaptureContext* c, int frameId, LongMatchData&& match,
+ int64_t contentY, bool committed);
+void LongCaptureAfterCommit(LongCaptureContext* c, int frameId);
+int LongCaptureCommitStitch(LongCaptureContext* c, std::vector& curr,
+ LongMatchData& currMatch, int d);
+void LongCaptureUpdateWheelEstimate(LongCaptureContext* c, int d);
+bool LongCaptureFrameUnstable(LongCaptureContext* c, const LongMatchData& curr);
+void LongCaptureUpdateStabilityRef(LongCaptureContext* c, const LongMatchData& curr);
+bool LongCaptureHasCropConstraint(const LongCaptureContext* c);
+void LongCaptureEraseDisplayRows(LongCaptureContext* c, int64_t r0, int64_t r1);
+void LongCaptureExecuteCropPurge(LongCaptureContext* c, bool below);
+// LongCaptureTryStitch 已在 internal.h 声明(供单元测试共用),此处不重复。
+
+// —— 抓帧 / DIB / 缩略图 / 位图构建 / 消息泵(lc_frame_io.cpp)——
+void LongCaptureDownscaleRow(const uint32_t* src, uint32_t* dst, int srcW, int dstW);
+void LongCaptureOutputRows(const LongCaptureContext* c, int& outTop, int& outBottom);
+bool LongCaptureRegisterWheelObserver(HWND target);
+void LongCaptureUnregisterWheelObserver();
+bool LongCaptureEnsureDib(LongCaptureContext* c, HDC screenDC);
+bool LongCaptureCaptureFrameBuf(LongCaptureContext* c, std::vector& out);
+void LongCaptureRebuildThumb(LongCaptureContext* c);
+void LongCaptureRebuildThumbDisplay(LongCaptureContext* c);
+HBITMAP LongCaptureBuildResultBitmap(LongCaptureContext* c);
+void LongCapturePumpMessages(LongCaptureContext* c);
+bool LongCaptureInitFirstFrame(LongCaptureContext* c, std::vector& frameBuf);
+void LongCaptureInitBaseline(LongCaptureContext* c, std::vector& frame);
+HBITMAP LongCaptureBuildFinalBitmap(LongCaptureContext* c);
+
+// —— 面板 / 蒙版 UI(lc_panel_ui.cpp)——
+bool EnsureArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h, int wantW, int wantH);
+void FreeArgbSurface(HDC& dc, HBITMAP& bmp, void*& bits, int& w, int& h);
+void LongCapturePanelRender(HWND panel, LongCaptureContext* c);
+LRESULT CALLBACK LongCapturePanelWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
+void LongCapturePanelUpdate(LongCaptureContext* c);
+HWND LongCaptureCreatePanel(CaptureContext* ctx, LongCaptureContext* c);
+LRESULT CALLBACK LongCaptureMaskWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
+RECT CalcSampleCrop(const CaptureContext* ctx);
+void EnterLongCaptureMask(const CaptureContext* ctx);
+
+// —— 工具栏 UI(lc_toolbar_ui.cpp)——
+void LongCaptureToolbarRender(LongCaptureContext* c, int dstX, int dstY, int w, int h);
+void LongCaptureToolbarRepaint();
+LRESULT CALLBACK LongCaptureToolbarWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp);
+HWND LongCaptureCreateToolbar(CaptureContext* ctx, LongCaptureContext* c);
+void LongCaptureSetMenu(LongCaptureContext* c, LCMenuKind kind);
+void LongCaptureResetSession(LongCaptureContext* c);
+void LongCaptureSwitchDirection(LongCaptureContext* c);
+void LongCaptureSetAutoScroll(LongCaptureContext* c, bool on);
+void LongCaptureApplyCrop(LongCaptureContext* c, int row);
+// 临时摘除/恢复长截图窗口组的置顶(保存对话框等系统弹窗需要真正置顶)。
+// 定义于 lc_toolbar_ui.cpp,session 主循环弹保存对话框前后调用。
+void LongCaptureSetTopmost(bool topmost);
+
+// —— 会话主循环 / 生命周期(lc_session.cpp)——
+void LongCaptureEmitFailure();
+void LongCaptureWaitMessages(LongCaptureContext* c, DWORD ms);
+// 释放采样 DIB 段:把原 wndproc_windows.cpp 里手动释放 lc->dibDC/dibBmp 的跨界所有权
+// 收进此处,由 DestroyLongCaptureContext 统一释放(CR-021)。
+// 注:会话级窗口(panel/toolbar/mask)销毁仍由捕获线程清理段调用,
+// 本函数只负责 DIB 资源的归口释放。
+void DestroyLongCaptureContext(LongCaptureContext* lc);
diff --git a/src/screenshot/mosaic_windows.cpp b/src/screenshot/mosaic_windows.cpp
new file mode 100644
index 0000000..2762327
--- /dev/null
+++ b/src/screenshot/mosaic_windows.cpp
@@ -0,0 +1,371 @@
+// 截图模块:马赛克子系统(reveal-mask 模型、画笔光标、重建/揭示)
+#include "internal.h"
+
+// ==================== 马赛克渲染 ====================
+
+// 马赛克原理:将原始屏幕位图(srcDC = memDC,物理像素)一次缩小到按 blockPx
+// 计算的低分辨率位图,再以最近邻一次放大到目标 DC,避免逐块执行大量 GDI 调用。
+// 目标 DC(targetDC)为逻辑像素(backDC / finalDC),源 DC(srcDC)为物理像素(memDC),
+// 二者通过 dpiScale 换算:srcX = (absX - virtualX) * dpiScale。
+
+// 对单个矩形区域批量生成马赛克并绘制到 targetDC。
+// dstX0/dstY0/dstW/dstH 是目标逻辑像素矩形;srcAbsX0/srcAbsY0 是其在虚拟屏幕中的
+// 绝对逻辑坐标。低分辨率尺寸按 blockPx 向上取整,右/下不足整块的边缘由最后一格覆盖。
+// 返回 true 表示缩小和最近邻放大均成功;失败时不修改缓存有效性,调用方可跳过揭示。
+
+bool MosaicBlitRect(HDC targetDC, HDC srcDC,
+ int dstX0, int dstY0, int dstW, int dstH,
+ int srcAbsX0, int srcAbsY0,
+ int blockPx, int virtualX, int virtualY, double dpiScale) {
+ if (!targetDC || !srcDC || dstW <= 0 || dstH <= 0 || blockPx < 1 || dpiScale <= 0) {
+ return false;
+ }
+
+ // 每个低分辨率像素对应约一个 blockPx 逻辑像素块;向上取整覆盖非整块边缘。
+ int reducedW = (dstW + blockPx - 1) / blockPx;
+ int reducedH = (dstH + blockPx - 1) / blockPx;
+ if (reducedW <= 0 || reducedH <= 0) return false;
+
+ // 源位图是物理像素,目标与马赛克块大小是逻辑像素,统一在这里完成坐标换算。
+ int srcX = (int)((srcAbsX0 - virtualX) * dpiScale + 0.5);
+ int srcY = (int)((srcAbsY0 - virtualY) * dpiScale + 0.5);
+ int srcW = (int)(dstW * dpiScale + 0.5);
+ int srcH = (int)(dstH * dpiScale + 0.5);
+ if (srcW < 1) srcW = 1;
+ if (srcH < 1) srcH = 1;
+
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return false;
+ HDC reducedDC = CreateCompatibleDC(screenDC);
+ HBITMAP reducedBmp = reducedDC
+ ? CreateCompatibleBitmap(screenDC, reducedW, reducedH)
+ : NULL;
+ ReleaseDC(NULL, screenDC);
+ if (!reducedDC || !reducedBmp) {
+ if (reducedBmp) DeleteObject(reducedBmp);
+ if (reducedDC) DeleteDC(reducedDC);
+ return false;
+ }
+
+ HGDIOBJ oldReducedBmp = SelectObject(reducedDC, reducedBmp);
+ if (!oldReducedBmp || oldReducedBmp == HGDI_ERROR) {
+ DeleteObject(reducedBmp);
+ DeleteDC(reducedDC);
+ return false;
+ }
+
+ // 第一次 StretchBlt:以 HALFTONE 将整个源区域压缩到低分辨率马赛克采样图。
+ int oldReducedMode = GetStretchBltMode(reducedDC);
+ POINT oldBrushOrigin = {0, 0};
+ bool reducedModeReady = oldReducedMode != 0
+ && SetStretchBltMode(reducedDC, HALFTONE) != 0
+ && SetBrushOrgEx(reducedDC, 0, 0, &oldBrushOrigin);
+ bool reducedOk = reducedModeReady
+ && StretchBlt(reducedDC, 0, 0, reducedW, reducedH,
+ srcDC, srcX, srcY, srcW, srcH, SRCCOPY);
+ if (reducedModeReady) {
+ SetBrushOrgEx(reducedDC, oldBrushOrigin.x, oldBrushOrigin.y, NULL);
+ }
+ if (oldReducedMode != 0) SetStretchBltMode(reducedDC, oldReducedMode);
+
+ // 第二次 StretchBlt:最近邻放大,保持每个低分辨率采样点为纯色块。
+ int oldTargetMode = GetStretchBltMode(targetDC);
+ bool targetModeReady = reducedOk && oldTargetMode != 0
+ && SetStretchBltMode(targetDC, COLORONCOLOR) != 0;
+ bool expandedOk = targetModeReady
+ && StretchBlt(targetDC, dstX0, dstY0, dstW, dstH,
+ reducedDC, 0, 0, reducedW, reducedH, SRCCOPY);
+ if (oldTargetMode != 0) SetStretchBltMode(targetDC, oldTargetMode);
+
+ SelectObject(reducedDC, oldReducedBmp);
+ DeleteObject(reducedBmp);
+ DeleteDC(reducedDC);
+ return reducedOk && expandedOk;
+}
+
+// ==================== 马赛克渲染(reveal-mask 模型) ====================
+// 核心:仅在存在已提交马赛克或正在绘制马赛克时,才把整张虚拟屏幕按当前块大小
+// 生成 mosaicBase。马赛克标注只是「蒙版」——涂抹=路径圆形区域、框选=矩形区域——
+// 揭示其背后的 mosaicBase。任意区域、任意顺序叠加都连续无缝;切换块大小时延迟到
+// 下一次实际需要马赛克的绘制再重建,避免普通截图确认态承担无用的整屏预处理。
+
+// 释放马赛克 base 资源
+
+void FreeMosaicBase(CaptureContext* ctx) {
+ if (ctx->mosaicBaseDC) { DeleteDC(ctx->mosaicBaseDC); ctx->mosaicBaseDC = NULL; }
+ if (ctx->mosaicBaseBitmap) { DeleteObject(ctx->mosaicBaseBitmap); ctx->mosaicBaseBitmap = NULL; }
+ ctx->mosaicBaseW = 0;
+ ctx->mosaicBaseH = 0;
+ ctx->mosaicBaseBlockPx = 0;
+}
+
+// 用 GDI+ 把单色位图转为带透明通道的 32bpp HBITMAP(用于光标)。
+
+static HBITMAP ColorBitmapFromBitmap(Gdiplus::Bitmap& bmp) {
+ HBITMAP hBmp = NULL;
+ Gdiplus::Color bg(0, 0, 0, 0); // 透明背景
+ bmp.GetHBITMAP(bg, &hBmp);
+ return hBmp;
+}
+
+// 生成单个涂抹光标:半径圆(白底 + 深色描边 + 中心十字)。
+// size 为光标位图边长(逻辑像素);hotspot 在中心。
+
+static HCURSOR CreateMosaicBrushCursor(int radius) {
+ int pad = 3;
+ int size = (radius + pad) * 2;
+ if (size < 16) size = 16;
+
+ HCURSOR result = NULL;
+ // GDI+ 已由会话级 InitGdipResources 启动(在 InitMosaicBrushCursors 调用前完成)。
+ {
+ Gdiplus::Bitmap bmp(size, size, PixelFormat32bppARGB);
+ {
+ Gdiplus::Graphics g(&bmp);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ int cx = size / 2;
+ int cy = size / 2;
+ // 外圈:白色描边底(保证暗背景可见)
+ Gdiplus::Pen whitePen(Gdiplus::Color(255, 255, 255, 255), 3.0f);
+ g.DrawEllipse(&whitePen, cx - radius, cy - radius, radius * 2, radius * 2);
+ // 内圈:深色虚线描边
+ Gdiplus::Pen darkPen(Gdiplus::Color(255, 30, 30, 30), 1.5f);
+ darkPen.SetDashStyle(Gdiplus::DashStyleDash);
+ g.DrawEllipse(&darkPen, cx - radius, cy - radius, radius * 2, radius * 2);
+ // 中心十字(准星)
+ Gdiplus::Pen crossPen(Gdiplus::Color(255, 30, 30, 30), 1.0f);
+ int cl = (std::min)(6, radius);
+ g.DrawLine(&crossPen, cx - cl, cy, cx + cl, cy);
+ g.DrawLine(&crossPen, cx, cy - cl, cx, cy + cl);
+ }
+ HBITMAP hColor = ColorBitmapFromBitmap(bmp);
+
+ // 掩码位图(全黑,使用彩色光标时掩码可忽略,但 CreateIcon 要求非空)
+ HDC screenDC = GetDC(NULL);
+ HDC maskDC = CreateCompatibleDC(screenDC);
+ HBITMAP hMask = CreateCompatibleBitmap(screenDC, size, size);
+ HGDIOBJ oldMask = SelectObject(maskDC, hMask);
+ PatBlt(maskDC, 0, 0, size, size, BLACKNESS);
+ SelectObject(maskDC, oldMask);
+ DeleteDC(maskDC);
+ ReleaseDC(NULL, screenDC);
+
+ ICONINFO ii = {0};
+ ii.fIcon = FALSE;
+ ii.xHotspot = size / 2;
+ ii.yHotspot = size / 2;
+ ii.hbmMask = hMask;
+ ii.hbmColor = hColor;
+ result = CreateIconIndirect(&ii);
+ DeleteObject(hMask);
+ DeleteObject(hColor);
+ }
+ return result;
+}
+
+// 初始化涂抹光标缓存(按 DPI 缩放半径)。
+
+void InitMosaicBrushCursors(CaptureContext* ctx) {
+ if (ctx->mosaicBrushCursorsInited) return;
+ for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
+ ctx->mosaicBrushCursors[i] = CreateMosaicBrushCursor(SC_MOSAIC_RADIUS[i]);
+ }
+ ctx->mosaicBrushCursorsInited = true;
+}
+
+void FreeMosaicBrushCursors(CaptureContext* ctx) {
+ for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
+ if (ctx->mosaicBrushCursors[i]) { DestroyIcon(ctx->mosaicBrushCursors[i]); ctx->mosaicBrushCursors[i] = NULL; }
+ }
+ ctx->mosaicBrushCursorsInited = false;
+}
+
+// 生成会话级马赛克底图:将整个虚拟屏幕批量缩小后最近邻放大到离屏位图。
+// 缓存坐标与 backDC 一致(原点 = 虚拟屏幕左上角),尺寸 = virtualW×virtualH,
+// 与选区无关:选区移动/缩放不需要重建,仅在虚拟屏幕尺寸或块大小变化时重建。
+// 先在临时 GDI 对象中完整生成,成功后再替换旧缓存;失败会保留“无有效缓存”状态。
+
+bool RebuildMosaicBase(CaptureContext* ctx) {
+ int w = ctx->virtualW;
+ int h = ctx->virtualH;
+ if (w <= 0 || h <= 0) {
+ FreeMosaicBase(ctx);
+ return false;
+ }
+
+ int blockPx = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
+ if (blockPx < 2) blockPx = 2;
+
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) {
+ FreeMosaicBase(ctx);
+ return false;
+ }
+ HDC newDC = CreateCompatibleDC(screenDC);
+ HBITMAP newBitmap = newDC ? CreateCompatibleBitmap(screenDC, w, h) : NULL;
+ ReleaseDC(NULL, screenDC);
+ if (!newDC || !newBitmap) {
+ if (newBitmap) DeleteObject(newBitmap);
+ if (newDC) DeleteDC(newDC);
+ FreeMosaicBase(ctx);
+ return false;
+ }
+
+ HGDIOBJ oldNewBitmap = SelectObject(newDC, newBitmap);
+ if (!oldNewBitmap || oldNewBitmap == HGDI_ERROR) {
+ DeleteObject(newBitmap);
+ DeleteDC(newDC);
+ FreeMosaicBase(ctx);
+ return false;
+ }
+
+ // 整虚拟屏幕按 blockPx 马赛克化:base 原点 = 虚拟屏幕左上角,源绝对坐标 = virtualX/virtualY。
+ bool generated = MosaicBlitRect(newDC, ctx->memDC, 0, 0, w, h,
+ ctx->virtualX, ctx->virtualY, blockPx,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale);
+ if (!generated) {
+ SelectObject(newDC, oldNewBitmap);
+ DeleteObject(newBitmap);
+ DeleteDC(newDC);
+ FreeMosaicBase(ctx);
+ return false;
+ }
+
+ FreeMosaicBase(ctx);
+ ctx->mosaicBaseDC = newDC;
+ ctx->mosaicBaseBitmap = newBitmap;
+ ctx->mosaicBaseW = w;
+ ctx->mosaicBaseH = h;
+ ctx->mosaicBaseBlockPx = blockPx;
+ return true;
+}
+
+// 判断现有马赛克缓存是否缺失,或尺寸/块大小已与当前会话不一致。
+// 此函数只检查缓存键;调用方必须先通过 HasMosaicToRender 确认当前帧确实需要马赛克。
+
+bool MosaicBaseNeedsRebuild(const CaptureContext* ctx) {
+ int blockPx = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
+ if (blockPx < 2) blockPx = 2;
+ bool blockChanged = (blockPx != ctx->mosaicBaseBlockPx);
+ bool sizeChanged = (ctx->mosaicBaseW != ctx->virtualW || ctx->mosaicBaseH != ctx->virtualH);
+ return blockChanged || sizeChanged || !ctx->mosaicBaseDC;
+}
+
+bool HasMosaicToRender(const std::vector& annotations, const Annotation* curDrawing) {
+ if (curDrawing && curDrawing->type == AT_Mosaic) return true;
+ for (const Annotation& a : annotations) {
+ if (a.type == AT_Mosaic) return true;
+ }
+ return false;
+}
+
+// 把单条马赛克标注对应的蒙版构建为目标 DC 局部坐标下的 HRGN。
+// ox/oy 将标注的绝对屏幕坐标转换到目标坐标系:覆盖层使用 -virtualX/-virtualY,
+// 导出位图使用 -selection.left/-selection.top。
+
+static HRGN BuildMosaicMaskRegion(const Annotation& a, float ox, float oy) {
+ if (a.mosaicRect) {
+ int absL = (std::min)(a.x1, a.x2);
+ int absT = (std::min)(a.y1, a.y2);
+ int absR = (std::max)(a.x1, a.x2);
+ int absB = (std::max)(a.y1, a.y2);
+ return CreateRectRgn((int)(absL + ox + 0.5f), (int)(absT + oy + 0.5f),
+ (int)(absR + ox + 0.5f), (int)(absB + oy + 0.5f));
+ } else {
+ // 涂抹:把整条路径变为连续的「胶囊」区域(保证快速移动时不留空隙)。
+ // 做法:沿相邻点之间的线段以不超过 radius/2 的步长插值取点,每个点画一个圆并并入区域,
+ // 相邻圆重叠从而形成无缝的粗笔触轨迹。
+ int radius = a.brushRadius;
+ if (radius < 1) radius = 1;
+ HRGN rgn = CreateRectRgn(0, 0, 0, 0);
+ if (a.pts.empty()) return rgn;
+ // 步长:半径的一半,保证相邻圆重叠 ≥50%,无视觉缝隙
+ double step = (std::max)(1.0, radius * 0.5);
+
+ auto addCircle = [&](double cx, double cy) {
+ int ix = (int)(cx + 0.5);
+ int iy = (int)(cy + 0.5);
+ HRGN circle = CreateEllipticRgn(ix - radius, iy - radius,
+ ix + radius, iy + radius);
+ CombineRgn(rgn, rgn, circle, RGN_OR);
+ DeleteObject(circle);
+ };
+
+ // 第一个点
+ addCircle(a.pts[0].x + ox, a.pts[0].y + oy);
+ for (size_t i = 1; i < a.pts.size(); i++) {
+ double x0 = a.pts[i - 1].x + ox;
+ double y0 = a.pts[i - 1].y + oy;
+ double x1 = a.pts[i].x + ox;
+ double y1 = a.pts[i].y + oy;
+ double dx = x1 - x0, dy = y1 - y0;
+ double segLen = std::sqrt(dx * dx + dy * dy);
+ if (segLen < 0.5) {
+ addCircle(x1, y1);
+ continue;
+ }
+ int n = (int)(segLen / step + 0.5);
+ if (n < 1) n = 1;
+ for (int k = 1; k <= n; k++) {
+ double t = (double)k / n;
+ addCircle(x0 + dx * t, y0 + dy * t);
+ }
+ }
+ return rgn;
+ }
+}
+
+// 揭示马赛克:把 mosaicBase 中由 masks(已提交标注)+ curDrawing(正在绘制)覆盖的区域
+// BitBlt 到 targetDC。mosaicBase 与 targetDC 使用相同的目标坐标系:覆盖层以虚拟屏幕
+// 左上角为原点,导出位图以截图选区左上角为原点。
+// contentBounds 是目标 DC 中允许显示马赛克的内容矩形;最终蒙版会与其求交,确保笔刷半径
+// 或历史标注不会越过截图选区。ox/oy 用于把标注绝对坐标换算到目标局部坐标。
+
+void RevealMosaicToTarget(HDC targetDC, HDC mosaicBase,
+ const std::vector& annotations,
+ const Annotation* curDrawing,
+ const RECT& contentBounds,
+ float ox, float oy) {
+ if (!targetDC || !mosaicBase
+ || contentBounds.right <= contentBounds.left
+ || contentBounds.bottom <= contentBounds.top) {
+ return;
+ }
+
+ // 合并所有马赛克标注的蒙版区域(目标局部坐标)
+ HRGN mask = CreateRectRgn(0, 0, 0, 0);
+ bool any = false;
+ for (const Annotation& a : annotations) {
+ if (a.type != AT_Mosaic) continue;
+ HRGN r = BuildMosaicMaskRegion(a, ox, oy);
+ CombineRgn(mask, mask, r, RGN_OR);
+ DeleteObject(r);
+ any = true;
+ }
+ if (curDrawing && curDrawing->type == AT_Mosaic) {
+ HRGN r = BuildMosaicMaskRegion(*curDrawing, ox, oy);
+ CombineRgn(mask, mask, r, RGN_OR);
+ DeleteObject(r);
+ any = true;
+ }
+ if (any) {
+ // 先把马赛克蒙版裁到截图内容矩形,再与调用方已有的 dirtyRect 裁剪区求交。
+ // contentBounds 采用半开区间;因此边框所在的矩形外沿不会被马赛克写入。
+ HRGN contentRgn = CreateRectRgn(contentBounds.left, contentBounds.top,
+ contentBounds.right, contentBounds.bottom);
+ CombineRgn(mask, mask, contentRgn, RGN_AND);
+ DeleteObject(contentRgn);
+
+ // 用 SaveDC 保护调用方的裁剪区(P1 局部帧时为 dirtyRect),揭示结束后完整恢复。
+ int saved = SaveDC(targetDC);
+ // mask 与现有裁剪区(dirtyRect)求交,揭示只发生在 dirtyRect∩选区∩蒙版。
+ ExtSelectClipRgn(targetDC, mask, RGN_AND);
+ // base 与 targetDC 同坐标系,1:1 拷贝。
+ BitBlt(targetDC, contentBounds.left, contentBounds.top,
+ contentBounds.right - contentBounds.left,
+ contentBounds.bottom - contentBounds.top,
+ mosaicBase, contentBounds.left, contentBounds.top, SRCCOPY);
+ if (saved) RestoreDC(targetDC, saved);
+ }
+ DeleteObject(mask);
+}
diff --git a/src/screenshot/output_windows.cpp b/src/screenshot/output_windows.cpp
new file mode 100644
index 0000000..7b61e4c
--- /dev/null
+++ b/src/screenshot/output_windows.cpp
@@ -0,0 +1,715 @@
+// 截图模块:导出管线(PNG 编码、Base64、剪贴板、保存对话框)
+#include "internal.h"
+#include // GetSaveFileNameW 保存对话框(PromptSaveFilePath 唯一使用方)
+#include // SHGetKnownFolderPath 已知文件夹路径(默认保存目录兜底,PromptSaveFilePath 唯一使用方)
+#include // std::unique_ptr(GetPngEncoderClsid 编码器表分配)
+#include // std::wcscmp(GetPngEncoderClsid 匹配 PNG MIME)
+
+// Base64 编码表
+
+static const char base64_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+// Base64 编码
+
+static std::string Base64Encode(const BYTE* data, size_t len) {
+ std::string result;
+ result.reserve(((len + 2) / 3) * 4);
+ for (size_t i = 0; i < len; i += 3) {
+ unsigned int b = (data[i] << 16) | ((i + 1 < len ? data[i + 1] : 0) << 8) | (i + 2 < len ? data[i + 2] : 0);
+ result.push_back(base64_chars[(b >> 18) & 0x3F]);
+ result.push_back(base64_chars[(b >> 12) & 0x3F]);
+ result.push_back(i + 1 < len ? base64_chars[(b >> 6) & 0x3F] : '=');
+ result.push_back(i + 2 < len ? base64_chars[b & 0x3F] : '=');
+ }
+ return result;
+}
+
+// 取流的真实已写字节长度。
+// CreateStreamOnHGlobal 的底层 HGLOBAL 按堆粒度分配,GlobalSize 返回的是分配容量而非
+// PNG 实际写入的字节数:尾部长度虚高会进入 base64 编码、落盘与剪贴板 PNG 输出
+// (体积膨胀、严格消费方读取出错),因此统一经 IStream::Stat 取逻辑长度。
+// STATFLAG_NONAME 免去名字拷贝,st.pwcsName 为空无需 CoTaskMemFree;
+// 失败返回 0(调用方按 len==0 走既有空数据分支)。须在 stream->Release() 之前调用。
+
+static size_t GetStreamLength(IStream* stream) {
+ STATSTG st = {};
+ if (!stream || FAILED(stream->Stat(&st, STATFLAG_NONAME))) return 0;
+ return (size_t)st.cbSize.QuadPart;
+}
+
+// 获取 PNG 编码器 CLSID(从 binding_windows.cpp 迁入截图目录,CR-019)。
+// 进程内 PNG 编码器 CLSID 不变,首次查找后用 static 缓存,后续直接返回缓存值,
+// 避免每次编码都分配并遍历编码器表。返回值 >= 0 表示成功(返回的是匹配到的编码器索引)。
+// 仍返回 int 索引以保持原有调用契约(调用方判 >= 0 / == -1);CLSID 经 pClsid 带出。
+// 须在 GDI+ 已初始化(截图会话级 InitGdipResources 或调用方局部 GdiPlusInit)后调用。
+// 线程安全:截图捕获线程与 binding 的 IconWorker 线程可能并发调用,用 std::call_once
+// 保证缓存查找与写入只发生一次,后续读缓存为只读(CLSID/索引在进程内不变,无数据竞争)。
+
+int GetPngEncoderClsid(CLSID* pClsid) {
+ static CLSID s_clsid = {};
+ static int s_index = -1;
+ static std::once_flag s_once;
+ std::call_once(s_once, [&]() {
+ UINT num = 0u;
+ UINT size = 0u;
+ Gdiplus::GetImageEncodersSize(std::addressof(num), std::addressof(size));
+ if (size == 0u) { s_index = -1; return; }
+
+ std::unique_ptr pImageCodecInfo(
+ static_cast(static_cast(new BYTE[size])));
+ if (pImageCodecInfo == nullptr) { s_index = -1; return; }
+
+ Gdiplus::GetImageEncoders(num, size, pImageCodecInfo.get());
+
+ for (UINT i = 0u; i < num; i++) {
+ if (std::wcscmp(pImageCodecInfo.get()[i].MimeType, L"image/png") == 0) {
+ s_clsid = pImageCodecInfo.get()[i].Clsid;
+ s_index = (int)i;
+ break;
+ }
+ }
+ });
+ if (pClsid) *pClsid = s_clsid;
+ return s_index;
+}
+
+// 将 HALFTONE 缩放模式设置到目标 DC(output_windows.cpp / long_capture_windows.cpp 共用)。
+// HALFTONE 在做下采样缩放时比默认 COLORONCOLOR 质量更好,但 BrushOrg 会被 StretchBlt 用到,
+// 因此同时把画刷原点复位到 (0,0) 避免抖动(MSDN 推荐配套调用)。
+
+void SetHalftoneStretchMode(HDC dc) {
+ SetStretchBltMode(dc, HALFTONE);
+ SetBrushOrgEx(dc, 0, 0, NULL);
+}
+
+// 将 HBITMAP 转换为 PNG base64 字符串
+
+std::string BitmapToBase64Png(HBITMAP hBitmap) {
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
+ std::string result;
+ {
+ Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(hBitmap, NULL);
+ if (bmp) {
+ CLSID pngClsid;
+ if (GetPngEncoderClsid(&pngClsid) >= 0) {
+ IStream* stream = NULL;
+ CreateStreamOnHGlobal(NULL, TRUE, &stream);
+ if (stream && bmp->Save(stream, &pngClsid, NULL) == Gdiplus::Ok) {
+ // 真实长度取自流(须在 Release 前);GlobalSize 只是堆分配容量
+ size_t len = GetStreamLength(stream);
+ HGLOBAL hMem = NULL;
+ GetHGlobalFromStream(stream, &hMem);
+ BYTE* ptr = (BYTE*)GlobalLock(hMem);
+ if (ptr && len > 0) {
+ result = "data:image/png;base64," + Base64Encode(ptr, len);
+ }
+ GlobalUnlock(hMem);
+ }
+ if (stream) stream->Release();
+ }
+ delete bmp;
+ }
+ }
+ return result;
+}
+
+// 打开剪贴板(供本文件两条剪贴板导出路径共用,保持开门阶段语义一致)。
+// 输入法/剪贴板管理器会高频短暂占用剪贴板使 OpenClipboard 瞬时失败,
+// 因此失败后以 Sleep(10) 为间隔重试,总等待约 300ms 后仍失败才放弃。
+// 返回 true 表示已持有剪贴板(此后必须以 CloseClipboard 归还)。
+
+static bool OpenClipboardWithRetry() {
+ const int kMaxRetries = 30;
+ for (int i = 0;; ++i) {
+ if (OpenClipboard(NULL)) return true;
+ if (i >= kMaxRetries) return false;
+ Sleep(10);
+ }
+}
+
+// 把不透明位图放入剪贴板(CF_BITMAP)。
+// 成功 SetClipboardData 后 hCopy 由系统接管(不得再 DeleteObject);
+// 失败或 CopyImage 失败时须自行删除副本并返回 false —— 剪贴板此时已被清空,
+// 不能像传 NULL 那样注册延迟渲染,也不得向上游谎报成功。
+
+bool SaveBitmapToClipboard(HBITMAP hBitmap) {
+ if (!OpenClipboardWithRetry()) return false;
+ EmptyClipboard();
+ BITMAP bm;
+ GetObject(hBitmap, sizeof(BITMAP), &bm);
+ HBITMAP hCopy = (HBITMAP)CopyImage(hBitmap, IMAGE_BITMAP, bm.bmWidth, bm.bmHeight, LR_COPYRETURNORG);
+ bool ok = false;
+ if (hCopy) {
+ ok = NULL != SetClipboardData(CF_BITMAP, hCopy);
+ if (!ok) DeleteObject(hCopy); // 未被系统接管,自行删除避免泄漏
+ }
+ CloseClipboard();
+ return ok;
+}
+
+// 单次编码同时满足多个输出需求(base64 / 原始字节 / 落盘),
+// 避免长截图这类超大位图被 GDI+ 反复编码。任一输出成功即返回 true。
+bool EncodeHBitmapPng(HBITMAP hBitmap, std::string* base64Out, std::string* rawOut,
+ const wchar_t* filePath) {
+ if (!hBitmap) return false;
+ bool ok = false;
+ Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(hBitmap, NULL);
+ if (bmp) {
+ CLSID pngClsid;
+ if (GetPngEncoderClsid(&pngClsid) >= 0) {
+ IStream* stream = NULL;
+ if (CreateStreamOnHGlobal(NULL, TRUE, &stream) == S_OK && stream) {
+ if (bmp->Save(stream, &pngClsid, NULL) == Gdiplus::Ok) {
+ HGLOBAL hMem = NULL;
+ if (GetHGlobalFromStream(stream, &hMem) == S_OK && hMem) {
+ // 真实长度取自流(须在 Release 前);GlobalSize 只是堆分配容量
+ size_t len = GetStreamLength(stream);
+ BYTE* ptr = (BYTE*)GlobalLock(hMem);
+ if (ptr && len > 0) {
+ if (rawOut) rawOut->assign((const char*)ptr, len);
+ if (base64Out) *base64Out = "data:image/png;base64," + Base64Encode(ptr, len);
+ ok = true;
+ if (filePath && *filePath) {
+ // 原子落盘:先写同目录临时文件(与目标同目录保证同一卷,
+ // MoveFileEx 才能做元数据级原子替换),写入全部成功且句柄
+ // 关闭后再以 MOVEFILE_REPLACE_EXISTING|MOVEFILE_WRITE_THROUGH
+ // 一次性替换目标文件。避免磁盘满/权限中断时旧图已被
+ // CREATE_ALWAYS 覆盖、只留截断 PNG 的情况。
+ // 任一步失败都删除临时文件并保持 ok=false 错误信号。
+ std::wstring tmpPath = std::wstring(filePath) + L".tmp";
+ bool saved = false;
+ HANDLE hf = CreateFileW(tmpPath.c_str(), GENERIC_WRITE, 0, NULL,
+ CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (hf != INVALID_HANDLE_VALUE) {
+ DWORD written = 0;
+ if (WriteFile(hf, ptr, (DWORD)len, &written, NULL)
+ && written == (DWORD)len)
+ saved = true;
+ CloseHandle(hf); // 先关句柄再替换/删除,否则会因占用失败
+ }
+ if (!saved || !MoveFileExW(tmpPath.c_str(), filePath,
+ MOVEFILE_REPLACE_EXISTING |
+ MOVEFILE_WRITE_THROUGH)) {
+ DeleteFileW(tmpPath.c_str()); // 尽力清理失败遗留的临时文件
+ ok = false;
+ }
+ }
+ }
+ if (ptr) GlobalUnlock(hMem);
+ }
+ }
+ stream->Release();
+ }
+ }
+ delete bmp;
+ }
+ return ok;
+}
+
+// 将预乘 32bpp ARGB 像素缓冲编码为 PNG,可选产出 data URL base64 与/或原始 PNG 字节。
+// 用 Gdiplus::Bitmap 直接包装外部预乘缓冲(PixelFormat32bppPARGB),
+// PNG 编码器会转为非预乘写入 PNG(同时与剪贴板 CF_DIB 的预乘约定一致),保留 alpha。
+
+static bool EncodePremulArgbPng(const void* bits, int w, int h, int stride,
+ std::string* base64Out, std::string* rawOut) {
+ if (!bits || w <= 0 || h <= 0) return false;
+ CLSID pngClsid;
+ if (GetPngEncoderClsid(&pngClsid) < 0) return false;
+ IStream* stream = NULL;
+ if (CreateStreamOnHGlobal(NULL, TRUE, &stream) != S_OK || !stream) return false;
+ bool ok = false;
+ {
+ Gdiplus::Bitmap bmp(w, h, stride, PixelFormat32bppPARGB, (BYTE*)bits);
+ if (bmp.Save(stream, &pngClsid, NULL) == Gdiplus::Ok) {
+ HGLOBAL hMem = NULL;
+ if (GetHGlobalFromStream(stream, &hMem) == S_OK && hMem) {
+ // 真实长度取自流(须在 Release 前);GlobalSize 只是堆分配容量
+ size_t len = GetStreamLength(stream);
+ BYTE* ptr = (BYTE*)GlobalLock(hMem);
+ if (ptr && len > 0) {
+ if (rawOut) rawOut->assign((const char*)ptr, len);
+ if (base64Out) *base64Out = "data:image/png;base64," + Base64Encode(ptr, len);
+ ok = true;
+ }
+ if (ptr) GlobalUnlock(hMem);
+ }
+ }
+ }
+ stream->Release();
+ return ok;
+}
+
+// 圆角透明图复制到剪贴板:CF_DIB(BITMAPV4HEADER, BI_BITFIELDS, 预乘 ARGB, 自下而上)
+// + 注册 PNG 格式(透明度跨应用最可靠,浏览器/Slack/图像编辑器按此读取)。
+// 仅 radius>0 调用;radius==0 仍走 SaveBitmapToClipboard。
+// MSDN 约定:调用 GetDIBits 时目标位图不得选入传入的 DC(函数内部自行建空内存 DC,
+// 不依赖调用方任何 DC 的选中状态),否则部分驱动返回失败/错位数据,粘贴后为空白。
+
+static bool SaveArgbBitmapToClipboard(HBITMAP hbmp, int w, int h,
+ const std::string& pngBytes) {
+ if (!OpenClipboardWithRetry()) return false; // 与 SaveBitmapToClipboard 相同的重试策略
+ EmptyClipboard();
+ // CF_DIB:用 GetDIBits 转成自下而上、BI_BITFIELDS 的 32bpp 预乘 ARGB
+ BITMAPV4HEADER b4 = {};
+ b4.bV4Size = sizeof(BITMAPV4HEADER);
+ b4.bV4Width = w;
+ b4.bV4Height = h; // 正值 = 自下而上
+ b4.bV4Planes = 1;
+ b4.bV4BitCount = 32;
+ b4.bV4V4Compression = BI_BITFIELDS;
+ b4.bV4RedMask = 0x00FF0000;
+ b4.bV4GreenMask = 0x0000FF00;
+ b4.bV4BlueMask = 0x000000FF;
+ b4.bV4AlphaMask = 0xFF000000;
+ b4.bV4SizeImage = (DWORD)((SIZE_T)w * (SIZE_T)h * 4);
+ SIZE_T dibSize = sizeof(BITMAPV4HEADER) + (SIZE_T)w * (SIZE_T)h * 4;
+ HGLOBAL hDib = GlobalAlloc(GMEM_MOVEABLE, dibSize);
+ if (hDib) {
+ BYTE* p = (BYTE*)GlobalLock(hDib);
+ if (p) {
+ memcpy(p, &b4, sizeof(b4));
+ // 用私有空内存 DC 完成 GetDIBits:hbmp 保证不选入该 DC,
+ // 也不要求调用方先摘出位图,契约在函数内部恒成立。
+ HDC dibDC = CreateCompatibleDC(NULL);
+ bool dibOk = false;
+ if (dibDC) {
+ // 返回值为拷贝的扫描行数,==h 才算完整转换;失败则不注册 CF_DIB
+ // 并释放缓冲,免得未填充的像素数据被粘到剪贴板(PNG 格式仍可携带真实内容)。
+ dibOk = h > 0 && GetDIBits(dibDC, hbmp, 0, h, p + sizeof(b4),
+ (BITMAPINFO*)&b4, DIB_RGB_COLORS) == h;
+ DeleteDC(dibDC);
+ }
+ GlobalUnlock(hDib);
+ if (dibOk) {
+ if (!SetClipboardData(CF_DIB, hDib)) GlobalFree(hDib);
+ } else {
+ GlobalFree(hDib);
+ }
+ } else {
+ GlobalFree(hDib);
+ }
+ }
+ // PNG 格式:透明度最可靠的载体
+ if (!pngBytes.empty()) {
+ static UINT pngFmt = 0;
+ if (pngFmt == 0) pngFmt = RegisterClipboardFormatW(L"PNG");
+ if (pngFmt) {
+ HGLOBAL hPng = GlobalAlloc(GMEM_MOVEABLE, pngBytes.size());
+ if (hPng) {
+ BYTE* pp = (BYTE*)GlobalLock(hPng);
+ if (pp) {
+ memcpy(pp, pngBytes.data(), pngBytes.size());
+ GlobalUnlock(hPng);
+ if (!SetClipboardData(pngFmt, hPng)) GlobalFree(hPng);
+ } else GlobalFree(hPng);
+ }
+ }
+ }
+ CloseClipboard();
+ return true;
+}
+
+// 生成圆角透明的 32bpp 预乘 ARGB DIB(radius>0 用)。
+// 复用现有像素提取+标注合成流程,但用 32bpp ARGB DIB 替代不透明位图;
+// 合成前把 alpha 统一置 255(保证标注以不透明背景混合、AA 边正确),合成后按圆角
+// 蒙版逐像素写入 alpha(内部不透明、弧边抗锯齿预乘、外部透明)。
+// 返回 HBITMAP(调用方 DeleteObject);outDC 调用方 DeleteDC;outBits 为 DIB 像素指针。
+// 任一拷贝/缩放环节失败时清理全部已建 GDI 资源并返回 NULL(out* 均保持空值/零值),
+// 调用方按失败处理,不会拿到内容未初始化的半成品。
+
+static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy,
+ double dpiScale, const std::vector& anns, int radius, int mosaicSizeIdx,
+ HDC& outDC, void*& outBits, int& outW, int& outH) {
+ outDC = NULL; outBits = NULL; outW = 0; outH = 0;
+ int width = rect.right - rect.left;
+ int height = rect.bottom - rect.top;
+ if (width <= 0 || height <= 0) return NULL;
+
+ int lx = rect.left - vx;
+ int ly = rect.top - vy;
+ int px = (int)(lx * dpiScale + 0.5);
+ int py = (int)(ly * dpiScale + 0.5);
+ int pw = (int)(width * dpiScale + 0.5);
+ int ph = (int)(height * dpiScale + 0.5);
+
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return NULL;
+
+ // 物理尺寸 32bpp ARGB DIB(top-down);BitBlt 后 RGB 就位,alpha 字节不可靠(下方统一置 255)
+ BITMAPINFO bi = {};
+ bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi.bmiHeader.biWidth = pw;
+ bi.bmiHeader.biHeight = -ph;
+ bi.bmiHeader.biPlanes = 1;
+ bi.bmiHeader.biBitCount = 32;
+ bi.bmiHeader.biCompression = BI_RGB;
+ void* bits = NULL;
+ HBITMAP regionBmp = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0);
+ HDC regionDC = regionBmp ? CreateCompatibleDC(screenDC) : NULL;
+ if (regionDC) SelectObject(regionDC, regionBmp);
+
+ // 选区拷贝失败(锁屏瞬断/源不可读等)会残留未初始化像素:整体返回 NULL,
+ // 由 ExtractRegionResult / SaveRegionToPngFile 的失败分支收尾,不输出黑图。
+ if (!regionDC || !regionBmp ||
+ !BitBlt(regionDC, 0, 0, pw, ph, memDC, px, py, SRCCOPY)) {
+ if (regionDC) DeleteDC(regionDC);
+ if (regionBmp) DeleteObject(regionBmp);
+ ReleaseDC(NULL, screenDC);
+ return NULL;
+ }
+
+ HBITMAP finalBmp = regionBmp;
+ HDC finalDC = regionDC;
+ void* finalBits = bits;
+ int finalW = pw, finalH = ph;
+
+ if (dpiScale > 1.01 || dpiScale < 0.99) {
+ BITMAPINFO bi2 = {};
+ bi2.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ bi2.bmiHeader.biWidth = width;
+ bi2.bmiHeader.biHeight = -height;
+ bi2.bmiHeader.biPlanes = 1;
+ bi2.bmiHeader.biBitCount = 32;
+ bi2.bmiHeader.biCompression = BI_RGB;
+ void* bits2 = NULL;
+ HBITMAP scaledBmp = CreateDIBSection(screenDC, &bi2, DIB_RGB_COLORS, &bits2, NULL, 0);
+ HDC scaledDC = scaledBmp ? CreateCompatibleDC(screenDC) : NULL;
+ if (scaledDC && bits2) {
+ SelectObject(scaledDC, scaledBmp);
+ SetHalftoneStretchMode(scaledDC);
+ }
+ // 缩放拷贝任一步失败:清理两套资源后同上按整体失败处理,不出半成品。
+ if (!scaledDC || !bits2 ||
+ !StretchBlt(scaledDC, 0, 0, width, height, regionDC, 0, 0, pw, ph, SRCCOPY)) {
+ if (scaledDC) DeleteDC(scaledDC);
+ if (scaledBmp) DeleteObject(scaledBmp);
+ DeleteDC(regionDC);
+ DeleteObject(regionBmp);
+ ReleaseDC(NULL, screenDC);
+ return NULL;
+ }
+ DeleteDC(regionDC);
+ DeleteObject(regionBmp);
+ finalBmp = scaledBmp; finalDC = scaledDC; finalBits = bits2;
+ finalW = width; finalH = height;
+ }
+
+ // 合成前置 alpha=255:标注按不透明背景混合(结果不透明),后续圆角蒙版只负责透明
+ if (finalBits) {
+ BYTE* p = (BYTE*)finalBits;
+ int cnt = finalW * finalH;
+ for (int i = 0; i < cnt; i++) p[i * 4 + 3] = 255;
+ }
+ CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale,
+ SC_MOSAIC_SIZES[mosaicSizeIdx]);
+
+ // 圆角蒙版:同尺寸 32bpp DIB,不透明黑底 + GDI+ 填白圆角路径,取 RGB 通道作 coverage
+ int r = (std::min)(radius, (std::min)(finalW, finalH) / 2);
+ if (r >= 1 && finalBits) {
+ BITMAPINFO mbi = {};
+ mbi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
+ mbi.bmiHeader.biWidth = finalW;
+ mbi.bmiHeader.biHeight = -finalH;
+ mbi.bmiHeader.biPlanes = 1;
+ mbi.bmiHeader.biBitCount = 32;
+ mbi.bmiHeader.biCompression = BI_RGB;
+ void* maskBits = NULL;
+ HBITMAP maskBmp = CreateDIBSection(screenDC, &mbi, DIB_RGB_COLORS, &maskBits, NULL, 0);
+ if (maskBmp && maskBits) {
+ BYTE* mp = (BYTE*)maskBits;
+ int mcnt = finalW * finalH;
+ for (int i = 0; i < mcnt; i++) { mp[i*4]=0; mp[i*4+1]=0; mp[i*4+2]=0; mp[i*4+3]=255; }
+ HDC maskDC = CreateCompatibleDC(screenDC);
+ HGDIOBJ oldMask = SelectObject(maskDC, maskBmp);
+ {
+ Gdiplus::Graphics g(maskDC);
+ g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality);
+ Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, 0, 0, finalW, finalH, r);
+ g.FillPath(&white, &path);
+ }
+ SelectObject(maskDC, oldMask);
+ DeleteDC(maskDC);
+ // 逐像素:final alpha = coverage(mask B 通道,==G==R),RGB 按其预乘
+ BYTE* dst = (BYTE*)finalBits;
+ BYTE* msk = (BYTE*)maskBits;
+ int dcnt = finalW * finalH;
+ for (int i = 0; i < dcnt; i++) {
+ int a = msk[i * 4];
+ if (a <= 0) {
+ dst[i*4]=0; dst[i*4+1]=0; dst[i*4+2]=0; dst[i*4+3]=0;
+ } else if (a >= 255) {
+ dst[i*4+3]=255; // RGB 不变(不透明)
+ } else {
+ dst[i*4] = (BYTE)((int)dst[i*4] * a / 255);
+ dst[i*4+1] = (BYTE)((int)dst[i*4+1] * a / 255);
+ dst[i*4+2] = (BYTE)((int)dst[i*4+2] * a / 255);
+ dst[i*4+3] = (BYTE)a;
+ }
+ }
+ DeleteObject(maskBmp);
+ }
+ }
+
+ ReleaseDC(NULL, screenDC);
+ outDC = finalDC; outBits = finalBits; outW = finalW; outH = finalH;
+ return finalBmp;
+}
+
+// 不透明位图路径(radius==0)的公共合成:从预截屏位图按选区提取区域,若有 DPI 缩放则
+// 缩放回逻辑尺寸,再合成标注。ExtractRegionResult(base64+剪贴板)与 SaveRegionToPngFile
+// (落盘)的 radius==0 分支此前为近逐行两份重复,CR-019 收口至此单一实现。
+// 成功时经 outFinalDC/outFinalBmp 带出已合成的位图(调用方负责 DeleteDC/DeleteObject);
+// 失败时内部已清理全部 GDI 资源、out* 保持 NULL,返回 false。
+// mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。
+
+static bool ComposeSelectedBitmap(HDC memDC, const RECT& rect, int vx, int vy,
+ double dpiScale, const std::vector& anns, int mosaicSizeIdx,
+ HDC& outFinalDC, HBITMAP& outFinalBmp) {
+ outFinalDC = NULL; outFinalBmp = NULL;
+ int width = rect.right - rect.left;
+ int height = rect.bottom - rect.top;
+ if (width <= 0 || height <= 0) return false;
+
+ // 从物理尺寸位图提取区域
+ int lx = rect.left - vx;
+ int ly = rect.top - vy;
+ int px = (int)(lx * dpiScale + 0.5);
+ int py = (int)(ly * dpiScale + 0.5);
+ int pw = (int)(width * dpiScale + 0.5);
+ int ph = (int)(height * dpiScale + 0.5);
+
+ HDC screenDC = GetDC(NULL);
+ HDC regionDC = screenDC ? CreateCompatibleDC(screenDC) : NULL;
+ HBITMAP regionBmp = screenDC ? CreateCompatibleBitmap(screenDC, pw, ph) : NULL;
+ if (regionDC && regionBmp) SelectObject(regionDC, regionBmp);
+
+ // 拷贝/缩放任一环节失败都按失败收尾:未初始化的 CompatibleBitmap 内容会被当成截图结果
+ // (黑图报成功),已创建资源在尾部统一清理。
+ bool captured = regionDC && regionBmp &&
+ BitBlt(regionDC, 0, 0, pw, ph, memDC, px, py, SRCCOPY);
+
+ // 如果有 DPI 缩放,缩放回逻辑尺寸
+ HBITMAP finalBmp = regionBmp;
+ HDC finalDC = regionDC;
+ if (captured && (dpiScale > 1.01 || dpiScale < 0.99)) {
+ HDC scaledDC = CreateCompatibleDC(screenDC);
+ HBITMAP scaledBmp = scaledDC ? CreateCompatibleBitmap(screenDC, width, height) : NULL;
+ if (scaledDC && scaledBmp)
+ SelectObject(scaledDC, scaledBmp);
+ if (scaledDC && scaledBmp &&
+ StretchBlt(scaledDC, 0, 0, width, height, regionDC, 0, 0, pw, ph, SRCCOPY)) {
+ DeleteDC(regionDC);
+ DeleteObject(regionBmp);
+ finalBmp = scaledBmp;
+ finalDC = scaledDC;
+ } else {
+ captured = false; // 缩放失败不得输出半成品
+ if (scaledDC) DeleteDC(scaledDC);
+ if (scaledBmp) DeleteObject(scaledBmp);
+ }
+ }
+
+ if (captured) {
+ // 合成标注进最终图像(finalDC 原点 = 选区原点,标注为绝对坐标,偏移 = -rect.left/top)
+ CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale,
+ SC_MOSAIC_SIZES[mosaicSizeIdx]);
+ }
+
+ ReleaseDC(NULL, screenDC);
+
+ if (!captured) {
+ // 失败:清理已建资源(final* 与 region* 共享所有权时仅此一处删除)
+ DeleteDC(finalDC);
+ DeleteObject(finalBmp);
+ return false;
+ }
+
+ outFinalDC = finalDC;
+ outFinalBmp = finalBmp;
+ return true;
+}
+
+// 从预截屏位图提取区域,生成 base64 并复制到剪贴板。
+// anns:可选的标注列表,会合成进最终 PNG(选区相对坐标,finalDC 原点 = 选区原点)。
+// radius>0 走圆角透明导出(32bpp ARGB + 圆角蒙版 + PNG/PNG剪贴板);radius==0 维持不透明位图路径不变。
+// mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。
+// 拷贝/缩放失败时 success 保持 false 且不产出 base64/剪贴板数据(资源全量清理)。
+
+ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect,
+ int vx, int vy, double dpiScale, const std::vector& anns,
+ int radius, int mosaicSizeIdx) {
+ ScreenshotResult* result = new ScreenshotResult();
+ result->success = false;
+ int width = rect.right - rect.left;
+ int height = rect.bottom - rect.top;
+ result->x = rect.left;
+ result->y = rect.top;
+ result->x2 = rect.right;
+ result->y2 = rect.bottom;
+ result->width = width;
+ result->height = height;
+
+ if (width <= 0 || height <= 0) return result;
+
+ // radius>0:圆角透明导出
+ if (radius > 0) {
+ HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0;
+ HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius,
+ mosaicSizeIdx, fDC, fBits, fw, fh);
+ if (fbmp && fBits) {
+ std::string rawPng, b64;
+ if (EncodePremulArgbPng(fBits, fw, fh, fw * 4, &b64, &rawPng)) {
+ result->base64 = b64;
+ result->success = SaveArgbBitmapToClipboard(fbmp, fw, fh, rawPng);
+ }
+ }
+ if (fDC) DeleteDC(fDC);
+ if (fbmp) DeleteObject(fbmp);
+ return result;
+ }
+
+ // radius==0:原有不透明位图路径(公共合成,CR-019 收口至 ComposeSelectedBitmap)
+ HDC finalDC = NULL; HBITMAP finalBmp = NULL;
+ if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx,
+ finalDC, finalBmp)) {
+ // 生成 base64
+ result->base64 = BitmapToBase64Png(finalBmp);
+ // 复制到剪贴板
+ result->success = SaveBitmapToClipboard(finalBmp);
+ }
+
+ DeleteDC(finalDC); // NULL 安全
+ DeleteObject(finalBmp);
+
+ return result;
+}
+
+// ==================== 保存对话框辅助 ====================
+
+// 生成默认保存文件名:Screenshot_YYYYMMDD_HHMMSS.png
+
+static std::wstring MakeDefaultScreenshotName() {
+ time_t now = time(NULL);
+ struct tm lt;
+ localtime_s(<, &now);
+ wchar_t buf[64];
+ wsprintfW(buf, L"Screenshot_%04d%02d%02d_%02d%02d%02d.png",
+ lt.tm_year + 1900, lt.tm_mon + 1, lt.tm_mday,
+ lt.tm_hour, lt.tm_min, lt.tm_sec);
+ return std::wstring(buf);
+}
+
+// 弹出系统保存对话框,返回用户选择的文件完整路径(含 .png 后缀);
+// 用户取消或失败时返回空字符串。
+// hwndOwner:父窗口句柄(截图覆盖层),用于模态居中。
+// 注意:覆盖层是 WS_EX_TOPMOST 全屏窗口,通用对话框可能被遮挡。
+// 弹出前临时移除其 TOPMOST(让对话框自然置顶),关闭后恢复,保证对话框可见可交互。
+
+std::wstring PromptSaveFilePath(HWND hwndOwner) {
+ // 默认目录:图片库(FOLDERID_Pictures),获取失败则退化为桌面
+ wchar_t* defaultDir = nullptr;
+ HRESULT hr = SHGetKnownFolderPath(FOLDERID_Pictures, 0, NULL, &defaultDir);
+ std::wstring initDir;
+ if (SUCCEEDED(hr) && defaultDir) {
+ initDir = defaultDir;
+ CoTaskMemFree(defaultDir);
+ } else {
+ wchar_t* desktop = nullptr;
+ if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Desktop, 0, NULL, &desktop)) && desktop) {
+ initDir = desktop;
+ CoTaskMemFree(desktop);
+ }
+ }
+
+ std::wstring defaultName = MakeDefaultScreenshotName();
+
+ wchar_t fileBuf[MAX_PATH] = {0};
+ wcsncpy_s(fileBuf, MAX_PATH, defaultName.c_str(), _TRUNCATE);
+
+ // 临时取消覆盖层 TOPMOST,确保保存对话框显示在最上层
+ if (hwndOwner) {
+ SetWindowPos(hwndOwner, HWND_NOTOPMOST, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
+ }
+
+ OPENFILENAMEW ofn = {0};
+ ofn.lStructSize = sizeof(OPENFILENAMEW);
+ ofn.hwndOwner = hwndOwner;
+ ofn.lpstrFile = fileBuf;
+ ofn.nMaxFile = MAX_PATH;
+ ofn.lpstrFilter = L"PNG 图像 (*.png)\0*.png\0";
+ ofn.nFilterIndex = 1;
+ ofn.lpstrDefExt = L"png"; // 用户未输扩展名时自动补 .png
+ if (!initDir.empty()) {
+ ofn.lpstrInitialDir = initDir.c_str();
+ }
+ ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY
+ | OFN_NOCHANGEDIR;
+
+ BOOL ok = GetSaveFileNameW(&ofn);
+
+ // 恢复覆盖层 TOPMOST(用户取消保存时需要回到置顶全屏状态)
+ if (hwndOwner) {
+ SetWindowPos(hwndOwner, HWND_TOPMOST, 0, 0, 0, 0,
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
+ }
+
+ if (ok) {
+ return std::wstring(fileBuf);
+ }
+ return std::wstring();
+}
+
+// 将已合成标注的选区 HBITMAP 保存为 PNG 文件。
+// 返回 true 表示保存成功。
+// mosaicSizeIdx:马赛克块大小索引(显式传参,消除对全局 g_captureCtx 的穿透耦合)。
+// 选区拷贝/缩放任一环节失败时同样返回 false 并清理全部已建 GDI 资源,不会落盘黑图。
+
+bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy,
+ double dpiScale, const std::vector& anns,
+ const std::wstring& filePath, int radius, int mosaicSizeIdx) {
+ int width = rect.right - rect.left;
+ int height = rect.bottom - rect.top;
+ if (width <= 0 || height <= 0 || filePath.empty()) return false;
+
+ // radius>0:圆角透明导出(32bpp ARGB + 圆角蒙版),GDI+ 直接保存为 PNG 文件
+ if (radius > 0) {
+ HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0;
+ HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius,
+ mosaicSizeIdx, fDC, fBits, fw, fh);
+ bool ok = false;
+ if (fbmp && fBits) {
+ CLSID pngClsid;
+ if (GetPngEncoderClsid(&pngClsid) >= 0) {
+ Gdiplus::Bitmap bmp(fw, fh, fw * 4, PixelFormat32bppPARGB, (BYTE*)fBits);
+ ok = (bmp.Save(filePath.c_str(), &pngClsid, NULL) == Gdiplus::Ok);
+ }
+ }
+ if (fDC) DeleteDC(fDC);
+ if (fbmp) DeleteObject(fbmp);
+ return ok;
+ }
+
+ // radius==0:原有不透明位图路径(公共合成,CR-019 收口至 ComposeSelectedBitmap)
+ HDC finalDC = NULL; HBITMAP finalBmp = NULL;
+ bool ok = false;
+ if (ComposeSelectedBitmap(memDC, rect, vx, vy, dpiScale, anns, mosaicSizeIdx,
+ finalDC, finalBmp)) {
+ // 用 GDI+ 保存为 PNG 文件(GDI+ 已由会话级 InitGdipResources 启动)
+ Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(finalBmp, NULL);
+ if (bmp) {
+ CLSID pngClsid;
+ if (GetPngEncoderClsid(&pngClsid) >= 0) {
+ ok = (bmp->Save(filePath.c_str(), &pngClsid, NULL) == Gdiplus::Ok);
+ }
+ delete bmp;
+ }
+ }
+
+ DeleteDC(finalDC); // NULL 安全
+ DeleteObject(finalBmp);
+ return ok;
+}
diff --git a/src/screenshot/overlay_input_windows.cpp b/src/screenshot/overlay_input_windows.cpp
new file mode 100644
index 0000000..87541d5
--- /dev/null
+++ b/src/screenshot/overlay_input_windows.cpp
@@ -0,0 +1,1609 @@
+// 截图模块:覆盖层鼠标/键盘/IME 消息处理(从原 screenshot_windows.cpp 的 WndProc 提取)
+#include "internal.h"
+#include // IME 输入法支持(OnImeComposition 取 GCS_RESULTSTR 合成结果字符串)
+
+// ==================== 文字提交 / 参数回显公共块 ====================
+
+// 把输入缓冲中的文字按当前字号/颜色固化为一条文字标注并入栈历史(缓冲为空则无任何副作用)。
+// 六条提交路径(子菜单字号/颜色点击、工具栏按钮、选区内换位、选区外退出、Enter 键)
+// 构造参数完全一致,统一走此函数避免逐处手抄漂移。
+
+static void CommitPendingText(CaptureContext* ctx) {
+ if (!ctx->textBuf.empty()) {
+ Annotation textAnnotation = {};
+ textAnnotation.type = AT_Text;
+ textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
+ textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
+ textAnnotation.x1 = ctx->textAnchorX;
+ textAnnotation.y1 = ctx->textAnchorY;
+ textAnnotation.text = ctx->textBuf;
+ PushAnnotationHistory(ctx);
+ ctx->annotations.push_back(textAnnotation);
+ }
+}
+
+// 提交文字后的完整编辑出口:清空缓冲/光标/选择区间并回到确认态,全屏重绘。
+// Enter 提交路径历史上不清选择区间、点选区内路径保持编辑态,二者只复用
+// 上面的提交核心,不走本函数(保持各自原有清理序列不变)。
+
+static void CommitPendingTextAndExitEditing(HWND hwnd, CaptureContext* ctx) {
+ ctx->textBuf.clear();
+ ctx->textCaretPos = 0;
+ ctx->textSelStart = -1;
+ ctx->textSelEnd = -1;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+}
+
+// 参数回显公共块:在对应预设表中顺序查找首个匹配项并写回子菜单高亮索引;
+// 无匹配时保持原索引不变(与逐处内联循环语句逐句等价)。矢量标注回显粗细、
+// 文字标注回显字号到第一组,颜色到第二组。
+
+static void EchoThickIdx(CaptureContext* ctx, int thicknessPx) {
+ for (int i = 0; i < SC_THICK_COUNT; i++) {
+ if (SC_THICK_PRESETS[i] == thicknessPx) { ctx->drawThickIdx = i; break; }
+ }
+}
+
+static void EchoFontIdx(CaptureContext* ctx, int fontSizePx) {
+ for (int i = 0; i < SC_FONT_COUNT; i++) {
+ if (SC_FONT_SIZES[i] == fontSizePx) { ctx->fontSizeIdx = i; break; }
+ }
+}
+
+static void EchoColorIdx(CaptureContext* ctx, COLORREF color) {
+ for (int i = 0; i < SC_COLOR_COUNT; i++) {
+ if (SC_COLOR_PRESETS[i] == color) { ctx->drawColorIdx = i; break; }
+ }
+}
+
+LRESULT OnLButtonDown(HWND hwnd, CaptureContext* ctx) {
+ // 长截图期间覆盖层已被隐藏(灰蒙版 + 面板 + 工具栏接管全部交互),不会收到消息;
+ // 保险起见直接忽略,不进入任何编辑态分支。
+ if (ctx->state == CS_LongCapturing) return 0;
+ if (ctx->state == CS_Idle) {
+ // 开始新的框选
+ ctx->startX = ctx->mouseX;
+ ctx->startY = ctx->mouseY;
+ ctx->endX = ctx->mouseX;
+ ctx->endY = ctx->mouseY;
+ ctx->selectionCornerRadius = 0; // 新框选从直角开始
+ ctx->state = CS_Selecting;
+ ctx->needFullRedraw = true;
+ } else if (ctx->state == CS_TextEditing) {
+ // 文字编辑态:允许点击工具栏/子菜单操作,或点击当前输入框内选择文字
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+
+ // 命中粗细/颜色子菜单:切换属性,提交文字,回到确认态
+ if (ctx->popupOpen) {
+ int hit = HitTestPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
+ if (hit > 0) {
+ // 字号切换
+ ctx->fontSizeIdx = hit - 1;
+ // 提交当前文字(如果有),应用新属性给选中的文字标注
+ CommitPendingTextAndExitEditing(hwnd, ctx);
+ return 0;
+ }
+ if (hit < 0) {
+ // 颜色切换
+ ctx->drawColorIdx = -hit - 1;
+ // 提交当前文字
+ CommitPendingTextAndExitEditing(hwnd, ctx);
+ return 0;
+ }
+ }
+
+ // 点击工具栏按钮:先提交当前文字,再回到确认态
+ int toolbarBtn = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
+ if (toolbarBtn >= 0 && toolbarBtn != TB_Separator1 && toolbarBtn != TB_Separator2) {
+ // 先提交当前文字(如果有)
+ CommitPendingTextAndExitEditing(hwnd, ctx);
+ return 0;
+ }
+
+ // 点击当前输入框内:开始文字选择或移动光标
+ // 命中区与绘制边框完全一致(用 GDI+ 紧凑度量),否则点边框附近算"框外"会提交文字。
+ int textX = ctx->textAnchorX - ctx->virtualX;
+ int textY = ctx->textAnchorY - ctx->virtualY;
+ int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
+
+ float offX = 0, offY = 0, textW = 0, textH = 0;
+ MeasureTextGdip(ctx->backDC, ctx->textBuf, fontPx, offX, offY, textW, textH);
+ float glyphLeft = (float)textX + offX;
+ float glyphTop = (float)textY + offY;
+ float glyphW = (textW > 20.0f || !ctx->textBuf.empty()) ? textW : 20.0f;
+ float glyphH = (textH > 0 ? textH : (float)fontPx);
+
+ const float padding = 4.0f;
+ RECT inputBox = {
+ (int)floorf(glyphLeft - padding),
+ (int)floorf(glyphTop - padding),
+ (int)ceilf(glyphLeft + glyphW + padding),
+ (int)ceilf(glyphTop + glyphH + padding)
+ };
+
+ if (PointInRect(mxRel, myRel, inputBox)) {
+ // 点击输入框内:计算光标位置并开始选择
+ int caretPos = CalcCaretPosFromMouse(ctx->backDC, ctx->textBuf, fontPx, textX, mxRel);
+
+ ctx->textCaretPos = caretPos;
+ ctx->textSelStart = caretPos;
+ ctx->textSelEnd = caretPos;
+ ctx->textDraggingSelection = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+
+ // 点击选区内其他位置:提交当前文字,开始新的输入
+ if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ CommitPendingText(ctx);
+ ctx->textBuf.clear();
+ ctx->textAnchorX = ctx->mouseX;
+ ctx->textAnchorY = ctx->mouseY;
+ ctx->textCaretPos = 0;
+ ctx->textSelStart = -1;
+ ctx->textSelEnd = -1;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+
+ // 点击选区外:提交文字并退出文字编辑态
+ CommitPendingTextAndExitEditing(hwnd, ctx);
+ return 0;
+ } else if (ctx->state == CS_Confirmed) {
+ // 实时命中测试(不依赖 hover 缓存值)。
+ // 远程桌面(RDP)下 WM_MOUSEMOVE 常被节流/合并,hoverToolbarBtn/hoveredTextAnnotation
+ // 可能滞后于实际鼠标位置。若继续用缓存判断,点击文字标注时可能误命中残留的工具栏
+ // 索引并提前 return,导致文字选中/拖拽分支永远执行不到。
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+ int b = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
+
+ // 最左「6 点把手」:进入工具栏整体拖拽(不触发任何工具按钮)。
+ // 置 toolbarPlaced 后 OnPaint 不再随选区自动重算位置,此后由
+ // MOUSEMOVE 按「按下矩形 + 鼠标位移」平移并钳制在虚拟屏幕内。
+ if (b == SC_TB_GRIP) {
+ ctx->toolbarDragging = true;
+ ctx->toolbarPlaced = true;
+ ctx->toolbarDragStartX = ctx->mouseX;
+ ctx->toolbarDragStartY = ctx->mouseY;
+ ctx->toolbarDragStartRect = ctx->toolbarRect;
+ return 0;
+ }
+
+ // 点击工具栏按钮(实时命中)
+ if (b >= 0 && b != TB_Separator1 && b != TB_Separator2) {
+ // 选中态保留规则(与 Figma/PowerPoint 一致):
+ // - 点击"与已选中标注同类型"的绘制工具按钮(含文字)-> 保留选中,仅切换子菜单开合
+ // (例:选中矩形后点矩形按钮 -> 关闭/重开粗细颜色子菜单,矩形仍选中)。
+ // - 点击"不同类型"的绘制工具按钮 -> 切换到该绘制工具,取消当前选中
+ // (子菜单参数将用于后续绘制,不再作用于旧选中元素,避免参数错配)。
+ // - 点击确认/取消/保存/撤销/重做等"与选中元素无关"的按钮 -> 取消所有选中。
+ bool isDrawToolBtn = IsVectorTool(b) || b == TB_Mosaic || b == TB_Text || b == TB_Drag;
+ bool matchesSelection = false;
+ if (isDrawToolBtn) {
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
+ && AnnotationTypeToTool(ctx->annotations[ctx->selectedAnnotation].type) == b) {
+ matchesSelection = true;
+ }
+ if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()
+ && b == TB_Text) {
+ matchesSelection = true;
+ }
+ }
+ bool hadAnnSel = (ctx->selectedAnnotation >= 0);
+ bool hadTextSel = (ctx->selectedTextAnnotation >= 0);
+ if (!matchesSelection) {
+ // 不匹配选中项:取消所有选中(切换到其它工具或执行无关操作)。
+ // 切换到不同类型工具时 activeTool 必变,高亮按钮位移 + popup 可能切换,
+ // 故脏区须含工具栏+popup(includeToolbar=true)。需在清状态前算脏区。
+ RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->selectedTextAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ if (hadAnnSel || hadTextSel) {
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ }
+ }
+ // 确定:提取选区并完成截图
+ if (b == TB_Confirm) {
+ ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius,
+ ctx->mosaicSizeIdx);
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。
+ EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2,
+ result->width, result->height, result->base64);
+ delete result;
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ return 0;
+ }
+ // 取消:回调失败并关闭(统一走 EmitScreenshotResult)
+ if (b == TB_Cancel) {
+ EmitScreenshotResult(false);
+ ctx->state = CS_Cancelled;
+ DestroyWindow(hwnd);
+ return 0;
+ }
+ // 矢量工具:切换激活态 + 打开/关闭粗细颜色子菜单
+ if (b == TB_Rect || b == TB_Circle || b == TB_Arrow || b == TB_Brush) {
+ if (ctx->activeTool == b) {
+ // 再次点同一工具:关闭工具与子菜单
+ ctx->activeTool = -1;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ } else {
+ ctx->activeTool = b;
+ ctx->popupTool = b;
+ ctx->popupOpen = true;
+ // 匹配选中项时回显该标注的粗细/颜色到子菜单
+ if (matchesSelection && ctx->selectedAnnotation >= 0
+ && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
+ EchoThickIdx(ctx, selA.thickness);
+ EchoColorIdx(ctx, selA.color);
+ }
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ // 文字工具:切换激活态 + 打开/关闭子菜单(字号+颜色)
+ if (b == TB_Text) {
+ if (ctx->activeTool == b) {
+ // 再次点同一工具:关闭工具与子菜单
+ ctx->activeTool = -1;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ } else {
+ ctx->activeTool = b;
+ ctx->popupTool = b;
+ ctx->popupOpen = true;
+ // 匹配选中文字时回显该标注的字号/颜色到子菜单
+ if (matchesSelection && ctx->selectedTextAnnotation >= 0
+ && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ const Annotation& selT = ctx->annotations[ctx->selectedTextAnnotation];
+ EchoFontIdx(ctx, selT.thickness);
+ EchoColorIdx(ctx, selT.color);
+ }
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ // 马赛克工具:切换激活态 + 打开/关闭子菜单(模式+块大小)
+ if (b == TB_Mosaic) {
+ if (ctx->activeTool == b) {
+ // 再次点同一工具:关闭工具与子菜单
+ ctx->activeTool = -1;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ } else {
+ ctx->activeTool = b;
+ ctx->popupTool = b;
+ ctx->popupOpen = true;
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ if (b == TB_Drag) {
+ if (ctx->activeTool == b) {
+ ctx->activeTool = -1;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ } else {
+ ctx->activeTool = b;
+ if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ ctx->popupTool = TB_Text;
+ const Annotation& selT = ctx->annotations[ctx->selectedTextAnnotation];
+ EchoFontIdx(ctx, selT.thickness);
+ EchoColorIdx(ctx, selT.color);
+ ctx->popupOpen = true;
+ } else if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
+ ctx->popupTool = AnnotationTypeToTool(selA.type);
+ EchoThickIdx(ctx, selA.thickness);
+ EchoColorIdx(ctx, selA.color);
+ ctx->popupOpen = CanShowStylePopupTool(ctx->popupTool);
+ } else {
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ }
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ // 撤销:恢复上一份标注快照
+ if (b == TB_Undo) {
+ if (UndoAnnotations(ctx)) {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ // 重做:恢复下一份标注快照
+ if (b == TB_Redo) {
+ if (RedoAnnotations(ctx)) {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ // 保存到本地:弹出系统保存对话框,保存为 PNG 后关闭截图
+ if (b == TB_Save) {
+ std::wstring filePath = PromptSaveFilePath(hwnd);
+ if (!filePath.empty()) {
+ bool saved = SaveRegionToPngFile(ctx->memDC, ctx->selection,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale,
+ ctx->annotations, filePath, ctx->selectionCornerRadius, ctx->mosaicSizeIdx);
+ // 无论保存成功与否,均关闭截图窗口(用户已选择保存路径)
+ // 通过回调告知 JS 结果(成功/失败),不回传路径。
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏
+ // (原代码先 new 后判 TSFN,TSFN 为空时 result 泄漏 —— 此处一并修复)。
+ if (saved) {
+ EmitScreenshotResult(true, ctx->selection.left, ctx->selection.top,
+ ctx->selection.right, ctx->selection.bottom,
+ ctx->selection.right - ctx->selection.left,
+ ctx->selection.bottom - ctx->selection.top);
+ } else {
+ EmitScreenshotResult(false);
+ }
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ }
+ // 用户取消保存对话框:不关闭,留在编辑态
+ return 0;
+ }
+ // 长截图:复用当前选区进入手动滚动捕获(隐藏覆盖层 → 预览面板 → 滚轮增量拼接)
+ if (b == TB_LongCapture) {
+ BeginLongCapture(ctx, hwnd);
+ return 0;
+ }
+ return 0;
+ }
+
+ // 命中马赛克子菜单(模式切换 + 块大小 + 涂抹半径)
+ // 马赛克标注本身不可选中,且这些选项只影响后续绘制、不作用于已选中的其它元素,
+ // 故均属于"非拖拽/resize 动作"-> 取消当前选中态。
+ if (ctx->popupOpen && ctx->popupTool == TB_Mosaic) {
+ int hit = HitTestMosaicPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
+ // 清除选中态并在清空前计算脏区(马赛克 popup 不改 activeTool,仅清覆盖物选中)。
+ // 返回 dirty(backDC 坐标);调用方据 IsValidRect 决定局部或全屏 invalidate。
+ auto clearSel = [&]() {
+ RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
+ ctx->selectedAnnotation = -1;
+ ctx->selectedTextAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ return dirty;
+ };
+ if (hit == 1) {
+ ctx->mosaicRectMode = false; // 涂抹模式
+ { RECT d = clearSel();
+ if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
+ return 0;
+ }
+ if (hit == 2) {
+ ctx->mosaicRectMode = true; // 框选模式
+ { RECT d = clearSel();
+ if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
+ return 0;
+ }
+ // 命中码编码预留区间远宽于预设表实际档数(见 HitTestMosaicPopup 返回约定),
+ // 索引合法性不能依赖生成端巧合:超出现有档位的命中码直接忽略本次点击,
+ // 防止常量表扩档或编码变动后 mosaicSizeIdx/mosaicRadiusIdx 越界(下游多处无校验直取数组)。
+ if (hit >= 101 && hit < 200) {
+ if ((size_t)(hit - 101) >= (size_t)SC_MOSAIC_COUNT) return 0;
+ ctx->mosaicSizeIdx = hit - 101;
+ { RECT d = clearSel();
+ if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
+ return 0;
+ }
+ if (hit >= 201) {
+ if ((size_t)(hit - 201) >= (size_t)SC_MOSAIC_RADIUS_COUNT) return 0;
+ ctx->mosaicRadiusIdx = hit - 201;
+ { RECT d = clearSel();
+ if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
+ return 0;
+ }
+ }
+
+ // 命中粗细/颜色子菜单
+ if (ctx->popupOpen) {
+ int hit = HitTestPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
+ if (hit > 0) {
+ // 第一组:文字工具时为字号索引,矢量工具时为粗细索引
+ if (ctx->popupTool == TB_Text) {
+ ctx->fontSizeIdx = hit - 1;
+ // 如果选中了文字标注,修改其字号(选中文字时子菜单改动作用于该标注)
+ if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ int newSize = SC_FONT_SIZES[ctx->fontSizeIdx];
+ if (ctx->annotations[ctx->selectedTextAnnotation].thickness != newSize) {
+ PushAnnotationHistory(ctx);
+ ctx->annotations[ctx->selectedTextAnnotation].thickness = newSize;
+ ctx->annotations[ctx->selectedTextAnnotation].textCacheValid = false;
+ }
+ }
+ } else {
+ ctx->drawThickIdx = hit - 1;
+ // 矢量工具改粗细:若已选中矢量标注,则作用于该标注(保持选中);
+ // 否则作用于后续绘制。
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ int newThickness = SC_THICK_PRESETS[ctx->drawThickIdx];
+ if (ctx->annotations[ctx->selectedAnnotation].thickness != newThickness) {
+ PushAnnotationHistory(ctx);
+ ctx->annotations[ctx->selectedAnnotation].thickness = newThickness;
+ }
+ }
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ if (hit < 0) {
+ ctx->drawColorIdx = -hit - 1;
+ // 如果选中了文字标注,修改其颜色(选中文字时颜色改动作用于该标注)
+ if (ctx->popupTool == TB_Text && ctx->selectedTextAnnotation >= 0
+ && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ COLORREF newColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
+ if (ctx->annotations[ctx->selectedTextAnnotation].color != newColor) {
+ PushAnnotationHistory(ctx);
+ ctx->annotations[ctx->selectedTextAnnotation].color = newColor;
+ }
+ }
+ // 矢量工具改颜色:若已选中矢量标注,则作用于该标注(保持选中);
+ // 否则作用于后续绘制。
+ if (ctx->popupTool != TB_Text && ctx->selectedAnnotation >= 0
+ && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ COLORREF newColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
+ if (ctx->annotations[ctx->selectedAnnotation].color != newColor) {
+ PushAnnotationHistory(ctx);
+ ctx->annotations[ctx->selectedAnnotation].color = newColor;
+ }
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ }
+
+ // ===== 通用标注交互(选中/拖拽/缩放,优先于绘制工具)=====
+ // 1) 已选中标注的四角 resize 手柄命中 -> 进入缩放
+ // (须在普通命中之前:手柄贴在选中框角上,可能与标注本体重叠)
+ // 不复用 CS_Resizing(该状态会改 ctx->selection 选区),用 resizingAnnotation 标志
+ // 在 CS_Confirmed 下独立处理,与文字拖拽(draggingTextAnnotation)机制对称。
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
+ && !IsDragTool(ctx->activeTool)) {
+ Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
+ RECT box = MeasureAnnotationBounds(sel, ctx->backDC);
+ // 按类型命中缩放手柄:箭头=2 端点;矩形/圆=8 手柄;画笔=无(仅可拖动)
+ int handle = HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY, ctx->backDC, ctx->handleMetrics.handleSize);
+ if (handle != RH_None) {
+ ctx->resizingAnnotation = ctx->selectedAnnotation;
+ ctx->annotationResizeHandle = handle;
+ ctx->annotationDragStartX = ctx->mouseX;
+ ctx->annotationDragStartY = ctx->mouseY;
+ ctx->annotationResizeStartBox = box;
+ ctx->dragStartAnnotation = ctx->annotations[ctx->selectedAnnotation];
+ ctx->annotationOpHistoryPushed = false;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+ }
+ // 2) 文字标注命中 -> 优先选中并可拖动
+ // 文字与画笔/矩形等覆盖物重叠时,优先进入文字选中逻辑,避免被非文字命中分支吞掉。
+ int hitText = HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
+ if (hitText >= 0) {
+ if (ctx->activeTool == TB_Text) {
+ // 选中文字标注,保持确认态。
+ // selectedTextAnnotation 持久保持选中态(与 hover 解耦),鼠标移开仍高亮,
+ // 直到点击空白或进入其他操作才清除。
+ // 精确脏区:清掉上一个选中项(可能为非文字/另一文字)的选中边框。
+ // 须在赋新选中前算脏区,否则读不到旧选中项的包围盒。
+ RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->selectedTextAnnotation = hitText;
+ ctx->hoveredTextAnnotation = hitText;
+ // 工具栏回显:文字工具保持高亮,并继续回显文字参数。
+ ctx->activeTool = TB_Text;
+ ctx->popupTool = TB_Text;
+ EchoFontIdx(ctx, ctx->annotations[hitText].thickness);
+ EchoColorIdx(ctx, ctx->annotations[hitText].color);
+ ctx->popupOpen = true;
+ ctx->draggingTextAnnotation = hitText;
+ ctx->textDragStartX = ctx->mouseX;
+ ctx->textDragStartY = ctx->mouseY;
+ ctx->dragStartX = ctx->annotations[hitText].x1;
+ ctx->dragStartY = ctx->annotations[hitText].y1;
+ ctx->annotationOpHistoryPushed = false;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ } else {
+ // 非文字工具或空态下:切换选中目标到文字。
+ const int handleMargin = ctx->handleMetrics.handleMargin;
+ RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
+ RECT newBox = MeasureTextAnnotation(ctx->backDC, ctx->annotations[hitText]);
+ newBox.left -= ctx->virtualX; newBox.top -= ctx->virtualY;
+ newBox.right -= ctx->virtualX; newBox.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(newBox, handleMargin));
+ ctx->draggingTextAnnotation = hitText;
+ ctx->hoveredAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredTextAnnotation = hitText;
+ ctx->selectedTextAnnotation = hitText;
+ // 工具栏回显:切到文字工具按钮,打开字号/颜色子菜单,同步该标注参数。
+ ctx->activeTool = TB_Text;
+ ctx->popupTool = TB_Text;
+ ctx->popupOpen = true;
+ EchoFontIdx(ctx, ctx->annotations[hitText].thickness);
+ EchoColorIdx(ctx, ctx->annotations[hitText].color);
+ ctx->textDragStartX = ctx->mouseX;
+ ctx->textDragStartY = ctx->mouseY;
+ ctx->dragStartX = ctx->annotations[hitText].x1;
+ ctx->dragStartY = ctx->annotations[hitText].y1;
+ ctx->annotationOpHistoryPushed = false;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ }
+
+ // 3) 任意非文字标注命中 -> 选中并可拖拽
+ // 工具激活时也优先选中已有对象(与 Figma/PowerPoint 一致),点空白才绘制。
+ int hitAnn = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
+ if (hitAnn >= 0 && ctx->annotations[hitAnn].type != AT_Text) {
+ // 切换选中目标:脏区 = 旧选中项(清掉其边框/手柄)∪ 新目标(显示新边框/手柄)
+ // ∪ 工具栏+popup(activeTool 可能变化导致高亮按钮位移)。
+ // 须在改 selectedAnnotation 之前算旧选中脏区。
+ const int handleMargin = ctx->handleMetrics.handleMargin;
+ RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
+ RECT newBox = MeasureAnnotationBounds(ctx->annotations[hitAnn], ctx->backDC);
+ newBox.left -= ctx->virtualX; newBox.top -= ctx->virtualY;
+ newBox.right -= ctx->virtualX; newBox.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(newBox, handleMargin));
+ ctx->selectedAnnotation = hitAnn;
+ ctx->hoveredAnnotation = hitAnn;
+ // 与文字选中互斥:选中非文字时清文字选中
+ ctx->selectedTextAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ // 工具栏回显:保持原有回显逻辑。
+ // 同步当前粗细/颜色索引为该标注的值,子菜单高亮与选中元素一致(回显参数)。
+ const Annotation& hitA = ctx->annotations[hitAnn];
+ ctx->activeTool = AnnotationTypeToTool(hitA.type);
+ ctx->popupTool = ctx->activeTool;
+ ctx->popupOpen = true;
+ EchoThickIdx(ctx, hitA.thickness);
+ EchoColorIdx(ctx, hitA.color);
+ // 进入拖拽模式:按下即可移动(按下未移动时,松开仅保持选中态)
+ ctx->draggingAnnotation = hitAnn;
+ ctx->annotationDragStartX = ctx->mouseX;
+ ctx->annotationDragStartY = ctx->mouseY;
+ ctx->dragStartAnnotation = ctx->annotations[hitAnn];
+ ctx->annotationOpHistoryPushed = false;
+ // 触发重绘:needFullRedraw 仅是 WM_PAINT 内的提示,必须有 InvalidateRect 才会触发 WM_PAINT。
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+
+ // 矢量工具激活时,点击选区内部/工具栏外 -> 开始绘制
+ // 子菜单保持打开,绘制中可继续看到当前粗细/颜色。
+ // 注意:上面通用标注命中分支已拦截「点中已有标注」的情况,此处仅点空白才进入。
+ if (IsVectorTool(ctx->activeTool) && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ // 点空白绘制新标注:清除已有选中态
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->hasCurDrawing = true;
+ ctx->curDrawing = {};
+ ctx->curDrawing.type = ToolToAnnotationType(ctx->activeTool);
+ ctx->curDrawing.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
+ ctx->curDrawing.thickness = SC_THICK_PRESETS[ctx->drawThickIdx];
+ // 起点用绝对虚拟屏幕坐标
+ ctx->curDrawing.x1 = ctx->mouseX;
+ ctx->curDrawing.y1 = ctx->mouseY;
+ ctx->curDrawing.x2 = ctx->curDrawing.x1;
+ ctx->curDrawing.y2 = ctx->curDrawing.y1;
+ if (ctx->curDrawing.type == AT_Brush) {
+ POINT p = { ctx->curDrawing.x1, ctx->curDrawing.y1 };
+ ctx->curDrawing.pts.push_back(p);
+ }
+ ctx->state = CS_Drawing;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+
+ // 马赛克工具激活时,点击选区内部 -> 开始马赛克绘制
+ // 子菜单保持打开,绘制中可继续看到当前模式/块大小。
+ if (ctx->activeTool == TB_Mosaic && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ // 点空白绘制新标注:清除已有选中态
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->hasCurDrawing = true;
+ ctx->curDrawing = {};
+ ctx->curDrawing.type = AT_Mosaic;
+ ctx->curDrawing.color = 0; // 马赛克无颜色
+ ctx->curDrawing.mosaicRect = ctx->mosaicRectMode;
+ ctx->curDrawing.mosaicSize = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
+ ctx->curDrawing.brushRadius = SC_MOSAIC_RADIUS[ctx->mosaicRadiusIdx];
+ ctx->curDrawing.x1 = ctx->mouseX;
+ ctx->curDrawing.y1 = ctx->mouseY;
+ ctx->curDrawing.x2 = ctx->curDrawing.x1;
+ ctx->curDrawing.y2 = ctx->curDrawing.y1;
+ if (!ctx->mosaicRectMode) {
+ // 涂抹模式:记录路径起点(揭示由 WM_PAINT 统一处理)
+ POINT p = { ctx->curDrawing.x1, ctx->curDrawing.y1 };
+ ctx->curDrawing.pts.push_back(p);
+ }
+ ctx->state = CS_Drawing;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+
+ // 文字工具激活时:点击选区内空白 -> 进入输入态
+ if (ctx->activeTool == TB_Text) {
+ if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ ctx->textBuf.clear();
+ ctx->textAnchorX = ctx->mouseX;
+ ctx->textAnchorY = ctx->mouseY;
+ ctx->textCaretPos = 0;
+ ctx->textSelStart = -1;
+ ctx->textSelEnd = -1;
+ // 精确脏区:清掉上一个选中项的选中边框/手柄。须在清状态前算。
+ RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
+ ctx->state = CS_TextEditing;
+ // 进入输入态时清除文字/非文字标注选中,避免残留选中边框
+ ctx->hoveredTextAnnotation = -1;
+ ctx->selectedTextAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ // 保持子菜单打开,清空绘制标志
+ // ctx->popupOpen 保持不变
+ ctx->hasCurDrawing = false;
+ // 文字工具保持激活,避免工具栏视觉状态丢失
+ // ctx->activeTool 保持为 TB_Text
+ // 不置 needFullRedraw:进入编辑态仅改变选中边框,用局部脏区即可,避免全屏重绘。
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ }
+
+ // 命中调整手柄 -> 进入 Resizing
+ int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
+ if (h != RH_None) {
+ ctx->selectedTextAnnotation = -1; // 进入手柄调整,清除文字选中
+ ctx->selectedAnnotation = -1; // 清除非文字标注选中
+ ctx->hoveredAnnotation = -1;
+ ctx->resizeHandle = h;
+ ctx->dragStartX = ctx->mouseX;
+ ctx->dragStartY = ctx->mouseY;
+ ctx->dragStartSelection = ctx->selection;
+ ctx->kbDX = 0;
+ ctx->kbDY = 0;
+ ctx->state = CS_Resizing;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+ // 命中圆角手柄 -> 进入圆角调整(复用 CS_Resizing + 对应角手柄)
+ int corner = HitTestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
+ ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset, ctx->selectionCornerRadius);
+ if (corner != RH_None) {
+ ctx->selectedTextAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->resizeHandle = corner; // 记录具体角,决定拖拽方向与光标
+ ctx->dragStartX = ctx->mouseX;
+ ctx->dragStartY = ctx->mouseY;
+ ctx->dragStartSelection = ctx->selection;
+ ctx->dragStartRadius = ctx->selectionCornerRadius;
+ ctx->kbDX = 0;
+ ctx->kbDY = 0;
+ ctx->state = CS_Resizing;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+ // 点击选区内部 -> 整体拖动(已有标注内容时禁止,避免标注与背景错位)
+ if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ if (!ctx->annotations.empty()) {
+ // 已有预处理内容:只能通过手柄改范围,不允许整体拖动。
+ // 点空白(选中态的拖动被禁止)-> 取消当前选中。
+ if (ctx->selectedAnnotation >= 0 || ctx->selectedTextAnnotation >= 0) {
+ // 精确脏区:清掉选中项的选中边框/手柄。须在清状态前算。
+ RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
+ ctx->selectedTextAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ }
+ return 0;
+ }
+ ctx->selectedTextAnnotation = -1; // 进入选区拖动,清除文字选中
+ ctx->selectedAnnotation = -1; // 清除非文字标注选中
+ ctx->hoveredAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ ctx->dragStartX = ctx->mouseX;
+ ctx->dragStartY = ctx->mouseY;
+ ctx->dragStartSelection = ctx->selection;
+ ctx->state = CS_Moving;
+ ctx->needFullRedraw = true;
+ return 0;
+ }
+ // 点击选区外空白:清除文字/非文字标注选中,进入确认态后不可重新框选,忽略点击
+ {
+ RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
+ ctx->selectedTextAnnotation = -1;
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ }
+ return 0;
+ }
+ return 0;
+}
+
+LRESULT OnMouseMove(HWND hwnd, CaptureContext* ctx) {
+ POINT pt;
+ GetCursorPos(&pt);
+ ctx->mouseX = pt.x;
+ ctx->mouseY = pt.y;
+ // currentColor 仅用于放大镜信息面板(DrawInfoPanel)。该面板在 CS_Idle/CS_Selecting
+ // 以及 CS_Resizing(标准手柄)态绘制:前两者在此取色(鼠标处),CS_Resizing 的取色
+ // 由 ApplyResizeSelection 在活动手柄锚点处完成,故此处不再重复。启动时已取一次初值。
+ if (ctx->state == CS_Idle || ctx->state == CS_Selecting) {
+ ctx->currentColor = GetPixelColorFromBitmap(ctx->memDC,
+ ctx->mouseX, ctx->mouseY, ctx->virtualX, ctx->virtualY, ctx->dpiScale);
+ }
+
+ // 工具栏拖拽中(把手按下):按「按下矩形 + 鼠标位移」平移工具栏并局部刷新
+ // 旧 ∪ 新区域。子菜单锚定工具栏,同步预估新位置一并失效;ctx->toolbarRect /
+ // popupRect 即刻更新,保证拖拽帧间命中测试与绘制一致。
+ if (ctx->toolbarDragging) {
+ int dx = pt.x - ctx->toolbarDragStartX;
+ int dy = pt.y - ctx->toolbarDragStartY;
+ RECT n = ctx->toolbarDragStartRect;
+ n.left += dx; n.right += dx; n.top += dy; n.bottom += dy;
+ int tw = n.right - n.left, th = n.bottom - n.top;
+ // 钳制在整个虚拟屏幕内(显式拖放允许跨屏,不受单显示器边界约束)
+ if (n.left < 0) { n.left = 0; n.right = tw; }
+ if (n.top < 0) { n.top = 0; n.bottom = th; }
+ if (n.right > ctx->virtualW) { n.right = ctx->virtualW; n.left = n.right - tw; }
+ if (n.bottom > ctx->virtualH) { n.bottom = ctx->virtualH; n.top = n.bottom - th; }
+ RECT dirty = InflateRectBy(UnionRectSafe(ctx->toolbarRect, n), 2);
+ if (ctx->popupOpen) {
+ RECT np = {0, 0, 0, 0};
+ if (ctx->popupTool == TB_Mosaic) {
+ int mpw, mph;
+ CalcMosaicPopupSize(ctx->popupMetrics, mpw, mph);
+ CalcPopupPlacement(n, ctx->virtualX, ctx->virtualY,
+ ctx->virtualW, ctx->virtualH, ctx->popupMetrics, mpw, mph, np);
+ } else {
+ CalcPopupPosition(n, ctx->virtualX, ctx->virtualY,
+ ctx->virtualW, ctx->virtualH, ctx->popupMetrics, np);
+ }
+ dirty = UnionRectSafe(dirty, InflateRectBy(ctx->popupRect, 2));
+ dirty = UnionRectSafe(dirty, InflateRectBy(np, 2));
+ ctx->popupRect = np;
+ }
+ ctx->toolbarRect = n;
+ InvalidateRect(hwnd, &dirty, FALSE);
+ return 0;
+ }
+
+ if (ctx->state == CS_Selecting) {
+ ctx->endX = ctx->mouseX;
+ ctx->endY = ctx->mouseY;
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else if (ctx->state == CS_Idle) {
+ int newHovered = FindWindowAtPoint(ctx->windows, ctx->mouseX, ctx->mouseY);
+ ctx->hoveredWindow = newHovered;
+ // 像素信息浮窗跟随鼠标:刷新旧面板位置 ∪ 新面板位置(放大镜跟随,两块都需重绘)。
+ // 新面板位置在此预算(与 WM_PAINT 的 CalcPanelPosition 同源)。
+ int npx, npy;
+ CalcPanelPosition(ctx->mouseX, ctx->mouseY,
+ ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->panelMetrics, npx, npy);
+ RECT newPanel = { npx - ctx->virtualX, npy - ctx->virtualY,
+ npx - ctx->virtualX + ctx->panelMetrics.w, npy - ctx->virtualY + ctx->panelMetrics.h };
+ RECT dirty = InflateRectBy(UnionRectSafe(ctx->lastPanelRect, newPanel), 2);
+ // 窗口高亮变化也纳入(hoveredWindow 切换时旧/新高亮框)
+ if (newHovered >= 0 && newHovered < (int)ctx->windows.size()) {
+ RECT hr = ctx->windows[newHovered].rect;
+ hr.left -= ctx->virtualX; hr.top -= ctx->virtualY;
+ hr.right -= ctx->virtualX; hr.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(hr, 5));
+ }
+ dirty = UnionRectSafe(dirty, InflateRectBy(ctx->lastHighlightRect, 5));
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else if (ctx->state == CS_Resizing) {
+ if (IsCornerRadiusHandle(ctx->resizeHandle)) {
+ // 圆角调整:手柄沿所在角对角线滑动,四角同步(共用同一 radius)。
+ // 取鼠标位移在该角对角线方向的投影(两向内分量之和/2)作为半径增量:
+ // 垂直于对角线的位移被忽略,故手柄轨迹恒为对角线。
+ int dx = pt.x - ctx->dragStartX;
+ int dy = pt.y - ctx->dragStartY;
+ int inX, inY;
+ switch (ctx->resizeHandle) {
+ case RH_CornerRadiusTL: inX = dx; inY = dy; break; // 右下为内
+ case RH_CornerRadiusTR: inX = -dx; inY = dy; break; // 左下为内
+ case RH_CornerRadiusBL: inX = dx; inY = -dy; break; // 右上为内
+ case RH_CornerRadiusBR: inX = -dx; inY = -dy; break; // 左上为内
+ default: inX = dx; inY = dy; break;
+ }
+ int diagDelta = (inX + inY) / 2; // 对角线方向位移(轴向)
+ int w = ctx->selection.right - ctx->selection.left;
+ int h = ctx->selection.bottom - ctx->selection.top;
+ int maxR = (std::min)(w, h) / 2;
+ if (maxR < 0) maxR = 0;
+ int r = ctx->dragStartRadius + diagDelta;
+ if (r < 0) r = 0;
+ if (r > maxR) r = maxR;
+ ctx->selectionCornerRadius = r;
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else {
+ // 每帧都从按下时的完整快照重算:活动端可以越过固定端并翻转,固定端不会
+ // 因上一帧 NormalizeRect 后的 left/right、top/bottom 角色交换而漂移。
+ // 鼠标位移 + 键盘微调(kbDX/kbDY) 共同决定活动端;放大镜焦点取活动手柄锚点。
+ ApplyResizeSelection(hwnd, ctx);
+ }
+ } else if (ctx->state == CS_Moving) {
+ // 整体平移
+ int dx = pt.x - ctx->dragStartX;
+ int dy = pt.y - ctx->dragStartY;
+ int sw = ctx->dragStartSelection.right - ctx->dragStartSelection.left;
+ int sh = ctx->dragStartSelection.bottom - ctx->dragStartSelection.top;
+ int nl = ctx->dragStartSelection.left + dx;
+ int nt = ctx->dragStartSelection.top + dy;
+ // 约束到虚拟屏幕
+ if (nl < ctx->virtualX) nl = ctx->virtualX;
+ if (nt < ctx->virtualY) nt = ctx->virtualY;
+ if (nl + sw > ctx->virtualX + ctx->virtualW) nl = ctx->virtualX + ctx->virtualW - sw;
+ if (nt + sh > ctx->virtualY + ctx->virtualH) nt = ctx->virtualY + ctx->virtualH - sh;
+ ctx->selection.left = nl;
+ ctx->selection.top = nt;
+ ctx->selection.right = nl + sw;
+ ctx->selection.bottom = nt + sh;
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else if (ctx->state == CS_Drawing) {
+ // 更新正在绘制的标注终点/路径(选区相对坐标)
+ if (ctx->hasCurDrawing) {
+ // 终点用绝对坐标,钳制到选区内
+ int selL = ctx->selection.left, selR = ctx->selection.right;
+ int selT = ctx->selection.top, selB = ctx->selection.bottom;
+ int ax = (std::max)(selL, (std::min)(ctx->mouseX, selR));
+ int ay = (std::max)(selT, (std::min)(ctx->mouseY, selB));
+ if (ctx->curDrawing.type == AT_Brush) {
+ POINT p = { ax, ay };
+ ctx->curDrawing.pts.push_back(p);
+ } else if (ctx->curDrawing.type == AT_Mosaic && !ctx->curDrawing.mosaicRect) {
+ // 马赛克涂抹模式:记录路径点。揭示由 WM_PAINT 统一处理(reveal-mask 模型,
+ // 每帧从预计算的 base 揭示蒙版区域,无需增量绘制)。
+ POINT p = { ax, ay };
+ ctx->curDrawing.pts.push_back(p);
+ } else {
+ ctx->curDrawing.x2 = ax;
+ ctx->curDrawing.y2 = ay;
+ }
+ // 局部刷新:上帧 curDrawing 包围盒 ∪ 本帧鼠标点附近(标注都在选区内)。
+ // 坐标转 backDC(减 virtualX/Y)。lastDrawingBox 由 WM_PAINT 每帧更新。
+ RECT mouseBox = { ax - ctx->virtualX - 8, ay - ctx->virtualY - 8,
+ ax - ctx->virtualX + 8, ay - ctx->virtualY + 8 };
+ RECT drawDirty;
+ if (ctx->hasLastDrawingBox) {
+ RECT ldb = { ctx->lastDrawingBox.left - ctx->virtualX,
+ ctx->lastDrawingBox.top - ctx->virtualY,
+ ctx->lastDrawingBox.right - ctx->virtualX,
+ ctx->lastDrawingBox.bottom - ctx->virtualY };
+ drawDirty = InflateRectBy(UnionRectSafe(ldb, mouseBox), 4);
+ } else {
+ drawDirty = InflateRectBy(mouseBox, 4);
+ }
+ InvalidateRect(hwnd, &drawDirty, FALSE);
+ }
+ } else if (ctx->state == CS_TextEditing) {
+ // 文字编辑态:拖动选择文字
+ if (ctx->textDraggingSelection) {
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int textX = ctx->textAnchorX - ctx->virtualX;
+ int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
+ int caretPos = CalcCaretPosFromMouse(ctx->backDC, ctx->textBuf, fontPx, textX, mxRel);
+
+ ctx->textSelEnd = caretPos;
+ ctx->textCaretPos = caretPos;
+ InvalidateTextLine(hwnd, ctx);
+ }
+ } else if (ctx->resizingAnnotation >= 0) {
+ // 非文字标注缩放:四角手柄走包围盒变换;箭头端点手柄仅平移单个端点
+ int idx = ctx->resizingAnnotation;
+ int dx = ctx->mouseX - ctx->annotationDragStartX;
+ int dy = ctx->mouseY - ctx->annotationDragStartY;
+ if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
+ PushAnnotationHistory(ctx);
+ ctx->annotationOpHistoryPushed = true;
+ }
+ // 从按下时快照还原再变换,避免累积浮点误差
+ ctx->annotations[idx] = ctx->dragStartAnnotation;
+ Annotation& a = ctx->annotations[idx];
+ if (ctx->annotationResizeHandle == RH_ArrowStart
+ || ctx->annotationResizeHandle == RH_ArrowEnd) {
+ // 箭头端点拖拽:仅移动对应端点,另一端点保持快照值不变
+ if (ctx->annotationResizeHandle == RH_ArrowStart) {
+ a.x1 = ctx->dragStartAnnotation.x1 + dx;
+ a.y1 = ctx->dragStartAnnotation.y1 + dy;
+ } else {
+ a.x2 = ctx->dragStartAnnotation.x2 + dx;
+ a.y2 = ctx->dragStartAnnotation.y2 + dy;
+ }
+ } else {
+ // 包围盒缩放:4 角 + 4 边中点手柄,根据拖拽手柄更新包围盒,
+ // 再按包围盒变换映射标注坐标(矩形/圆均支持 8 手柄)。
+ const RECT& o = ctx->annotationResizeStartBox;
+ RECT n = o;
+ switch (ctx->annotationResizeHandle) {
+ case RH_TopLeft: n.left = o.left + dx; n.top = o.top + dy; break;
+ case RH_TopRight: n.right = o.right + dx; n.top = o.top + dy; break;
+ case RH_BottomLeft: n.left = o.left + dx; n.bottom = o.bottom + dy; break;
+ case RH_BottomRight: n.right = o.right + dx; n.bottom = o.bottom + dy; break;
+ case RH_Left: n.left = o.left + dx; break;
+ case RH_Right: n.right = o.right + dx; break;
+ case RH_Top: n.top = o.top + dy; break;
+ case RH_Bottom: n.bottom = o.bottom + dy; break;
+ }
+ // 防翻转:保证 right>left、bottom>top(至少 1px)
+ n = NormalizeRect(n);
+ if (n.right - n.left < 2) n.right = n.left + 2;
+ if (n.bottom - n.top < 2) n.bottom = n.top + 2;
+ TransformAnnotationByBox(a, o, n);
+ }
+ InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC));
+ } else if (ctx->draggingAnnotation >= 0) {
+ // 非文字标注整体拖拽:对按下时快照做 dx/dy 平移后写回(避免累积误差)
+ int idx = ctx->draggingAnnotation;
+ int dx = ctx->mouseX - ctx->annotationDragStartX;
+ int dy = ctx->mouseY - ctx->annotationDragStartY;
+ if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
+ PushAnnotationHistory(ctx);
+ ctx->annotationOpHistoryPushed = true;
+ }
+ ctx->annotations[idx] = ctx->dragStartAnnotation;
+ Annotation& a = ctx->annotations[idx];
+ switch (a.type) {
+ case AT_Rect:
+ case AT_Circle:
+ case AT_Arrow:
+ case AT_Mosaic:
+ if (a.type == AT_Mosaic && !a.mosaicRect) {
+ for (POINT& p : a.pts) { p.x += dx; p.y += dy; }
+ } else {
+ a.x1 += dx; a.y1 += dy; a.x2 += dx; a.y2 += dy;
+ }
+ break;
+ case AT_Brush:
+ for (POINT& p : a.pts) { p.x += dx; p.y += dy; }
+ break;
+ case AT_Text:
+ a.x1 += dx; a.y1 += dy;
+ break;
+ }
+ InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC));
+ } else if (ctx->draggingTextAnnotation >= 0) {
+ // 拖动文字标注位置
+ int dx = ctx->mouseX - ctx->textDragStartX;
+ int dy = ctx->mouseY - ctx->textDragStartY;
+ if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
+ PushAnnotationHistory(ctx);
+ ctx->annotationOpHistoryPushed = true;
+ }
+ ctx->annotations[ctx->draggingTextAnnotation].x1 = ctx->dragStartX + dx;
+ ctx->annotations[ctx->draggingTextAnnotation].y1 = ctx->dragStartY + dy;
+ InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[ctx->draggingTextAnnotation], ctx->backDC));
+ } else if (ctx->state == CS_Confirmed) {
+ // hover 手柄/工具栏/文字/非文字标注变化需重绘以更新光标提示与高亮
+ int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+ int tb = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
+ int ht = HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
+ // 非文字标注 hover:优先用选中项的手柄命中(箭头=端点;矩形/圆=8 手柄;画笔=无),否则普通命中
+ int ha = -1;
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
+ bool onHandle = (HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY,
+ ctx->backDC, ctx->handleMetrics.handleSize) != RH_None);
+ if (onHandle) {
+ ha = ctx->selectedAnnotation; // 悬停在手柄上视为悬停选中项(光标由 SETCURSOR 处理)
+ }
+ }
+ if (ha < 0) {
+ ha = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
+ }
+ // 倒角手柄"靠近"判定:感应区比命中框大一圈(handleSize + cornerProximity),
+ // 鼠标靠近某角即显示该角手柄;选区四角互不相邻,取最近的一个。
+ int newNear = FindNearestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
+ ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
+ ctx->selectionCornerRadius, ctx->handleMetrics.cornerProximity);
+ // 仅当 hover 状态真正变化时才重绘(去掉纯 moved 无变化的重绘,减少无意义全屏帧)。
+ // 脏区域 = 各变化项的旧位置 ∪ 新位置(工具栏/标注边框高亮/倒角手柄变化)。
+ if (h != ctx->resizeHandle || tb != ctx->hoverToolbarBtn
+ || ht != ctx->hoveredTextAnnotation || ha != ctx->hoveredAnnotation
+ || newNear != ctx->hoveredCornerHandle) {
+ RECT dirty = {0,0,0,0};
+ // 工具栏 hover 变化:整条工具栏(按钮高亮)
+ if (tb != ctx->hoverToolbarBtn) {
+ dirty = UnionRectSafe(dirty, InflateRectBy(ctx->toolbarRect, 2));
+ }
+ // 文字标注 hover 变化:旧 ∪ 新 标注盒(backDC 坐标)
+ if (ht != ctx->hoveredTextAnnotation) {
+ if (ctx->hoveredTextAnnotation >= 0 && ctx->hoveredTextAnnotation < (int)ctx->annotations.size()) {
+ RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ctx->hoveredTextAnnotation]);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, 3));
+ }
+ if (ht >= 0 && ht < (int)ctx->annotations.size()) {
+ RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ht]);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, 3));
+ }
+ }
+ // 非文字标注 hover 变化:旧 ∪ 新 标注盒
+ // 选中态标注在 hover 进入/离开时会显示/隐藏 resize 手柄,手柄贴在包围盒边缘外,
+ // inflate 量需覆盖手柄半径(handleSize/2 + 余量 = handleMetrics.handleMargin),否则手柄痕迹残留。
+ if (ha != ctx->hoveredAnnotation) {
+ const int handleMargin = ctx->handleMetrics.handleMargin;
+ if (ctx->hoveredAnnotation >= 0 && ctx->hoveredAnnotation < (int)ctx->annotations.size()) {
+ RECT r = MeasureAnnotationBounds(ctx->annotations[ctx->hoveredAnnotation], ctx->backDC);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
+ }
+ if (ha >= 0 && ha < (int)ctx->annotations.size()) {
+ RECT r = MeasureAnnotationBounds(ctx->annotations[ha], ctx->backDC);
+ r.left -= ctx->virtualX; r.top -= ctx->virtualY;
+ r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
+ dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
+ }
+ }
+ // 倒角手柄靠近变化:旧 ∪ 新角手柄位置重绘(出现/消失/切换角)。
+ if (newNear != ctx->hoveredCornerHandle) {
+ if (IsCornerRadiusHandle(ctx->hoveredCornerHandle)) {
+ dirty = UnionRectSafe(dirty, CornerHandleDirtyRect(ctx, ctx->hoveredCornerHandle));
+ }
+ if (IsCornerRadiusHandle(newNear)) {
+ dirty = UnionRectSafe(dirty, CornerHandleDirtyRect(ctx, newNear));
+ }
+ }
+ ctx->resizeHandle = h;
+ ctx->hoverToolbarBtn = tb;
+ ctx->hoveredTextAnnotation = ht;
+ ctx->hoveredAnnotation = ha;
+ ctx->hoveredCornerHandle = newNear;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ // 兜底(理论上不会到这里)
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ }
+ }
+ return 0;
+}
+
+LRESULT OnLButtonUp(HWND hwnd, CaptureContext* ctx) {
+ // 工具栏拖拽结束:位置已在 MOUSEMOVE 固化(toolbarPlaced=true),
+ // 只退出拖拽态,不落入任何状态分支
+ if (ctx->toolbarDragging) {
+ ctx->toolbarDragging = false;
+ return 0;
+ }
+ if (ctx->state == CS_Selecting) {
+ int w = abs(ctx->endX - ctx->startX);
+ int h = abs(ctx->endY - ctx->startY);
+
+ RECT finalRect;
+ if (w <= 1 && h <= 1) {
+ // 点击 -> 使用悬停窗口矩形
+ int idx = FindWindowAtPoint(ctx->windows, ctx->mouseX, ctx->mouseY);
+ if (idx >= 0) {
+ finalRect = ctx->windows[idx].rect;
+ } else {
+ // 匹配不到窗口时,默认选区为鼠标所在的屏幕
+ POINT pt = { ctx->mouseX, ctx->mouseY };
+ HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
+ if (hMonitor) {
+ MONITORINFO monitorInfo;
+ monitorInfo.cbSize = sizeof(MONITORINFO);
+ if (GetMonitorInfo(hMonitor, &monitorInfo)) {
+ finalRect = monitorInfo.rcMonitor;
+ } else {
+ // 获取失败,降级为虚拟屏幕
+ finalRect = { ctx->virtualX, ctx->virtualY,
+ ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
+ }
+ } else {
+ // 获取显示器失败,降级为虚拟屏幕
+ finalRect = { ctx->virtualX, ctx->virtualY,
+ ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
+ }
+ }
+ } else {
+ finalRect.left = (std::min)(ctx->startX, ctx->endX);
+ finalRect.top = (std::min)(ctx->startY, ctx->endY);
+ finalRect.right = (std::max)(ctx->startX, ctx->endX);
+ finalRect.bottom = (std::max)(ctx->startY, ctx->endY);
+ }
+
+ // 进入确认态(可调整/拖动/工具栏),而非直接完成
+ EnterConfirmed(ctx, finalRect);
+ // 自动确认模式:跳过编辑态,直接提取选区并完成截图
+ if (ctx->autoConfirm) {
+ ScreenshotResult* result = ExtractRegionResult(ctx->memDC, finalRect,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius,
+ ctx->mosaicSizeIdx);
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。
+ EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2,
+ result->width, result->height, result->base64);
+ delete result;
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ return 0;
+ }
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else if (ctx->state == CS_Resizing) {
+ if (IsCornerRadiusHandle(ctx->resizeHandle)) {
+ // 圆角调整结束:钳制半径即足够(选区矩形未变),无需最小尺寸逻辑。
+ ClampCornerRadius(ctx);
+ ctx->resizeHandle = RH_None;
+ // 松手后重算靠近角:鼠标仍在该角附近则保持显示,否则回 RH_None 由 MOUSEMOVE 再探测。
+ ctx->hoveredCornerHandle = FindNearestCornerRadiusHandle(ctx->mouseX, ctx->mouseY,
+ ctx->selection, ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
+ ctx->selectionCornerRadius, ctx->handleMetrics.cornerProximity);
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else {
+ // resize 结束:从按下快照和最终鼠标位置重算,并只沿活动端当前所在侧补足最小尺寸。
+ // 不能复用 EnterConfirmed 的固定向右/下扩张,否则穿越后会移动按下时的固定点。
+ // 叠加键盘微调位移(kbDX/kbDY),使其在松开时一并固化到最终选区。
+ int dx = (ctx->mouseX - ctx->dragStartX) + ctx->kbDX;
+ int dy = (ctx->mouseY - ctx->dragStartY) + ctx->kbDY;
+ RECT virtualBounds = {
+ ctx->virtualX, ctx->virtualY,
+ ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH
+ };
+ RECT contentBounds = {0, 0, 0, 0};
+ bool hasContent = CalcAnnotationsBounds(ctx->annotations, contentBounds, ctx->backDC);
+ ctx->selection = ResizeSelectionFromHandle(
+ ctx->dragStartSelection, ctx->resizeHandle, dx, dy, virtualBounds,
+ hasContent, contentBounds, true);
+ ctx->kbDX = 0;
+ ctx->kbDY = 0;
+ ctx->resizeHandle = RH_None;
+ ctx->hoveredCornerHandle = RH_None;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ ClampCornerRadius(ctx); // 选区尺寸变化后,半径可能越界,钳制
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ } else if (ctx->state == CS_Moving) {
+ // 整体拖动结束仍可走通用确认流程;移动不会改变 resize 固定点语义。
+ EnterConfirmed(ctx, ctx->selection);
+ InvalidateRect(hwnd, NULL, FALSE);
+ } else if (ctx->state == CS_Drawing) {
+ // 绘制结束 -> 提交标注(仅在有有效尺寸时)
+ bool valid = false;
+ if (ctx->hasCurDrawing) {
+ if (ctx->curDrawing.type == AT_Brush) {
+ valid = ctx->curDrawing.pts.size() >= 2;
+ } else if (ctx->curDrawing.type == AT_Mosaic) {
+ if (ctx->curDrawing.mosaicRect) {
+ // 框选模式:需要有效矩形尺寸
+ valid = (abs(ctx->curDrawing.x2 - ctx->curDrawing.x1) >= 2
+ || abs(ctx->curDrawing.y2 - ctx->curDrawing.y1) >= 2);
+ } else {
+ // 涂抹模式:至少 1 个点(单击也能产生一个马赛克圆)
+ valid = ctx->curDrawing.pts.size() >= 1;
+ }
+ } else {
+ valid = (abs(ctx->curDrawing.x2 - ctx->curDrawing.x1) >= 2
+ || abs(ctx->curDrawing.y2 - ctx->curDrawing.y1) >= 2);
+ }
+ }
+ if (valid) {
+ PushAnnotationHistory(ctx);
+ ctx->annotations.push_back(ctx->curDrawing);
+ // reveal-mask 模型:base 与标注无关,下一帧 WM_PAINT 自动把新蒙版揭示出来。
+ }
+ ctx->hasCurDrawing = false;
+ ctx->curDrawing = {};
+ ctx->mosaicDrawLastIdx = 0;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ } else if (ctx->state == CS_TextEditing) {
+ // 文字选择结束
+ if (ctx->textDraggingSelection) {
+ ctx->textDraggingSelection = false;
+ // 如果选择范围相同,清除选择
+ if (ctx->textSelStart == ctx->textSelEnd) {
+ ctx->textSelStart = -1;
+ ctx->textSelEnd = -1;
+ }
+ }
+ } else if (ctx->resizingAnnotation >= 0) {
+ // 非文字标注缩放结束:先清缩放标志,再刷新最后位置与上帧位置的并集。
+ // WM_PAINT 将按最终确认态重画选中手柄,避免沿用拖拽态光标/外观。
+ int idx = ctx->resizingAnnotation;
+ RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
+ ctx->resizingAnnotation = -1;
+ ctx->annotationResizeHandle = RH_None;
+ ctx->annotationOpHistoryPushed = false;
+ InvalidateAnnotationOp(hwnd, ctx, finalBox);
+ } else if (ctx->draggingAnnotation >= 0) {
+ // 非文字标注拖拽结束:先退出拖拽态再补刷最终脏区,不退化为全屏重绘。
+ int idx = ctx->draggingAnnotation;
+ RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
+ ctx->draggingAnnotation = -1;
+ ctx->annotationOpHistoryPushed = false;
+ InvalidateAnnotationOp(hwnd, ctx, finalBox);
+ } else if (ctx->draggingTextAnnotation >= 0) {
+ // 文字拖动结束:先退出拖动态,再按最终文字边框区域完成局部刷新。
+ int idx = ctx->draggingTextAnnotation;
+ RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
+ ctx->draggingTextAnnotation = -1;
+ ctx->annotationOpHistoryPushed = false;
+ InvalidateAnnotationOp(hwnd, ctx, finalBox);
+ }
+ return 0;
+}
+
+LRESULT OnKeyDown(HWND hwnd, WPARAM wParam, CaptureContext* ctx) {
+ // 方向键微调选区:调整手柄拖拽中(CS_Resizing)微调活动边,或确认态整体平移。
+ // 文字编辑态(CS_TextEditing)不拦截,走下方原有光标逻辑。
+ if ((ctx->state == CS_Resizing || ctx->state == CS_Confirmed)
+ && (wParam == VK_LEFT || wParam == VK_RIGHT
+ || wParam == VK_UP || wParam == VK_DOWN)
+ && HandleSelectionNudgeKey(hwnd, ctx, wParam)) {
+ return 0;
+ }
+ if (wParam == VK_ESCAPE) {
+ // 文字编辑态:ESC 取消输入,回到确认态
+ if (ctx->state == CS_TextEditing) {
+ ctx->textBuf.clear();
+ ctx->textCaretPos = 0;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ // 其它状态:ESC 取消截图
+ ctx->state = CS_Cancelled;
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏。
+ EmitScreenshotResult(false);
+ DestroyWindow(hwnd);
+ } else if (wParam == VK_RETURN) {
+ // 文字编辑态:Enter 提交文字
+ if (ctx->state == CS_TextEditing) {
+ // 历史上 Enter 路径不清 textSelStart/End(仅清缓冲与光标),保持原清理序列
+ CommitPendingText(ctx);
+ ctx->textBuf.clear();
+ ctx->textCaretPos = 0;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ return 0;
+ }
+ // 确认态:Enter 确认截图
+ if (ctx->state == CS_Confirmed) {
+ ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius,
+ ctx->mosaicSizeIdx);
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。
+ EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2,
+ result->width, result->height, result->base64);
+ delete result;
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ }
+ } else if (wParam == VK_BACK) {
+ // 文字编辑态:退格删除(文字内容变化,整行重排,刷新文字行区域)
+ if (ctx->state == CS_TextEditing && ctx->textCaretPos > 0) {
+ ctx->textBuf.erase(ctx->textCaretPos - 1, 1);
+ ctx->textCaretPos--;
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ } else if (wParam == VK_DELETE) {
+ // 文字编辑态:Delete 删除
+ if (ctx->state == CS_TextEditing && ctx->textCaretPos < (int)ctx->textBuf.size()) {
+ ctx->textBuf.erase(ctx->textCaretPos, 1);
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ // 确认态:Delete 删除当前选中的覆盖物
+ if (ctx->state == CS_Confirmed) {
+ if (ctx->selectedTextAnnotation >= 0
+ && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
+ PushAnnotationHistory(ctx);
+ ctx->annotations.erase(ctx->annotations.begin() + ctx->selectedTextAnnotation);
+ ctx->selectedTextAnnotation = -1;
+ ctx->hoveredTextAnnotation = -1;
+ ctx->draggingTextAnnotation = -1;
+ ctx->activeTool = TB_Drag;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ if (ctx->selectedAnnotation >= 0
+ && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
+ RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
+ PushAnnotationHistory(ctx);
+ ctx->annotations.erase(ctx->annotations.begin() + ctx->selectedAnnotation);
+ ctx->selectedAnnotation = -1;
+ ctx->hoveredAnnotation = -1;
+ ctx->draggingAnnotation = -1;
+ ctx->resizingAnnotation = -1;
+ ctx->annotationResizeHandle = RH_None;
+ ctx->activeTool = TB_Drag;
+ ctx->popupTool = -1;
+ ctx->popupOpen = false;
+ if (IsValidRect(dirty)) {
+ InvalidateRect(hwnd, &dirty, FALSE);
+ } else {
+ InvalidateRect(hwnd, NULL, FALSE);
+ }
+ return 0;
+ }
+ }
+ } else if (wParam == VK_LEFT) {
+ // 文字编辑态:左箭头移动光标(仅光标位置变化)
+ if (ctx->state == CS_TextEditing && ctx->textCaretPos > 0) {
+ ctx->textCaretPos--;
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ } else if (wParam == VK_RIGHT) {
+ // 文字编辑态:右箭头移动光标
+ if (ctx->state == CS_TextEditing && ctx->textCaretPos < (int)ctx->textBuf.size()) {
+ ctx->textCaretPos++;
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ } else if (wParam == VK_HOME) {
+ // 文字编辑态:Home 移动到行首
+ if (ctx->state == CS_TextEditing) {
+ ctx->textCaretPos = 0;
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ } else if (wParam == VK_END) {
+ // 文字编辑态:End 移动到行尾
+ if (ctx->state == CS_TextEditing) {
+ ctx->textCaretPos = (int)ctx->textBuf.size();
+ InvalidateTextLine(hwnd, ctx);
+ return 0;
+ }
+ }
+ return 0;
+}
+
+LRESULT OnImeComposition(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx) {
+ // 处理中文输入法(IME)输入
+ if (ctx->state == CS_TextEditing) {
+ if (lParam & GCS_RESULTSTR) {
+ HIMC hIMC = ImmGetContext(hwnd);
+ if (hIMC) {
+ // 获取输入法完成的字符串长度
+ LONG len = ImmGetCompositionStringW(hIMC, GCS_RESULTSTR, NULL, 0);
+ if (len > 0) {
+ // 分配缓冲区并获取字符串
+ std::wstring result(len / sizeof(wchar_t), L'\0');
+ ImmGetCompositionStringW(hIMC, GCS_RESULTSTR, &result[0], len);
+ result.resize(len / sizeof(wchar_t));
+
+ // 插入到当前光标位置
+ ctx->textBuf.insert(ctx->textCaretPos, result);
+ ctx->textCaretPos += (int)result.size();
+ InvalidateTextLine(hwnd, ctx);
+ }
+ ImmReleaseContext(hwnd, hIMC);
+ }
+ return 0;
+ }
+ }
+ return DefWindowProc(hwnd, msg, wParam, lParam);
+}
+
+LRESULT OnChar(HWND hwnd, WPARAM wParam, CaptureContext* ctx) {
+ // 文字编辑态:接收字符输入(ASCII 和直接输入)
+ if (ctx->state == CS_TextEditing) {
+ wchar_t ch = (wchar_t)wParam;
+ // 过滤控制字符(除了可打印字符)
+ // 忽略 IME 相关的控制字符
+ if (ch >= 32 && ch != 127) {
+ ctx->textBuf.insert(ctx->textCaretPos, 1, ch);
+ ctx->textCaretPos++;
+ InvalidateTextLine(hwnd, ctx);
+ }
+ return 0;
+ }
+ return 0;
+}
+
+LRESULT OnSetCursor(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, CaptureContext* ctx) {
+ // 长截图:统一箭头(遮罩上无交互;洞内光标由底层应用自行控制)
+ if (ctx->state == CS_LongCapturing) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
+ return TRUE;
+ }
+ // 工具栏拖拽中(按住 6 点把手):全程四向箭头
+ if (ctx->toolbarDragging) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ // 马赛克涂抹模式(确认态/绘制态):用预生成的半径圆光标(OS 跟随,无重绘延迟)
+ if (ctx->activeTool == TB_Mosaic && !ctx->mosaicRectMode
+ && (ctx->state == CS_Confirmed || ctx->state == CS_Drawing)
+ && ctx->mosaicBrushCursorsInited
+ && ctx->mosaicRadiusIdx >= 0 && ctx->mosaicRadiusIdx < SC_MOSAIC_RADIUS_COUNT
+ && ctx->mosaicBrushCursors[ctx->mosaicRadiusIdx]) {
+ // 工具栏/子菜单上仍用箭头/手型
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+ if (PointInRect(mxRel, myRel, ctx->toolbarRect)) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
+ return TRUE;
+ }
+ if (ctx->popupOpen && PointInRect(mxRel, myRel, ctx->popupRect)) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_HAND));
+ return TRUE;
+ }
+ if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ SetCursor(ctx->mosaicBrushCursors[ctx->mosaicRadiusIdx]);
+ return TRUE;
+ }
+ }
+ // 文字编辑中:I-beam 光标
+ if (ctx->state == CS_TextEditing) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_IBEAM));
+ return TRUE;
+ }
+ // 绘制中:十字光标
+ if (ctx->state == CS_Drawing) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_CROSS));
+ return TRUE;
+ }
+ // 非文字标注缩放中:对应四角 resize 光标
+ if (ctx->resizingAnnotation >= 0) {
+ SetCursor(LoadCursorW(NULL, HandleCursor(ctx->annotationResizeHandle)));
+ return TRUE;
+ }
+ // 非文字标注拖拽中:四向箭头光标
+ if (ctx->draggingAnnotation >= 0) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ // 拖动文字标注中:四向箭头光标
+ if (ctx->draggingTextAnnotation >= 0) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ // 确认态:根据 hover 位置切换 resize/move/箭头/十字光标
+ if (ctx->state == CS_Confirmed) {
+ // 工具栏或子菜单 -> 箭头(把手上为四向箭头,提示可拖动工具栏)
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+ if (PointInRect(mxRel, myRel, ctx->toolbarRect)) {
+ if (HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics) == SC_TB_GRIP) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ } else {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
+ }
+ return TRUE;
+ }
+ if (ctx->popupOpen && PointInRect(mxRel, myRel, ctx->popupRect)) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_HAND));
+ return TRUE;
+ }
+ // 手柄 -> 对应 resize 光标
+ int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
+ if (h != RH_None) {
+ SetCursor(LoadCursorW(NULL, HandleCursor(h)));
+ return TRUE;
+ }
+ // 圆角手柄 -> 对应对角 resize 光标(按所在角)
+ int cornerH = HitTestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
+ ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset, ctx->selectionCornerRadius);
+ if (cornerH != RH_None) {
+ SetCursor(LoadCursorW(NULL, HandleCursor(cornerH)));
+ return TRUE;
+ }
+ // 已选中的非文字标注手柄 -> 对应 resize 光标(缩放入入口)
+ // 箭头=起点/终点端点手柄(固定四向箭头);矩形/圆=8 手柄(方向自适应);画笔=无
+ // 独立命中测试,不依赖 hovered 缓存(RDP 节流下缓存会滞后)
+ if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
+ && ctx->annotations[ctx->selectedAnnotation].type != AT_Text) {
+ Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
+ int handle = HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY, ctx->backDC, ctx->handleMetrics.handleSize);
+ if (handle != RH_None) {
+ SetCursor(LoadCursorW(NULL, HandleCursor(handle)));
+ return TRUE;
+ }
+ }
+ // 非文字标注悬停 -> 四向箭头(可拖动/选中),与下方文字悬停判定并列
+ {
+ int hit = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
+ if (hit >= 0 && ctx->annotations[hit].type != AT_Text) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ }
+ // 文字标注悬停 -> 四向箭头(可拖动/选中)
+ // 注意:此处必须独立做命中测试,不能依赖 ctx->hoveredTextAnnotation。
+ // 在远程桌面(RDP)下鼠标移动事件常被节流/合并,WM_MOUSEMOVE 更新
+ // hoveredTextAnnotation 存在滞后,导致 WM_SETCURSOR 看到过期值。
+ // 文字工具未激活时,悬停已确认文字 -> 拖动光标;
+ // 文字工具激活时,悬停已确认文字 -> 仍为拖动光标(可选中改属性)。
+ if (HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC) >= 0) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ // 选区内部:
+ // 矢量/文字/马赛克工具激活 -> 十字;
+ // 已有标注内容 -> 箭头(禁止整体拖动);
+ // 否则 -> 移动光标
+ if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ if (IsVectorTool(ctx->activeTool) || ctx->activeTool == TB_Text
+ || ctx->activeTool == TB_Mosaic) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_CROSS));
+ } else if (!ctx->annotations.empty()) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
+ } else {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ }
+ return TRUE;
+ }
+ // 选区外 -> 箭头
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
+ return TRUE;
+ }
+ if (ctx->state == CS_Resizing) {
+ SetCursor(LoadCursorW(NULL, HandleCursor(ctx->resizeHandle)));
+ return TRUE;
+ }
+ if (ctx->state == CS_Moving) {
+ SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
+ return TRUE;
+ }
+ return DefWindowProc(hwnd, msg, wParam, lParam);
+}
diff --git a/src/screenshot/overlay_paint_windows.cpp b/src/screenshot/overlay_paint_windows.cpp
new file mode 100644
index 0000000..a76916f
--- /dev/null
+++ b/src/screenshot/overlay_paint_windows.cpp
@@ -0,0 +1,591 @@
+// 截图模块:覆盖层 WM_PAINT 脏区绘制管线(从原 screenshot_windows.cpp 的 WndProc 提取)
+#include "internal.h"
+
+LRESULT OnPaint(HWND hwnd, CaptureContext* ctx) {
+ PAINTSTRUCT ps;
+ HDC hdc = BeginPaint(hwnd, &ps);
+ HDC backDC = ctx->backDC;
+
+ // 计算浮窗位置
+ int panelX, panelY;
+ CalcPanelPosition(ctx->mouseX, ctx->mouseY,
+ ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->panelMetrics, panelX, panelY);
+ // 转为相对坐标
+ int panelXRel = panelX - ctx->virtualX;
+ int panelYRel = panelY - ctx->virtualY;
+
+ RECT curPanelRect = { panelXRel, panelYRel,
+ panelXRel + ctx->panelMetrics.w, panelYRel + ctx->panelMetrics.h };
+
+ // 当前选区矩形
+ RECT curSelRect = {0,0,0,0};
+ if (ctx->state == CS_Selecting) {
+ curSelRect.left = (std::min)(ctx->startX, ctx->endX) - ctx->virtualX;
+ curSelRect.top = (std::min)(ctx->startY, ctx->endY) - ctx->virtualY;
+ curSelRect.right = (std::max)(ctx->startX, ctx->endX) - ctx->virtualX;
+ curSelRect.bottom = (std::max)(ctx->startY, ctx->endY) - ctx->virtualY;
+ } else if (ctx->state == CS_Confirmed || ctx->state == CS_Resizing
+ || ctx->state == CS_Moving || ctx->state == CS_Drawing
+ || ctx->state == CS_TextEditing || ctx->state == CS_LongCapturing) {
+ curSelRect.left = ctx->selection.left - ctx->virtualX;
+ curSelRect.top = ctx->selection.top - ctx->virtualY;
+ curSelRect.right = ctx->selection.right - ctx->virtualX;
+ curSelRect.bottom = ctx->selection.bottom - ctx->virtualY;
+ }
+
+ // 当前高亮窗口矩形
+ RECT curHlRect = {0,0,0,0};
+ if (ctx->state == CS_Idle && ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
+ const RECT& wr = ctx->windows[ctx->hoveredWindow].rect;
+ curHlRect = { wr.left - ctx->virtualX, wr.top - ctx->virtualY,
+ wr.right - ctx->virtualX, wr.bottom - ctx->virtualY };
+ }
+
+ double ds = ctx->dpiScale;
+ int physW = (int)(ctx->virtualW * ds + 0.5);
+ int physH = (int)(ctx->virtualH * ds + 0.5);
+
+ // P1 脏区域优化:仅"选区正在移动/缩放"的状态(Selecting/Resizing/Moving)
+ // 因遮罩边界大范围变化,必须整屏恢复背景。其余 confirmedMode 状态(Confirmed/Drawing/
+ // TextEditing)选区静止,可用 ps.rcPaint 局部刷新(由各调用点 InvalidateRect 传精确矩形)。
+ // CS_LongCapturing:黑底底栏标签随拼接变化,且部分重绘会露出冻结快照底,故同样整屏重绘。
+ if (ctx->state == CS_Selecting || ctx->state == CS_Resizing || ctx->state == CS_Moving
+ || ctx->state == CS_LongCapturing) {
+ ctx->needFullRedraw = true;
+ }
+
+ // 本帧脏区域:fullFrame 时为全屏,否则取 ps.rcPaint(由 InvalidateRect 矩形决定)。
+ // 客户区坐标 = backDC 坐标(窗口原点 = 虚拟屏幕左上角),可直接用于背景恢复与裁剪。
+ bool fullFrame = ctx->needFullRedraw;
+ RECT dirtyRect;
+ HRGN dirtyRgn = NULL;
+ if (fullFrame) {
+ dirtyRect = { 0, 0, ctx->virtualW, ctx->virtualH };
+ } else {
+ // ps.rcPaint 在 InvalidateRect(NULL) 时为整个客户区(等价全屏),传矩形时为该矩形。
+ dirtyRect = ps.rcPaint;
+ // 限定到虚拟屏幕范围内(防御性:ps.rcPaint 理论上不会超出客户区=虚拟屏幕)
+ if (dirtyRect.left < 0) dirtyRect.left = 0;
+ if (dirtyRect.top < 0) dirtyRect.top = 0;
+ if (dirtyRect.right > ctx->virtualW) dirtyRect.right = ctx->virtualW;
+ if (dirtyRect.bottom > ctx->virtualH) dirtyRect.bottom = ctx->virtualH;
+ }
+
+ // 恢复背景:fullFrame 全屏恢复;局部帧只恢复 dirtyRect(其余区域保留上帧最终画面)。
+ if (fullFrame) {
+ if (ds > 1.01 || ds < 0.99) {
+ StretchBlt(backDC, 0, 0, ctx->virtualW, ctx->virtualH,
+ ctx->memDC, 0, 0, physW, physH, SRCCOPY);
+ } else {
+ BitBlt(backDC, 0, 0, ctx->virtualW, ctx->virtualH,
+ ctx->memDC, 0, 0, SRCCOPY);
+ }
+ ctx->needFullRedraw = false;
+ } else {
+ // 局部恢复脏区域为原始背景(后续渲染管线会对该区域重画遮罩/标注等,
+ // 因 AlphaBlend 作用于已恢复的清晰背景,结果与全屏渲染一致)。
+ RestoreDirtyRegion(backDC, ctx->memDC, dirtyRect, ds);
+ // 设置裁剪区:后续所有 GDI/GDI+ 绘制自动限制在 dirtyRect 内,
+ // 避免重绘未变化区域(GDI+ Graphics(backDC) 会继承此裁剪区)。
+ dirtyRgn = CreateRectRgn(dirtyRect.left, dirtyRect.top,
+ dirtyRect.right, dirtyRect.bottom);
+ SelectClipRgn(backDC, dirtyRgn);
+ }
+
+ // 绘制窗口高亮(Idle 状态)
+ if (ctx->state == CS_Idle) {
+ if (ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
+ // 高亮悬停的窗口
+ DrawWindowHighlight(backDC, ctx->windows[ctx->hoveredWindow].rect,
+ ctx->virtualX, ctx->virtualY, ctx->gdi);
+ } else {
+ // 没有匹配到窗口时,高亮鼠标所在的屏幕
+ POINT pt = { ctx->mouseX, ctx->mouseY };
+ HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
+ if (hMonitor) {
+ MONITORINFO monitorInfo;
+ monitorInfo.cbSize = sizeof(MONITORINFO);
+ if (GetMonitorInfo(hMonitor, &monitorInfo)) {
+ DrawWindowHighlight(backDC, monitorInfo.rcMonitor,
+ ctx->virtualX, ctx->virtualY, ctx->gdi);
+ }
+ }
+ }
+ }
+
+ // ---- 确认/调整/移动/绘制态:遮罩 + 边框 + 手柄 + 标注 + 工具栏 ----
+ RECT curLabelRect = {0,0,0,0};
+ RECT curToolbarRect = {0,0,0,0};
+ RECT curPopupRect = {0,0,0,0};
+ bool confirmedMode = (ctx->state == CS_Confirmed || ctx->state == CS_Resizing
+ || ctx->state == CS_Moving || ctx->state == CS_Drawing
+ || ctx->state == CS_TextEditing);
+ // CS_LongCapturing 期间覆盖层已被隐藏(独立灰蒙版 + 小地图面板 + 工具栏接管),
+ // 这里不绘制任何编辑态内容。
+ if (confirmedMode) {
+ // 选区外遮罩(选区内部保持清晰)
+ DrawDimMask(backDC, ctx->gdi,
+ curSelRect.left, curSelRect.top, curSelRect.right, curSelRect.bottom,
+ ctx->virtualW, ctx->virtualH, ctx->selectionCornerRadius);
+ // 已提交标注 + 正在绘制的标注(绘制范围 clip 在选区内)
+ // 调整选区时也保持显示,便于看清内容是否会被裁掉。
+ if (ctx->state == CS_Confirmed || ctx->state == CS_Drawing || ctx->state == CS_Resizing) {
+ const Annotation* cur = ctx->hasCurDrawing ? &ctx->curDrawing : nullptr;
+ // 马赛克(reveal-mask 模型):只有当前帧确实包含马赛克内容时才延迟生成 base,
+ // 普通截图确认态不再承担整张虚拟屏幕的马赛克预处理。
+ if (HasMosaicToRender(ctx->annotations, cur)) {
+ if (MosaicBaseNeedsRebuild(ctx)) RebuildMosaicBase(ctx);
+ if (ctx->mosaicBaseDC) {
+ // 全屏缓存与 backDC 均以虚拟屏幕左上角为原点;把标注绝对坐标转换到
+ // backDC 局部坐标,并用选区矩形硬裁剪马赛克笔刷的可见范围。
+ RevealMosaicToTarget(backDC, ctx->mosaicBaseDC,
+ ctx->annotations, cur, curSelRect,
+ (float)-ctx->virtualX, (float)-ctx->virtualY);
+ }
+ }
+ DrawAnnotations(backDC, curSelRect, ctx->virtualX, ctx->virtualY, ctx->annotations, cur);
+ // 缓存正在绘制标注的包围盒(绝对虚拟屏幕坐标),供 CS_Drawing 局部刷新计算旧位置
+ if (ctx->state == CS_Drawing && ctx->hasCurDrawing) {
+ ctx->lastDrawingBox = MeasureAnnotationBounds(ctx->curDrawing, backDC);
+ ctx->hasLastDrawingBox = true;
+ } else if (ctx->state != CS_Drawing) {
+ ctx->hasLastDrawingBox = false;
+ }
+ // 正在拖拽的矩形马赛克:叠加虚线边框提示当前框选范围(backDC 绝对坐标)。
+ // 涂抹模式不画矩形边框(其范围由预览圆体现)。
+ if (ctx->state == CS_Drawing && ctx->hasCurDrawing
+ && ctx->curDrawing.type == AT_Mosaic && ctx->curDrawing.mosaicRect) {
+ int rx1 = (std::min)(ctx->curDrawing.x1, ctx->curDrawing.x2);
+ int ry1 = (std::min)(ctx->curDrawing.y1, ctx->curDrawing.y2);
+ int rx2 = (std::max)(ctx->curDrawing.x1, ctx->curDrawing.x2);
+ int ry2 = (std::max)(ctx->curDrawing.y1, ctx->curDrawing.y2);
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
+ {
+ Gdiplus::Graphics graphics(backDC);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ // 虚线笔:白色底 + 深色虚线,保证在任意背景上可见
+ Gdiplus::Pen whitePen(Gdiplus::Color(255, 255, 255, 255), 3.0f);
+ graphics.DrawRectangle(&whitePen, (float)rx1, (float)ry1,
+ (float)(rx2 - rx1), (float)(ry2 - ry1));
+ Gdiplus::Pen dashPen(Gdiplus::Color(255, 0x1E, 0x88, 0xE5), 1.5f);
+ dashPen.SetDashStyle(Gdiplus::DashStyleDash);
+ graphics.DrawRectangle(&dashPen, (float)rx1, (float)ry1,
+ (float)(rx2 - rx1), (float)(ry2 - ry1));
+ }
+ }
+ }
+ // 文字编辑态:绘制输入光标和选中文字标注的边框
+ if (ctx->state == CS_TextEditing) {
+ if (HasMosaicToRender(ctx->annotations, nullptr)) {
+ if (MosaicBaseNeedsRebuild(ctx)) RebuildMosaicBase(ctx);
+ if (ctx->mosaicBaseDC) {
+ RevealMosaicToTarget(backDC, ctx->mosaicBaseDC,
+ ctx->annotations, nullptr, curSelRect,
+ (float)-ctx->virtualX, (float)-ctx->virtualY);
+ }
+ }
+ // 绘制已提交的标注
+ DrawAnnotations(backDC, curSelRect, ctx->virtualX, ctx->virtualY, ctx->annotations, nullptr);
+
+ // 绘制当前输入的文字和光标(统一用 GDI+,与提交态 DrawString 完全一致,
+ // 避免旧 GDI TextOutW 导致的文字偏靠下、右侧间距偏大的问题)
+ // 注意:测量与绘制共用同一个 GDI+ Graphics 作用域,避免在 backDC 上反复
+ // Startup/Shutdown 及创建多个 Graphics 对象引发的状态混乱/崩溃。
+ int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
+ COLORREF textColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
+
+ // 文字锚点转换为相对坐标
+ int textX = ctx->textAnchorX - ctx->virtualX;
+ int textY = ctx->textAnchorY - ctx->virtualY;
+
+ // 单次 GDI+ 作用域:完成测量(整体包围盒 + 逐字符宽度)与文字绘制。
+ // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
+ // 关键:测量与绘制必须在同一 Graphics 作用域内,故仍用内层 {} 包住。
+ float offX = 0, offY = 0, textW = 0, textH = 0;
+ std::vector charWidths;
+ {
+ Gdiplus::Graphics graphics(backDC);
+ graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
+ // 复用会话缓存的 FontFamily/StringFormat/Font(指针非空,InitGdipResources 保证)
+ Gdiplus::Font* font = GetGdipFont(ctx, fontPx);
+ Gdiplus::StringFormat* sf = ctx->gdipStrFmt;
+
+ // 整体紧凑包围盒
+ Gdiplus::RectF origin(0, 0, 0, 0);
+ Gdiplus::RectF bounds;
+ if (!ctx->textBuf.empty()) {
+ graphics.MeasureString(ctx->textBuf.c_str(), (INT)ctx->textBuf.size(),
+ font, origin, sf, &bounds);
+ offX = bounds.X;
+ offY = bounds.Y;
+ textW = bounds.Width;
+ textH = bounds.Height;
+ } else {
+ textH = (float)fontPx;
+ }
+ // 逐字符累计宽度(与 DrawString 渲染进度一致)
+ charWidths.assign(ctx->textBuf.size() + 1, 0.0f);
+ if (!ctx->textBuf.empty()) {
+ std::wstring sub;
+ sub.reserve(ctx->textBuf.size());
+ for (size_t i = 1; i <= ctx->textBuf.size(); i++) {
+ sub.assign(ctx->textBuf, 0, i);
+ graphics.MeasureString(sub.c_str(), (INT)sub.size(),
+ font, origin, sf, &bounds);
+ charWidths[i] = bounds.X + bounds.Width;
+ }
+ }
+
+ // 绘制文字(与提交态一致:顶部左对齐到锚点)
+ if (!ctx->textBuf.empty()) {
+ Gdiplus::SolidBrush textBrush(Gdiplus::Color(GetRValue(textColor),
+ GetGValue(textColor),
+ GetBValue(textColor)));
+ Gdiplus::RectF layoutRect((float)textX, (float)textY, 10000.0f,
+ (float)(fontPx * 2));
+ graphics.DrawString(ctx->textBuf.c_str(), -1, font, layoutRect,
+ sf, &textBrush);
+ }
+ }
+
+ // 字形可见区域的左上角(含 GDI+ 内部偏移)
+ float glyphLeft = (float)textX + offX;
+ float glyphTop = (float)textY + offY;
+ // 文字可见宽度(最小给一个占位宽度,空输入框也有合理大小)
+ float glyphW = (textW > 20.0f || !ctx->textBuf.empty()) ? textW : 20.0f;
+ float glyphH = (textH > 0 ? textH : (float)fontPx);
+
+ // 绘制边框:左右对称 padding,上下基于字形紧凑高度。
+ // 用字形可见区域 + padding,保证左侧与右侧间距一致,并完整包住文字。
+ const float padding = 4.0f;
+ int boxLeft = (int)floorf(glyphLeft - padding);
+ int boxTop = (int)floorf(glyphTop - padding);
+ int boxRight = (int)ceilf(glyphLeft + glyphW + padding);
+ int boxBottom = (int)ceilf(glyphTop + glyphH + padding);
+ HPEN boxPen = CreatePen(PS_SOLID, 1, textColor);
+ HGDIOBJ oldPen2 = SelectObject(backDC, boxPen);
+ HGDIOBJ oldBrush2 = SelectObject(backDC, GetStockObject(NULL_BRUSH));
+ Rectangle(backDC, boxLeft, boxTop, boxRight, boxBottom);
+ SelectObject(backDC, oldBrush2);
+ SelectObject(backDC, oldPen2);
+ DeleteObject(boxPen);
+
+ // 绘制文字选择高亮(基于 GDI+ 字符宽度)
+ if (ctx->textSelStart >= 0 && ctx->textSelEnd >= 0 && ctx->textSelStart != ctx->textSelEnd
+ && ctx->textSelStart < (int)charWidths.size()
+ && ctx->textSelEnd < (int)charWidths.size()) {
+ int selStart = (std::min)(ctx->textSelStart, ctx->textSelEnd);
+ int selEnd = (std::max)(ctx->textSelStart, ctx->textSelEnd);
+
+ float selLeftF = (float)textX + charWidths[selStart];
+ float selRightF = (float)textX + charWidths[selEnd];
+ int selLeft = (int)floorf(selLeftF);
+ int selRight = (int)ceilf(selRightF);
+ int selW = selRight - selLeft;
+ int selY = (int)floorf(glyphTop);
+ int selH = (int)ceilf(glyphH);
+ if (selW > 0 && selH > 0) {
+ BLENDFUNCTION blend = { AC_SRC_OVER, 0, 100, 0 };
+ HDC tempDC = CreateCompatibleDC(backDC);
+ HBITMAP tempBmp = CreateCompatibleBitmap(backDC, selW, selH);
+ SelectObject(tempDC, tempBmp);
+ RECT tempRect = {0, 0, selW, selH};
+ FillRect(tempDC, &tempRect, ctx->gdi.textSelBrush);
+ AlphaBlend(backDC, selLeft, selY, selW, selH, tempDC, 0, 0, selW, selH, blend);
+ DeleteDC(tempDC);
+ DeleteObject(tempBmp);
+ }
+ }
+
+ // 绘制光标(闪烁效果,基于 GDI+ 字符宽度,高度对齐字形)
+ // 始终缓存光标几何(即使本帧不可见),供光标闪烁/方向键的 InvalidateRect 局部刷新。
+ if (ctx->textCaretPos >= 0 && ctx->textCaretPos < (int)charWidths.size()) {
+ float caretXF = (float)textX + charWidths[ctx->textCaretPos];
+ int caretX = (int)floorf(caretXF);
+ int caretY2 = (int)floorf(glyphTop);
+ int caretH = (int)ceilf(glyphH);
+ // 缓存光标矩形(backDC 坐标,含 2px 宽度),供局部刷新
+ ctx->lastCaretRect = { caretX - 1, caretY2, caretX + 3, caretY2 + caretH };
+ ctx->hasLastCaret = true;
+ if (ctx->textCaretVisible) {
+ HPEN caretPen = CreatePen(PS_SOLID, 2, textColor);
+ HGDIOBJ oldPenC = SelectObject(backDC, caretPen);
+ MoveToEx(backDC, caretX, caretY2, NULL);
+ LineTo(backDC, caretX, caretY2 + caretH);
+ SelectObject(backDC, oldPenC);
+ DeleteObject(caretPen);
+ }
+ }
+ }
+ // 确认态和文字编辑态:文字标注的选中边框
+ // - selectedTextAnnotation:已选中的标注(点击后持久保持),实线高亮边框
+ // 注:文字悬浮辅助边框已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
+ if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
+ // 已选中:实线蓝色边框(醒目)
+ if (ctx->selectedTextAnnotation >= 0
+ && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
+ RECT annRect = MeasureTextAnnotation(backDC, ctx->annotations[ctx->selectedTextAnnotation]);
+ // 缓存选中文字标注包围盒(绝对坐标),供拖动时局部刷新算旧位置
+ ctx->lastAnnotationBox = annRect;
+ ctx->hasLastAnnotationBox = true;
+ annRect.left -= ctx->virtualX;
+ annRect.top -= ctx->virtualY;
+ annRect.right -= ctx->virtualX;
+ annRect.bottom -= ctx->virtualY;
+ HGDIOBJ oldPenS = SelectObject(backDC, ctx->gdi.annTextSelPen);
+ HGDIOBJ oldBrushS = SelectObject(backDC, GetStockObject(NULL_BRUSH));
+ Rectangle(backDC, annRect.left, annRect.top, annRect.right, annRect.bottom);
+ SelectObject(backDC, oldBrushS);
+ SelectObject(backDC, oldPenS);
+ }
+ // 注:文字悬浮辅助边框已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
+ }
+ // 非文字标注的选中/悬浮边框(确认态/文字编辑态)
+ // - selectedAnnotation:已选中,按工具类型差异化渲染(见下方分支)
+ // 注:鼠标悬浮辅助包围盒已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
+ if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
+ // 选中态(按工具类型差异化):
+ // 矩形:无包围盒 + 8 手柄(4 角 + 4 边中点)
+ // 圆形:蓝色虚线包围盒 + 8 手柄(4 角 + 4 边中点)
+ // 箭头:无包围盒 + 2 端点手柄
+ // 画笔:蓝色虚线包围盒 + 无手柄(仅可整体拖动)
+ // 手柄统一为白色圆形 + 红色描边(GDI+ 抗锯齿绘制,见下方)。
+ if (ctx->selectedAnnotation >= 0
+ && ctx->selectedAnnotation < (int)ctx->annotations.size()
+ && ctx->annotations[ctx->selectedAnnotation].type != AT_Text) {
+ RECT box = MeasureAnnotationBounds(ctx->annotations[ctx->selectedAnnotation], backDC);
+ // 缓存选中标注包围盒(绝对坐标),供拖拽/缩放时局部刷新算旧位置
+ ctx->lastAnnotationBox = box;
+ ctx->hasLastAnnotationBox = true;
+ box.left -= ctx->virtualX; box.top -= ctx->virtualY;
+ box.right -= ctx->virtualX; box.bottom -= ctx->virtualY;
+ const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
+
+ // 包围盒:仅圆形/画笔画蓝色虚线框;矩形/箭头不画。
+ if (selA.type == AT_Circle || selA.type == AT_Brush) {
+ HGDIOBJ oldPenA = SelectObject(backDC, ctx->gdi.annHoverPen);
+ HGDIOBJ oldBrushA = SelectObject(backDC, GetStockObject(NULL_BRUSH));
+ Rectangle(backDC, box.left, box.top, box.right, box.bottom);
+ SelectObject(backDC, oldBrushA);
+ SelectObject(backDC, oldPenA);
+ }
+
+ // 收集手柄坐标(backDC 局部坐标)。画笔无手柄;箭头取 2 端点;
+ // 矩形/圆形取 8 个(4 角 + 4 边中点)。
+ int handles[8][2];
+ int handleCount = 0;
+ if (selA.type == AT_Arrow) {
+ handles[0][0] = selA.x1 - ctx->virtualX;
+ handles[0][1] = selA.y1 - ctx->virtualY;
+ handles[1][0] = selA.x2 - ctx->virtualX;
+ handles[1][1] = selA.y2 - ctx->virtualY;
+ handleCount = 2;
+ } else if (selA.type == AT_Rect || selA.type == AT_Circle) {
+ int cx = (box.left + box.right) / 2;
+ int cy = (box.top + box.bottom) / 2;
+ // 4 角
+ handles[0][0] = box.left; handles[0][1] = box.top;
+ handles[1][0] = box.right; handles[1][1] = box.top;
+ handles[2][0] = box.left; handles[2][1] = box.bottom;
+ handles[3][0] = box.right; handles[3][1] = box.bottom;
+ // 4 边中点
+ handles[4][0] = cx; handles[4][1] = box.top;
+ handles[5][0] = cx; handles[5][1] = box.bottom;
+ handles[6][0] = box.left; handles[6][1] = cy;
+ handles[7][0] = box.right; handles[7][1] = cy;
+ handleCount = 8;
+ }
+ // 白色圆形手柄(红色描边),半径 = handleSize/2(DPI 缩放后),与方块视觉大小一致。
+ // GDI+ 抗锯齿绘制(GDI Ellipse 边缘有硬锯齿):白色填充 + 红色 1px 描边。
+ if (handleCount > 0) {
+ int half = ctx->handleMetrics.handleSize / 2;
+ Gdiplus::Graphics graphics(backDC);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::SolidBrush cBrush(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::Pen cPen(Gdiplus::Color(255, 229, 57, 53), 1.0f);
+ Gdiplus::GraphicsPath handlePath;
+ for (int i = 0; i < handleCount; i++) {
+ // 直径 2*half 与原 GDI Ellipse(x-half,y-half,x+half,y+half)
+ // 的像素覆盖一致(GDI 右下边 exclusive,直径 = 2*half)。
+ handlePath.AddEllipse(handles[i][0] - half, handles[i][1] - half,
+ 2 * half, 2 * half);
+ }
+ graphics.FillPath(&cBrush, &handlePath);
+ graphics.DrawPath(&cPen, &handlePath);
+ }
+ }
+ }
+ // 确认态边框和调整手柄最后绘制,避免马赛克及其他标注覆盖交互轮廓。
+ DrawConfirmedBorder(backDC, curSelRect, ctx->gdi, ctx->selectionCornerRadius);
+ // 正在拖拽倒角手柄(CS_Resizing + 角手柄):此时隐藏选区 resize 手柄,仅留被拖的倒角手柄。
+ bool draggingCorner = (ctx->state == CS_Resizing && IsCornerRadiusHandle(ctx->resizeHandle));
+ // 选区 resize 手柄:确认态/绘制标注/调整中/整体拖动中显示,并随 curSelRect 实时跟随。
+ // 倒角手柄拖拽时隐藏,避免两类手柄挤在同一角;文字编辑时不绘制,避免遮挡光标。
+ if (!draggingCorner && (ctx->state == CS_Confirmed || ctx->state == CS_Drawing
+ || ctx->state == CS_Resizing || ctx->state == CS_Moving)) {
+ DrawResizeHandles(backDC, curSelRect, ctx->handleMetrics.handleSize);
+ }
+ // 倒角拖拽手柄:默认不显示,仅"鼠标靠近某角"或"正拖拽某角"时显示该角一个。
+ // 拖拽 -> 取正在拖的角(resizeHandle);确认态靠近 -> 取 hoveredCornerHandle;
+ // 沿对角线内移 d = clamp(inset+radius, 0, maxR):radius 增大时沿对角线向中心滑动;
+ // 松手后 radius 保持 -> 手柄停在原地,不回弹到静止位 inset。
+ int visibleCorner = RH_None;
+ if (draggingCorner) {
+ visibleCorner = ctx->resizeHandle;
+ } else if (ctx->state == CS_Confirmed) {
+ visibleCorner = ctx->hoveredCornerHandle;
+ }
+ if (visibleCorner != RH_None) {
+ DrawCornerRadiusHandle(backDC, curSelRect,
+ ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
+ ctx->selectionCornerRadius, visibleCorner);
+ }
+ // 调整选区(标准手柄)时显示放大镜:焦点取活动手柄锚点(随活动边移动),
+ // 面板置于选区外侧避免遮挡目标。读 memDC 干净像素,所见即真实屏幕。
+ if (ctx->state == CS_Resizing && !IsCornerRadiusHandle(ctx->resizeHandle)) {
+ int ax, ay;
+ GetResizeHandleAnchor(ctx->resizeHandle, ctx->selection, ax, ay);
+ int pX, pY;
+ CalcResizePanelPosition(ctx->resizeHandle, ctx->selection,
+ ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH,
+ ctx->panelMetrics, pX, pY);
+ int pXRel = pX - ctx->virtualX, pYRel = pY - ctx->virtualY;
+ curPanelRect = { pXRel, pYRel,
+ pXRel + ctx->panelMetrics.w, pYRel + ctx->panelMetrics.h };
+ DrawInfoPanel(backDC, pXRel, pYRel, ctx->currentColor,
+ ctx->memDC, ctx->virtualX, ctx->virtualY, ax, ay, ctx->dpiScale,
+ ctx->gdi, ctx->panelMetrics, ctx->virtualW, ctx->virtualH);
+ }
+ // 悬浮工具栏 + 粗细/颜色子菜单
+ // 整体拖动选区(CS_Moving)时保持显示并实时跟随;调整选区(CS_Resizing)时仍隐藏,避免手柄附近抖动。
+ // 绘制标注(CS_Drawing)/文字编辑(CS_TextEditing)时保持显示,便于随时查看/切换工具与样式。
+ if (ctx->state == CS_Confirmed || ctx->state == CS_Moving || ctx->state == CS_Drawing || ctx->state == CS_TextEditing) {
+ if (ctx->toolbarPlaced) {
+ // 用户拖放后的手动位置:直接沿用(拖拽中由 MOUSEMOVE 实时更新),
+ // 不再随选区自动重算
+ curToolbarRect = ctx->toolbarRect;
+ } else {
+ CalcToolbarPosition(curSelRect, ctx->virtualX, ctx->virtualY,
+ ctx->virtualW, ctx->virtualH,
+ ctx->toolbarMetrics, curToolbarRect);
+ ctx->toolbarRect = curToolbarRect;
+ }
+ // hover 命中(相对坐标),确认态和文字编辑态均可更新 hover
+ if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
+ int mxRel = ctx->mouseX - ctx->virtualX;
+ int myRel = ctx->mouseY - ctx->virtualY;
+ ctx->hoverToolbarBtn = HitTestToolbar(mxRel, myRel, curToolbarRect, ctx->toolbarMetrics);
+ } else {
+ ctx->hoverToolbarBtn = -1;
+ }
+ DrawToolbar(backDC, curToolbarRect, ctx->hoverToolbarBtn, ctx->activeTool,
+ ctx->gdi, ctx->toolbarMetrics, ctx->iconCache);
+ int popupTool = ctx->popupTool;
+ // 马赛克子菜单:模式切换 + 块大小
+ if (ctx->popupOpen && popupTool == TB_Mosaic) {
+ int mpw, mph;
+ CalcMosaicPopupSize(ctx->popupMetrics, mpw, mph);
+ CalcPopupPlacement(curToolbarRect, ctx->virtualX, ctx->virtualY,
+ ctx->virtualW, ctx->virtualH,
+ ctx->popupMetrics, mpw, mph, curPopupRect);
+ ctx->popupRect = curPopupRect;
+ int modeIdx = ctx->mosaicRectMode ? 1 : 0;
+ DrawMosaicPopup(backDC, curPopupRect, modeIdx, ctx->mosaicSizeIdx,
+ ctx->mosaicRadiusIdx, ctx->popupMetrics);
+ }
+ // 粗细/颜色子菜单:文字工具激活时始终显示(含文字编辑态)
+ else if (ctx->popupOpen && CanShowStylePopupTool(popupTool)) {
+ CalcPopupPosition(curToolbarRect, ctx->virtualX, ctx->virtualY,
+ ctx->virtualW, ctx->virtualH,
+ ctx->popupMetrics, curPopupRect);
+ ctx->popupRect = curPopupRect;
+ bool isText = (popupTool == TB_Text);
+ int firstIdx = isText ? ctx->fontSizeIdx : ctx->drawThickIdx;
+ DrawPopup(backDC, curPopupRect, ctx->drawColorIdx, firstIdx, isText,
+ ctx->popupMetrics);
+ }
+ } else {
+ ctx->hoverToolbarBtn = -1;
+ }
+ // 工具栏 title 式 tooltip 气泡:最后绘制,盖在工具栏/子菜单之上
+ DrawToolbarTooltip(backDC, ctx);
+ // 确认态不绘制尺寸标签;放大镜仅在调整手柄(CS_Resizing)时绘制(见上方)
+ } else {
+ // ---- Idle/Selecting 态:原有逻辑 ----
+ // 绘制选区外遮罩(微信风格,仅 Selecting 状态),选区内部保持清晰
+ if (ctx->state == CS_Selecting) {
+ DrawDimMask(backDC, ctx->gdi,
+ curSelRect.left, curSelRect.top, curSelRect.right, curSelRect.bottom,
+ ctx->virtualW, ctx->virtualH, 0);
+ }
+
+ // 绘制选区或窗口尺寸标签
+ if (ctx->state == CS_Selecting) {
+ curLabelRect = DrawSelection(backDC, ctx->startX, ctx->startY, ctx->endX, ctx->endY,
+ ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->gdi, ctx->panelMetrics);
+ } else if (ctx->state == CS_Idle) {
+ RECT screenRect = {};
+ int ww = 0, wh = 0;
+ bool haveScreenSize = false;
+
+ if (ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
+ // 显示悬停窗口的尺寸
+ const RECT& wr = ctx->windows[ctx->hoveredWindow].rect;
+ ww = wr.right - wr.left;
+ wh = wr.bottom - wr.top;
+ screenRect = wr;
+ haveScreenSize = true;
+ } else {
+ // 没有匹配到窗口时,显示当前屏幕的尺寸
+ POINT pt = { ctx->mouseX, ctx->mouseY };
+ HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
+ if (hMonitor) {
+ MONITORINFO monitorInfo;
+ monitorInfo.cbSize = sizeof(MONITORINFO);
+ if (GetMonitorInfo(hMonitor, &monitorInfo)) {
+ screenRect = monitorInfo.rcMonitor;
+ ww = screenRect.right - screenRect.left;
+ wh = screenRect.bottom - screenRect.top;
+ haveScreenSize = true;
+ }
+ }
+ }
+
+ // 显示器查询失败(热插拔/驱动重置等瞬态)时不绘制尺寸标签,
+ // 避免 screenRect/ww/wh 未就绪即传入 DrawSizeLabel。
+ if (haveScreenSize) {
+ curLabelRect = DrawSizeLabel(backDC, ww, wh,
+ screenRect.left - ctx->virtualX, screenRect.top - ctx->virtualY,
+ screenRect.right - ctx->virtualX, screenRect.bottom - ctx->virtualY,
+ ctx->virtualW, ctx->virtualH, ctx->gdi, ctx->panelMetrics);
+ }
+ }
+
+ // 绘制放大镜信息面板
+ DrawInfoPanel(backDC, panelXRel, panelYRel, ctx->currentColor,
+ ctx->memDC, ctx->virtualX, ctx->virtualY,
+ ctx->mouseX, ctx->mouseY, ctx->dpiScale, ctx->gdi, ctx->panelMetrics,
+ ctx->virtualW, ctx->virtualH);
+ }
+
+ // 更新脏区域追踪
+ ctx->lastPanelRect = curPanelRect;
+ ctx->lastSelectionRect = curSelRect;
+ ctx->lastLabelRect = curLabelRect;
+ ctx->lastHighlightRect = curHlRect;
+ ctx->lastToolbarRect = curToolbarRect;
+ ctx->lastPopupRect = curPopupRect;
+
+ // 取消裁剪区(局部帧设置的),避免影响后续 GDI 操作
+ if (dirtyRgn) {
+ SelectClipRgn(backDC, NULL);
+ DeleteObject(dirtyRgn);
+ }
+
+ // 后台缓冲 -> 窗口(局部帧只拷脏区域,fullFrame 拷全屏)
+ BitBlt(hdc, dirtyRect.left, dirtyRect.top,
+ dirtyRect.right - dirtyRect.left, dirtyRect.bottom - dirtyRect.top,
+ backDC, dirtyRect.left, dirtyRect.top, SRCCOPY);
+ EndPaint(hwnd, &ps);
+ return 0;
+}
diff --git a/src/screenshot/overlay_ui_windows.cpp b/src/screenshot/overlay_ui_windows.cpp
new file mode 100644
index 0000000..ca2a21e
--- /dev/null
+++ b/src/screenshot/overlay_ui_windows.cpp
@@ -0,0 +1,1490 @@
+// 截图模块:覆盖层 UI 布局与绘制(metrics、弹窗、工具栏、信息面板、手柄、遮罩)
+#include "internal.h"
+
+// AlphaBlend 需要 msimg32
+#pragma comment(lib, "msimg32.lib")
+
+// 是否为圆角(内倒角)手柄:四个角共用同一套拖拽逻辑(改 selectionCornerRadius)
+
+bool IsCornerRadiusHandle(int h) {
+ return h == RH_CornerRadiusTL || h == RH_CornerRadiusTR ||
+ h == RH_CornerRadiusBL || h == RH_CornerRadiusBR;
+}
+
+SCPanelMetrics CalcPanelMetrics(double dpiScale) {
+ auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
+ SCPanelMetrics m;
+ m.w = scale(SC_PANEL_WIDTH);
+ m.h = scale(SC_PANEL_HEIGHT);
+ m.magnifierH = scale(SC_MAGNIFIER_HEIGHT);
+ m.margin = scale(SC_PANEL_MARGIN);
+ m.radius = scale(SC_PANEL_CORNER_RADIUS);
+ m.fontPx = scale(12);
+ m.crosshair = (std::max)(1, scale(1));
+ m.borderPad = (std::max)(1, scale(2));
+ m.labelPad = scale(6);
+ m.sizeLabelPadX = scale(12);
+ m.sizeLabelPadY = scale(4);
+ m.sizeLabelGap = scale(5);
+ return m;
+}
+
+SCToolbarMetrics CalcToolbarMetrics(double dpiScale) {
+ auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
+ SCToolbarMetrics m;
+ m.btn = scale(SC_TOOLBAR_BTN);
+ m.h = scale(SC_TOOLBAR_H);
+ m.gap = scale(SC_TOOLBAR_GAP);
+ m.pad = scale(SC_TOOLBAR_PAD);
+ m.radius = scale(SC_TOOLBAR_RADIUS);
+ m.margin = scale(SC_TOOLBAR_MARGIN);
+ m.border = scale(SC_TOOLBAR_BORDER);
+ // 图标视觉内容约占按钮 ~72%,留出内边距;额外 +2px 余量提升抗锯齿质量
+ m.iconSize = scale(SC_TOOLBAR_BTN - 8) + 2;
+ return m;
+}
+
+// 按当前 DPI 计算手柄几何:选区/标注 resize 手柄与圆角手柄尺寸同步缩放。
+
+SCHandleMetrics CalcHandleMetrics(double dpiScale) {
+ auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
+ SCHandleMetrics m;
+ m.handleSize = scale(SC_HANDLE_SIZE);
+ m.cornerKnobInset = scale(SC_CORNER_KNOB_INSET);
+ m.handleMargin = m.handleSize / 2 + 4;
+ m.cornerProximity = scale(SC_CORNER_PROXIMITY);
+ return m;
+}
+
+// ==================== 粗细/颜色子菜单 ====================
+
+// 按 DPI 计算子菜单几何(单行布局)。
+// 布局:[粗细圆点×3] sepGap | 分隔线 | sepGap [颜色圆点×8]
+// 单元格尺寸与工具栏按钮一致(= SC_TOOLBAR_BTN),单元格间距 = SC_TOOLBAR_GAP,视觉对齐。
+// 粗细圆点本身大小随预设值变化,颜色圆点固定直径,均居中在单元格内。
+
+SCPopupMetrics CalcPopupMetrics(double dpiScale) {
+ auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
+ SCPopupMetrics m;
+ m.pad = scale(SC_POPUP_PAD);
+ m.radius = scale(SC_POPUP_RADIUS);
+ m.cell = scale(SC_POPUP_CELL);
+ m.colorDot = scale(SC_POPUP_COLOR_DOT);
+ m.sepGap = scale(SC_POPUP_SEP_GAP);
+ m.sepH = scale(SC_POPUP_SEP_H);
+ m.border = scale(SC_POPUP_BORDER);
+ m.margin = scale(SC_POPUP_MARGIN);
+ return m;
+}
+
+// 子菜单单元格间距:把工具栏按钮间距 SC_TOOLBAR_GAP 按单元格尺寸等比折算后四舍五入。
+// 布局计算 / 命中测试 / 绘制三条路径必须共用同一公式,否则命中框与可见单元格错位。
+
+static int PopupCellGap(const SCPopupMetrics& m) {
+ return (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
+}
+
+// COLORREF 主题色 -> 不透明 Gdiplus::Color 的封装 ScOpaqueColor 见 internal.h 主题色常量组。
+
+// 子菜单选中态单元格背景:单元格大小的圆角矩形填充 bg + 带透明灰描边(alpha 90)。
+// 马赛克子菜单与普通粗细/颜色子菜单两处绘制共用同一外观(原先各持一份 lambda)。
+
+static void DrawPopupCellBg(Gdiplus::Graphics& graphics, const SCPopupMetrics& m,
+ int contentTop, int cellLeft, const Gdiplus::Color& bg) {
+ Gdiplus::GraphicsPath p;
+ AddRoundedRect(p, cellLeft, contentTop, m.cell, m.cell, m.cell / 4);
+ Gdiplus::SolidBrush bgBrush(bg);
+ graphics.FillPath(&bgBrush, &p);
+ // 背景边缘描边(带透明灰,alpha 90)
+ Gdiplus::Pen edgePen(Gdiplus::Color(90, 160, 160, 160), 1.0f);
+ graphics.DrawPath(&edgePen, &p);
+}
+
+// 子菜单总宽/高(单行)。单元格间距沿用工具栏按钮间距 SC_TOOLBAR_GAP。
+
+static void CalcPopupSize(const SCPopupMetrics& m, int& outW, int& outH) {
+ int cellGap = PopupCellGap(m);
+ // 粗细组宽
+ int thickW = SC_THICK_COUNT * m.cell + (SC_THICK_COUNT - 1) * cellGap;
+ // 颜色组宽
+ int colorW = SC_COLOR_COUNT * m.cell + (SC_COLOR_COUNT - 1) * cellGap;
+ int contentW = thickW + m.sepGap * 2 + 1 + colorW; // 1 = 分隔线宽度
+ outW = contentW + m.pad * 2 + m.border * 2;
+ outH = m.cell + m.pad * 2 + m.border * 2;
+}
+
+// toolbarRect / out 均为相对虚拟屏幕坐标;pw/ph 为子菜单尺寸。
+
+// 计算子菜单位置(通用):贴工具栏下方,放不下则上方,左右钳制到所在显示器内。
+// 翻转/钳制边界取「工具栏所在显示器」而非整虚拟屏幕(多屏异分辨率,见
+// GetMonitorBoundsForRect 注释),否则低分屏上子菜单会翻出本屏。
+
+void CalcPopupPlacement(const RECT& toolbarRect,
+ int virtualX, int virtualY,
+ int virtualW, int virtualH,
+ const SCPopupMetrics& m, int pw, int ph, RECT& out) {
+ // 边界基准:工具栏所在显示器(失败回退整虚拟屏幕)
+ RECT tbAbs = { toolbarRect.left + virtualX, toolbarRect.top + virtualY,
+ toolbarRect.right + virtualX, toolbarRect.bottom + virtualY };
+ RECT mon = {};
+ int bLeft = 0, bTop = 0, bRight = virtualW, bBottom = virtualH;
+ if (GetMonitorBoundsForRect(tbAbs, mon)) {
+ bLeft = mon.left - virtualX; bTop = mon.top - virtualY;
+ bRight = mon.right - virtualX; bBottom = mon.bottom - virtualY;
+ }
+ // 水平:与工具栏左对齐
+ int x = toolbarRect.left;
+ // 垂直:优先工具栏下方
+ int y = toolbarRect.bottom + m.margin;
+ if (y + ph > bBottom) {
+ y = toolbarRect.top - m.margin - ph;
+ }
+ // 左右钳制
+ if (x + pw > bRight) x = bRight - pw - m.margin;
+ if (x < bLeft) x = bLeft + m.margin;
+
+ out.left = x;
+ out.top = y;
+ out.right = x + pw;
+ out.bottom = y + ph;
+}
+
+// toolbarRect / out 均为相对虚拟屏幕坐标(与工具栏一致)。
+
+// 计算子菜单位置:贴工具栏下方,放不下则上方,左右钳制到所在显示器内。
+
+void CalcPopupPosition(const RECT& toolbarRect,
+ int virtualX, int virtualY,
+ int virtualW, int virtualH,
+ const SCPopupMetrics& m, RECT& out) {
+ int pw, ph;
+ CalcPopupSize(m, pw, ph);
+ CalcPopupPlacement(toolbarRect, virtualX, virtualY, virtualW, virtualH, m, pw, ph, out);
+}
+
+// 马赛克子菜单几何常量
+// 模式切换组:2 个单元格(涂抹 / 框选);块大小组:3 个单元格。
+
+static const int SC_MOSAIC_MODE_COUNT = 2; // 涂抹、框选
+
+// 涂抹模式用半径圆点表示,块大小用马赛克方块网格表示(绘制时按预设值缩放)
+
+// 计算马赛克子菜单总宽/高(单行)。
+// 布局:[模式×2] sepGap | 分隔线 | sepGap [块大小×3] sepGap | 分隔线 | sepGap [涂抹半径×3]
+
+void CalcMosaicPopupSize(const SCPopupMetrics& m, int& outW, int& outH) {
+ int cellGap = PopupCellGap(m);
+ int modeW = SC_MOSAIC_MODE_COUNT * m.cell + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
+ int sizeW = SC_MOSAIC_COUNT * m.cell + (SC_MOSAIC_COUNT - 1) * cellGap;
+ int radiusW = SC_MOSAIC_RADIUS_COUNT * m.cell + (SC_MOSAIC_RADIUS_COUNT - 1) * cellGap;
+ // 两组分隔线,每组分隔线宽 = sepGap*2 + 1
+ int contentW = modeW + (m.sepGap * 2 + 1) + sizeW + (m.sepGap * 2 + 1) + radiusW;
+ outW = contentW + m.pad * 2 + m.border * 2;
+ outH = m.cell + m.pad * 2 + m.border * 2;
+}
+
+// 命中测试马赛克子菜单,返回值约定:
+// +1 = 涂抹模式;+2 = 框选模式
+// +101.. = 第 N 个块大小(100 + sizeIdx + 1)
+// +201.. = 第 N 个涂抹半径(200 + radiusIdx + 1)
+// 0 = 未命中
+
+int HitTestMosaicPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m) {
+ if (!PointInRect(x, y, popupRect)) return 0;
+ int contentLeft = popupRect.left + m.border + m.pad;
+ int contentTop = popupRect.top + m.border + m.pad;
+ int cellGap = PopupCellGap(m);
+ if (y < contentTop || y >= contentTop + m.cell) return 0;
+
+ // 模式组
+ for (int i = 0; i < SC_MOSAIC_MODE_COUNT; i++) {
+ int ix = contentLeft + i * (m.cell + cellGap);
+ if (x >= ix && x < ix + m.cell) return i + 1; // +1=涂抹 +2=框选
+ }
+ int modeEndX = contentLeft + SC_MOSAIC_MODE_COUNT * m.cell
+ + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
+ int sizeStartX = modeEndX + m.sepGap * 2 + 1;
+ if (x < sizeStartX) return 0; // 第一条分隔线区域
+ // 块大小组
+ int sizeEndX = sizeStartX + SC_MOSAIC_COUNT * m.cell
+ + (SC_MOSAIC_COUNT - 1) * cellGap;
+ for (int i = 0; i < SC_MOSAIC_COUNT; i++) {
+ int ix = sizeStartX + i * (m.cell + cellGap);
+ if (x >= ix && x < ix + m.cell) return 100 + i + 1;
+ }
+ // 涂抹半径组
+ int radiusStartX = sizeEndX + m.sepGap * 2 + 1;
+ if (x < radiusStartX) return 0; // 第二条分隔线区域
+ for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
+ int ix = radiusStartX + i * (m.cell + cellGap);
+ if (x >= ix && x < ix + m.cell) return 200 + i + 1;
+ }
+ return 0;
+}
+
+// 绘制马赛克子菜单(单行)。
+// modeIdx:当前模式(0=涂抹 1=框选);sizeIdx:当前块大小索引;radiusIdx:涂抹半径索引。
+
+void DrawMosaicPopup(HDC hdc, const RECT& popupRect,
+ int modeIdx, int sizeIdx, int radiusIdx,
+ const SCPopupMetrics& m) {
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
+ {
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+
+ int pw = popupRect.right - popupRect.left;
+ int ph = popupRect.bottom - popupRect.top;
+
+ // 白色圆角背景 + 浅灰边框
+ Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::GraphicsPath bgPath;
+ AddRoundedRect(bgPath, popupRect.left, popupRect.top, pw, ph, m.radius);
+ graphics.FillPath(&whiteBrush, &bgPath);
+ Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
+ graphics.DrawPath(&borderPen, &bgPath);
+
+ int contentLeft = popupRect.left + m.border + m.pad;
+ int contentTop = popupRect.top + m.border + m.pad;
+ int midY = contentTop + m.cell / 2;
+ int cellGap = PopupCellGap(m);
+
+ auto cellColor = [&](bool sel) -> Gdiplus::Color {
+ return sel ? ScOpaqueColor(SC_THEME_TOOLBAR_BLUE)
+ : ScOpaqueColor(SC_THEME_ICON_DARK);
+ };
+
+ // 模式组
+ for (int i = 0; i < SC_MOSAIC_MODE_COUNT; i++) {
+ int cellLeft = contentLeft + i * (m.cell + cellGap);
+ bool sel = (i == modeIdx);
+ if (sel) DrawPopupCellBg(graphics, m, contentTop, cellLeft,
+ ScOpaqueColor(SC_THEME_SEL_BG));
+ int cx = cellLeft + m.cell / 2;
+ Gdiplus::Color c = cellColor(sel);
+ if (i == 0) {
+ // 涂抹模式:画一个画笔/毛刷图标(一条波浪线 + 圆头)
+ Gdiplus::Pen pen(c, (Gdiplus::REAL)2.0f);
+ pen.SetLineJoin(Gdiplus::LineJoinRound);
+ pen.SetStartCap(Gdiplus::LineCapRound);
+ pen.SetEndCap(Gdiplus::LineCapRound);
+ // 自由曲线(模拟涂抹轨迹)
+ Gdiplus::PointF curve[] = {
+ Gdiplus::PointF((float)(cx - m.cell * 0.28), (float)(midY + m.cell * 0.18)),
+ Gdiplus::PointF((float)(cx - m.cell * 0.10), (float)(midY - m.cell * 0.18)),
+ Gdiplus::PointF((float)(cx + m.cell * 0.10), (float)(midY + m.cell * 0.18)),
+ Gdiplus::PointF((float)(cx + m.cell * 0.28), (float)(midY - m.cell * 0.18)),
+ };
+ graphics.DrawLines(&pen, curve, 4);
+ } else {
+ // 框选模式:画一个虚线矩形
+ Gdiplus::Pen pen(c, (Gdiplus::REAL)2.0f);
+ int r = (int)(m.cell * 0.24);
+ graphics.DrawRectangle(&pen, cx - r, midY - r, r * 2, r * 2);
+ }
+ }
+
+ // 分隔线
+ int modeEndX = contentLeft + SC_MOSAIC_MODE_COUNT * m.cell
+ + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
+ int sepX = modeEndX + m.sepGap;
+ Gdiplus::Pen sepPen(Gdiplus::Color(255, 220, 220, 220), 1.0f);
+ graphics.DrawLine(&sepPen, sepX, midY - m.sepH / 2, sepX, midY + m.sepH / 2);
+
+ // 块大小组:每个单元格画一个 N×N 的马赛克方块网格,块越大网格越粗
+ int sizeStartX = sepX + m.sepGap + 1;
+ for (int i = 0; i < SC_MOSAIC_COUNT; i++) {
+ int cellLeft = sizeStartX + i * (m.cell + cellGap);
+ bool sel = (i == sizeIdx);
+ if (sel) DrawPopupCellBg(graphics, m, contentTop, cellLeft,
+ ScOpaqueColor(SC_THEME_SEL_BG));
+ Gdiplus::Color c = cellColor(sel);
+ int cx = cellLeft + m.cell / 2;
+ // 网格区域边长(占单元格约 0.6)
+ int gridHalf = (int)(m.cell * 0.26);
+ int gridSize = gridHalf * 2;
+ int gx = cx - gridHalf;
+ int gy = midY - gridHalf;
+ // 块数随预设递增:i=0 -> 2x2, i=1 -> 3x3, i=2 -> 4x4
+ int n = 2 + i;
+ int cellSz = gridSize / n;
+ if (cellSz < 1) cellSz = 1;
+ Gdiplus::SolidBrush b(c);
+ // 交错填充模拟马赛克质感(棋盘格)
+ for (int ry = 0; ry < n; ry++) {
+ for (int rx = 0; rx < n; rx++) {
+ if (((rx + ry) & 1) == 0) {
+ graphics.FillRectangle(&b, gx + rx * cellSz, gy + ry * cellSz,
+ cellSz, cellSz);
+ }
+ }
+ }
+ // 网格描边(未填充格用半透明)
+ Gdiplus::Pen gridPen(Gdiplus::Color(sel ? 200 : 120,
+ c.GetRed(), c.GetGreen(), c.GetBlue()),
+ 1.0f);
+ for (int k = 0; k <= n; k++) {
+ graphics.DrawLine(&gridPen, gx + k * cellSz, gy,
+ gx + k * cellSz, gy + n * cellSz);
+ graphics.DrawLine(&gridPen, gx, gy + k * cellSz,
+ gx + n * cellSz, gy + k * cellSz);
+ }
+ }
+
+ // 第二条分隔线
+ int sizeEndX = sizeStartX + SC_MOSAIC_COUNT * m.cell
+ + (SC_MOSAIC_COUNT - 1) * cellGap;
+ int sep2X = sizeEndX + m.sepGap;
+ graphics.DrawLine(&sepPen, sep2X, midY - m.sepH / 2, sep2X, midY + m.sepH / 2);
+
+ // 涂抹半径组:用不同直径的圆点表示半径大小(类似画笔粗细)。
+ // 仅涂抹模式下有意义;框选模式下置灰但仍可点击(切换后立即生效)。
+ int radiusStartX = sep2X + m.sepGap + 1;
+ bool radiusEnabled = (modeIdx == 0);
+ for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
+ int cellLeft = radiusStartX + i * (m.cell + cellGap);
+ bool sel = (i == radiusIdx) && radiusEnabled;
+ if (sel) DrawPopupCellBg(graphics, m, contentTop, cellLeft,
+ ScOpaqueColor(SC_THEME_SEL_BG));
+ Gdiplus::Color c = cellColor(sel);
+ int cx = cellLeft + m.cell / 2;
+ // 圆点直径随预设递增:小/中/大
+ int dotD = (int)(SC_MOSAIC_RADIUS[i] * 0.5 * (m.cell / (double)SC_POPUP_CELL) + 0.5);
+ if (dotD < 5) dotD = 5;
+ if (dotD > m.cell - 4) dotD = m.cell - 4;
+ int r = dotD / 2;
+ Gdiplus::Color drawC = radiusEnabled ? c
+ : Gdiplus::Color(160, c.GetRed(), c.GetGreen(), c.GetBlue());
+ Gdiplus::SolidBrush brush(drawC);
+ graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
+ }
+ }
+}
+
+// 命中测试子菜单,返回值约定:
+// +1..+SC_THICK_COUNT = 第 N 个粗细
+// -1..-SC_COLOR_COUNT = 第 N 个颜色(取负为索引+1)
+// 0 = 未命中(包括点在分隔线上)
+
+int HitTestPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m) {
+ if (!PointInRect(x, y, popupRect)) return 0;
+
+ // 内容左边界(绝对)
+ int contentLeft = popupRect.left + m.border + m.pad;
+ int contentTop = popupRect.top + m.border + m.pad;
+ int cellGap = PopupCellGap(m);
+ // y 必须在单元格高度内
+ if (y < contentTop || y >= contentTop + m.cell) return 0;
+
+ // 粗细组:contentLeft 起
+ int thickX0 = contentLeft;
+ for (int i = 0; i < SC_THICK_COUNT; i++) {
+ int ix = thickX0 + i * (m.cell + cellGap);
+ if (x >= ix && x < ix + m.cell) return i + 1;
+ }
+ int thickEndX = thickX0 + SC_THICK_COUNT * m.cell
+ + (SC_THICK_COUNT - 1) * cellGap;
+ // 分隔线区域(不命中)
+ int colorStartX = thickEndX + m.sepGap * 2 + 1;
+ if (x < colorStartX) return 0;
+
+ // 颜色组
+ for (int i = 0; i < SC_COLOR_COUNT; i++) {
+ int ix = colorStartX + i * (m.cell + cellGap);
+ if (x >= ix && x < ix + m.cell) return -(i + 1);
+ }
+ return 0;
+}
+
+// 绘制子菜单(GDI+ 抗锯齿白底圆角,单行布局)。
+// 第一组:isTextTool 时显示字号(不同大小 'A'),否则显示粗细(圆点直径区分)。
+// 第二组:颜色(固定直径圆点)。中间一条竖直分隔线。
+
+void DrawPopup(HDC hdc, const RECT& popupRect,
+ int colorIdx, int firstIdx, bool isTextTool,
+ const SCPopupMetrics& m) {
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
+ {
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+
+ int pw = popupRect.right - popupRect.left;
+ int ph = popupRect.bottom - popupRect.top;
+
+ // 白色圆角背景
+ Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::GraphicsPath bgPath;
+ AddRoundedRect(bgPath, popupRect.left, popupRect.top, pw, ph, m.radius);
+ graphics.FillPath(&whiteBrush, &bgPath);
+ // 浅灰边框
+ Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
+ graphics.DrawPath(&borderPen, &bgPath);
+
+ int contentLeft = popupRect.left + m.border + m.pad;
+ int contentTop = popupRect.top + m.border + m.pad;
+ int midY = contentTop + m.cell / 2;
+ int cellGap = PopupCellGap(m);
+
+ // 选中态背景:单元格大小的圆角矩形(公共绘制见 DrawPopupCellBg)。
+ // 粗细沿用主工具栏选中高亮底(不透明浅蓝,SC_THEME_SEL_BG);
+ // 颜色用带透明(alpha 80)的选中色。
+
+ // 第一组:文字工具显示字号(不同大小 'A'),矢量工具显示粗细(圆点直径区分)
+ int firstCount = isTextTool ? SC_FONT_COUNT : SC_THICK_COUNT;
+ int firstX0 = contentLeft;
+ for (int i = 0; i < firstCount; i++) {
+ int cellLeft = firstX0 + i * (m.cell + cellGap);
+ if (i == firstIdx) {
+ // 与主工具栏选中态一致的不透明浅蓝底
+ DrawPopupCellBg(graphics, m, contentTop, cellLeft,
+ ScOpaqueColor(SC_THEME_SEL_BG));
+ }
+ Gdiplus::Color iconC = (i == firstIdx)
+ ? ScOpaqueColor(SC_THEME_TOOLBAR_BLUE)
+ : ScOpaqueColor(SC_THEME_ICON_DARK);
+ int cx = cellLeft + m.cell / 2;
+ if (isTextTool) {
+ // 字号:用不同大小的字母 'A' 表示,居中绘制。
+ // 缩放系数:最大字号(36)额外缩小到 0.62,前两个字号(16/24)保持 0.72,
+ // 避免最大字号的 'A' 仍偏大溢出。
+ double sizeScale = (i == SC_FONT_COUNT - 1) ? 0.62 : 0.72;
+ int fontPx = (int)(SC_FONT_SIZES[i] * (m.cell / (double)SC_POPUP_CELL) * sizeScale + 0.5);
+ if (fontPx < 6) fontPx = 6;
+ Gdiplus::FontFamily fontFamily(SC_FONT_FACE);
+ Gdiplus::FontStyle fs = Gdiplus::FontStyleRegular;
+ // 'A' 像素高 = fontPx,按高度反推 emSize(GDI+ 用 em)
+ Gdiplus::Font font(&fontFamily, (Gdiplus::REAL)fontPx, fs, Gdiplus::UnitPixel);
+ Gdiplus::SolidBrush b(iconC);
+ Gdiplus::StringFormat sf;
+ sf.SetAlignment(Gdiplus::StringAlignmentCenter);
+ sf.SetLineAlignment(Gdiplus::StringAlignmentCenter);
+ Gdiplus::RectF cellRect((Gdiplus::REAL)cellLeft, (Gdiplus::REAL)contentTop,
+ (Gdiplus::REAL)m.cell, (Gdiplus::REAL)m.cell);
+ graphics.DrawString(L"A", 1, &font, cellRect, &sf, &b);
+ } else {
+ // 粗细:圆点直径取自 SC_THICK_DOT_SIZES
+ int dotD = (int)(SC_THICK_DOT_SIZES[i] * (m.cell / (double)SC_POPUP_CELL) + 0.5);
+ if (dotD < 4) dotD = 4;
+ if (dotD > m.cell) dotD = m.cell;
+ int r = dotD / 2;
+ Gdiplus::SolidBrush brush(iconC);
+ graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
+ }
+ }
+
+ // 分隔线
+ int firstEndX = firstX0 + firstCount * m.cell
+ + (firstCount - 1) * cellGap;
+ int sepX = firstEndX + m.sepGap;
+ Gdiplus::Pen sepPen(Gdiplus::Color(255, 220, 220, 220), 1.0f);
+ graphics.DrawLine(&sepPen, sepX, midY - m.sepH / 2, sepX, midY + m.sepH / 2);
+
+ // 颜色组:选中时背景变为带透明的选中色
+ int colorStartX = sepX + m.sepGap + 1;
+ for (int i = 0; i < SC_COLOR_COUNT; i++) {
+ COLORREF c = SC_COLOR_PRESETS[i];
+ int cellLeft = colorStartX + i * (m.cell + cellGap);
+ int cx = cellLeft + m.cell / 2;
+ int r = m.colorDot / 2;
+ if (i == colorIdx) {
+ DrawPopupCellBg(graphics, m, contentTop, cellLeft,
+ Gdiplus::Color(80, GetRValue(c), GetGValue(c), GetBValue(c)));
+ }
+ Gdiplus::Color gc(GetRValue(c), GetGValue(c), GetBValue(c));
+ Gdiplus::SolidBrush brush(gc);
+ graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
+ // 圆点本身始终保留极浅描边(白色块可见性),选中也保留
+ Gdiplus::Pen outline(Gdiplus::Color(255, 220, 220, 220), 1.0f);
+ graphics.DrawEllipse(&outline, cx - r, midY - r, r * 2, r * 2);
+ }
+ }
+}
+
+// 计算浮窗位置(优先右下,超出则翻转)
+
+void CalcPanelPosition(int mx, int my, int vx, int vy, int vw, int vh,
+ const SCPanelMetrics& m, int& px, int& py) {
+ int sr = vx + vw;
+ int sb = vy + vh;
+ px = mx + m.margin;
+ py = my + m.margin;
+ if (px + m.w > sr) px = mx - m.w - m.margin;
+ if (py + m.h > sb) py = my - m.h - m.margin;
+ if (px < vx) px = vx + m.margin;
+ if (py < vy) py = vy + m.margin;
+}
+
+// 调整选区时放大镜面板位置:放在被拖手柄的"外侧",避免遮挡正在调整的选区。
+// 左/右手柄置选区左/右外侧,顶/底置上下外侧,四角置对角外侧;边手柄在垂直方向居中。
+// 贴屏边放不下则翻到对侧,仍放不下则贴屏边(覆盖选区可接受,属边界情况)。
+
+void CalcResizePanelPosition(int handle, const RECT& sel,
+ int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py) {
+ bool movesL = handle == RH_Left || handle == RH_TopLeft || handle == RH_BottomLeft;
+ bool movesR = handle == RH_Right || handle == RH_TopRight || handle == RH_BottomRight;
+ bool movesT = handle == RH_Top || handle == RH_TopLeft || handle == RH_TopRight;
+ bool movesB = handle == RH_Bottom || handle == RH_BottomLeft || handle == RH_BottomRight;
+ if (movesL) px = sel.left - m.w - m.margin;
+ else if (movesR) px = sel.right + m.margin;
+ else px = (sel.left + sel.right) / 2 - m.w / 2;
+ if (movesT) py = sel.top - m.h - m.margin;
+ else if (movesB) py = sel.bottom + m.margin;
+ else py = (sel.top + sel.bottom) / 2 - m.h / 2;
+ // 外侧放不下 -> 翻到对侧;对侧仍放不下 -> 贴屏边
+ if (px < vx) px = sel.right + m.margin;
+ if (px + m.w > vx + vw) px = sel.left - m.w - m.margin;
+ if (px < vx) px = vx + m.margin;
+ if (px + m.w > vx + vw) px = vx + vw - m.w - m.margin;
+ if (py < vy) py = sel.bottom + m.margin;
+ if (py + m.h > vy + vh) py = sel.top - m.h - m.margin;
+ if (py < vy) py = vy + m.margin;
+ if (py + m.h > vy + vh) py = vy + vh - m.h - m.margin;
+}
+
+// 从预截屏位图恢复脏区域到后台缓冲
+
+void RestoreDirtyRegion(HDC backDC, HDC memDC, const RECT& dirty, double dpiScale) {
+ int w = dirty.right - dirty.left;
+ int h = dirty.bottom - dirty.top;
+ if (w <= 0 || h <= 0) return;
+ int x = (std::max)((int)dirty.left, 0);
+ int y = (std::max)((int)dirty.top, 0);
+ w = dirty.right - x;
+ h = dirty.bottom - y;
+ if (dpiScale > 1.01 || dpiScale < 0.99) {
+ int px = (int)(x * dpiScale + 0.5);
+ int py = (int)(y * dpiScale + 0.5);
+ int pw = (int)(w * dpiScale + 0.5);
+ int ph = (int)(h * dpiScale + 0.5);
+ StretchBlt(backDC, x, y, w, h, memDC, px, py, pw, ph, SRCCOPY);
+ } else {
+ BitBlt(backDC, x, y, w, h, memDC, x, y, SRCCOPY);
+ }
+}
+
+// 扩展矩形
+
+RECT InflateRectBy(const RECT& r, int margin) {
+ return { r.left - margin, r.top - margin, r.right + margin, r.bottom + margin };
+}
+
+// 矩形是否有效(宽高 > 0)
+
+bool IsValidRect(const RECT& r) {
+ return r.right > r.left && r.bottom > r.top;
+}
+
+// 两矩形并集的外包矩形(安全处理零矩形:若一方无效则返回另一方)。
+// 用于计算"旧位置 ∪ 新位置"的脏区域外包。
+
+RECT UnionRectSafe(const RECT& a, const RECT& b) {
+ bool va = IsValidRect(a), vb = IsValidRect(b);
+ if (!va && !vb) return {0, 0, 0, 0};
+ if (!va) return b;
+ if (!vb) return a;
+ return { (std::min)(a.left, b.left), (std::min)(a.top, b.top),
+ (std::max)(a.right, b.right), (std::max)(a.bottom, b.bottom) };
+}
+
+// ---- 绘制函数 ----
+
+// 绘制放大镜 + 鼠标信息面板
+// memDC 内是整个虚拟屏幕的物理尺寸位图,采样窗口以鼠标(mx,my)为中心;
+// 四边均把源区钳回位图内:左/上钳起点、右/下收窄采样宽高,
+// 钳制后采样宽高非正时跳过放大镜绘制,避免向 StretchBlt 传非法参数。
+// virtualW/virtualH 为虚拟屏逻辑尺寸,配合 dpiScale 还原位图物理总宽高。
+
+void DrawInfoPanel(HDC hdc, int panelX, int panelY, COLORREF color,
+ HDC memDC, int vx, int vy, int mx, int my, double dpiScale,
+ const SCGdiResources& gdi, const SCPanelMetrics& m,
+ int virtualW, int virtualH) {
+ HGDIOBJ oldBrush = SelectObject(hdc, gdi.bgBrush);
+ HGDIOBJ oldPen = SelectObject(hdc, gdi.borderPen);
+
+ // 圆角矩形背景
+ RoundRect(hdc, panelX, panelY, panelX + m.w, panelY + m.h,
+ m.radius, m.radius);
+
+ // 放大镜:从物理尺寸位图取像素
+ int srcW = m.w / SC_ZOOM_FACTOR;
+ int srcH = m.magnifierH / SC_ZOOM_FACTOR;
+ int mxLogical = mx - vx;
+ int myLogical = my - vy;
+ int mxPhysical = (int)(mxLogical * dpiScale + 0.5);
+ int myPhysical = (int)(myLogical * dpiScale + 0.5);
+ int srcWPhysical = (int)(srcW * dpiScale + 0.5);
+ int srcHPhysical = (int)(srcH * dpiScale + 0.5);
+ int srcXPhysical = mxPhysical - srcWPhysical / 2;
+ int srcYPhysical = myPhysical - srcHPhysical / 2;
+
+ int magX = panelX + m.borderPad;
+ int magY = panelY + m.borderPad;
+ int magW = m.w - m.borderPad * 2;
+ int magH = m.magnifierH - m.borderPad;
+
+ // 源区钳制:左/上越界把起点钳回 0;右/下越界按位图物理总宽高收窄采样宽高,
+ // 保证 [src, src+size) 完全落在 memDC 位图内(贴边时 StretchBlt 越界采样属未定义渲染)。
+ int srcXPoS = (std::max)(srcXPhysical, 0);
+ int srcYPoS = (std::max)(srcYPhysical, 0);
+ int totalWPhysical = (int)(virtualW * dpiScale + 0.5);
+ int totalHPhysical = (int)(virtualH * dpiScale + 0.5);
+ int sampleWPhysical = (std::min)(srcWPhysical, totalWPhysical - srcXPoS);
+ int sampleHPhysical = (std::min)(srcHPhysical, totalHPhysical - srcYPoS);
+
+ // 采样宽高非正(如鼠标坐标异常越过物理位图右/下边界)则放弃放大镜绘制
+ if (sampleWPhysical > 0 && sampleHPhysical > 0) {
+ StretchBlt(hdc, magX, magY, magW, magH, memDC,
+ srcXPoS, srcYPoS, sampleWPhysical, sampleHPhysical, SRCCOPY);
+ }
+
+ // 十字准星
+ SelectObject(hdc, gdi.crosshairPen);
+ int cx = magX + magW / 2;
+ int cy = magY + magH / 2;
+ MoveToEx(hdc, magX, cy, NULL); LineTo(hdc, magX + magW, cy);
+ MoveToEx(hdc, cx, magY, NULL); LineTo(hdc, cx, magY + magH);
+
+ // 文字信息
+ SetBkMode(hdc, TRANSPARENT);
+ SetTextColor(hdc, RGB(255, 255, 255));
+ HGDIOBJ oldFont = SelectObject(hdc, gdi.smallFont);
+
+ char hexBuf[32], rgbBuf[32];
+ ColorrefToStrings(color, hexBuf, rgbBuf);
+ char posBuf[64];
+ sprintf_s(posBuf, "%d, %d", mx, my);
+
+ int labelX = panelX + m.labelPad;
+ int valueRightX = panelX + m.w - m.labelPad;
+
+ // 获取文字高度
+ SIZE textSize;
+ GetTextExtentPoint32W(hdc, L"测试", 2, &textSize);
+ int lineH = textSize.cy;
+ int infoY = panelY + m.h - m.labelPad - lineH * 3;
+
+ // 辅助:右对齐绘制
+ auto drawRightAligned = [&](const wchar_t* text, int len, int rx, int ry) {
+ SIZE sz;
+ GetTextExtentPoint32W(hdc, text, len, &sz);
+ TextOutW(hdc, rx - sz.cx, ry, text, len);
+ };
+
+ // 坐标
+ TextOutW(hdc, labelX, infoY, L"坐标", 2);
+ std::wstring posW(posBuf, posBuf + strlen(posBuf));
+ drawRightAligned(posW.c_str(), (int)posW.size(), valueRightX, infoY);
+
+ // HEX
+ TextOutW(hdc, labelX, infoY + lineH, L"HEX", 3);
+ std::wstring hexW(hexBuf, hexBuf + strlen(hexBuf));
+ drawRightAligned(hexW.c_str(), (int)hexW.size(), valueRightX, infoY + lineH);
+
+ // RGB
+ TextOutW(hdc, labelX, infoY + lineH * 2, L"RGB", 3);
+ std::wstring rgbW(rgbBuf, rgbBuf + strlen(rgbBuf));
+ drawRightAligned(rgbW.c_str(), (int)rgbW.size(), valueRightX, infoY + lineH * 2);
+
+ SelectObject(hdc, oldFont);
+ SelectObject(hdc, oldBrush);
+ SelectObject(hdc, oldPen);
+}
+
+// 绘制尺寸标签,返回标签矩形
+
+RECT DrawSizeLabel(HDC hdc, int width, int height,
+ int refLeft, int refTop, int refRight, int refBottom,
+ int virtualW, int virtualH, const SCGdiResources& gdi, const SCPanelMetrics& m) {
+ RECT empty = {0, 0, 0, 0};
+ if (width < 0 || height < 0) return empty;
+
+ wchar_t sizeBuf[64];
+ swprintf_s(sizeBuf, L"%d × %d", width, height);
+ int sizeLen = (int)wcslen(sizeBuf);
+
+ HGDIOBJ oldFont = SelectObject(hdc, gdi.smallFont);
+ SIZE textSize;
+ GetTextExtentPoint32W(hdc, sizeBuf, sizeLen, &textSize);
+
+ int labelW = textSize.cx + m.sizeLabelPadX * 2;
+ int labelH = textSize.cy + m.sizeLabelPadY;
+
+ int lx = refLeft;
+ int ly = refTop - labelH - m.sizeLabelGap;
+ if (ly < 0) {
+ lx = refLeft + m.sizeLabelGap;
+ ly = refTop + m.sizeLabelGap;
+ if (lx + labelW > virtualW) lx = virtualW - labelW - m.sizeLabelGap;
+ if (ly + labelH > virtualH) ly = virtualH - labelH - m.sizeLabelGap;
+ if (lx + labelW > refRight) lx = refRight - labelW - m.sizeLabelGap;
+ if (ly + labelH > refBottom) ly = refBottom - labelH - m.sizeLabelGap;
+ }
+ if (lx < 0) lx = 0;
+ if (ly < 0) ly = 0;
+ if (lx + labelW > virtualW) lx = virtualW - labelW;
+ if (ly + labelH > virtualH) ly = virtualH - labelH;
+
+ HGDIOBJ oldBrush = SelectObject(hdc, gdi.bgBrush);
+ HGDIOBJ oldPen = SelectObject(hdc, gdi.borderPen);
+ RoundRect(hdc, lx, ly, lx + labelW, ly + labelH, m.radius, m.radius);
+
+ SetBkMode(hdc, TRANSPARENT);
+ SetTextColor(hdc, RGB(255, 255, 255));
+ TextOutW(hdc, lx + m.sizeLabelPadX, ly + m.borderPad, sizeBuf, sizeLen);
+
+ SelectObject(hdc, oldFont);
+ SelectObject(hdc, oldBrush);
+ SelectObject(hdc, oldPen);
+
+ RECT result = { lx, ly, lx + labelW, ly + labelH };
+ return result;
+}
+
+// 绘制选区矩形边框 + 尺寸标签
+
+RECT DrawSelection(HDC hdc, int x1, int y1, int x2, int y2,
+ int vx, int vy, int vw, int vh, const SCGdiResources& gdi, const SCPanelMetrics& m) {
+ int left = (std::min)(x1, x2) - vx;
+ int top = (std::min)(y1, y2) - vy;
+ int right = (std::max)(x1, x2) - vx;
+ int bottom = (std::max)(y1, y2) - vy;
+
+ HGDIOBJ oldPen = SelectObject(hdc, gdi.selectionPen);
+ HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
+ Rectangle(hdc, left, top, right, bottom);
+
+ int sizeW = right - left;
+ int sizeH = bottom - top;
+ RECT labelRect = DrawSizeLabel(hdc, sizeW, sizeH, left, top, right, bottom, vw, vh, gdi, m);
+
+ SelectObject(hdc, oldPen);
+ SelectObject(hdc, oldBrush);
+ return labelRect;
+}
+
+// 绘制窗口高亮边框
+
+void DrawWindowHighlight(HDC hdc, const RECT& rect, int vx, int vy, const SCGdiResources& gdi) {
+ int left = rect.left - vx;
+ int top = rect.top - vy;
+ int right = rect.right - vx;
+ int bottom = rect.bottom - vy;
+
+ HGDIOBJ oldPen = SelectObject(hdc, gdi.highlightPen);
+ HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
+ Rectangle(hdc, left, top, right, bottom);
+ SelectObject(hdc, oldPen);
+ SelectObject(hdc, oldBrush);
+}
+
+// 绘制选区外遮罩(微信风格)
+// 在 backDC 上对"选区外部"区域 AlphaBlend 一层半透明黑色,
+// 选区内部不绘制,保持原始截图清晰。
+// rect 为相对虚拟屏幕的逻辑坐标(已减去 virtualX/virtualY)。
+// radius>0 时额外给选区四角的"角帽"(方框内、圆角弧外)叠同色遮罩,
+// 使圆角内不残留清晰直角三角;角帽用 GDI+ 抗锯齿路径填充,与 AA 圆角边框一致。
+
+void DrawDimMask(HDC backDC, const SCGdiResources& gdi,
+ int selLeft, int selTop, int selRight, int selBottom,
+ int virtualW, int virtualH, int radius) {
+ if (!gdi.maskDC || !gdi.maskBitmap) return;
+
+ BLENDFUNCTION blend;
+ blend.BlendOp = AC_SRC_OVER;
+ blend.BlendFlags = 0;
+ blend.SourceConstantAlpha = SC_MASK_ALPHA;
+ blend.AlphaFormat = 0; // 不使用 per-pixel alpha,仅用常量 alpha
+
+ // 将虚拟屏幕按选区划分为四周四块,逐块 AlphaBlend 遮罩
+ // 上:x ∈ [0, vw], y ∈ [0, selTop]
+ if (selTop > 0) {
+ AlphaBlend(backDC, 0, 0, virtualW, selTop,
+ gdi.maskDC, 0, 0, virtualW, selTop, blend);
+ }
+ // 下:x ∈ [0, vw], y ∈ [selBottom, vh]
+ if (selBottom < virtualH) {
+ AlphaBlend(backDC, 0, selBottom, virtualW, virtualH - selBottom,
+ gdi.maskDC, 0, selBottom, virtualW, virtualH - selBottom, blend);
+ }
+ // 左:x ∈ [0, selLeft], y ∈ [selTop, selBottom]
+ if (selLeft > 0 && selBottom > selTop) {
+ AlphaBlend(backDC, 0, selTop, selLeft, selBottom - selTop,
+ gdi.maskDC, 0, selTop, selLeft, selBottom - selTop, blend);
+ }
+ // 右:x ∈ [selRight, vw], y ∈ [selTop, selBottom]
+ if (selRight < virtualW && selBottom > selTop) {
+ AlphaBlend(backDC, selRight, selTop, virtualW - selRight, selBottom - selTop,
+ gdi.maskDC, selRight, selTop, virtualW - selRight, selBottom - selTop, blend);
+ }
+
+ // 圆角角帽:选区方框内、圆角弧外的四角区域,叠加同色遮罩。
+ // 用 GDI+ GraphicsPath 在每角构造"外两条直边 + 内凹四分之一弧"的封闭图形,
+ // FillPath 填充得到角帽,弧边抗锯齿。遮罩色与上面 AlphaBlend 一致(黑+SC_MASK_ALPHA)。
+ if (radius > 0 && selRight > selLeft && selBottom > selTop) {
+ int x = selLeft, y = selTop;
+ int w = selRight - selLeft;
+ int h = selBottom - selTop;
+ int r = (std::min)(radius, (std::min)(w, h) / 2);
+ if (r >= 1) {
+ Gdiplus::Graphics graphics(backDC);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::SolidBrush dimBrush(Gdiplus::Color(SC_MASK_ALPHA, 0, 0, 0));
+ Gdiplus::GraphicsPath capPath;
+ // 左上角:上边 → 内凹弧 → 左边
+ capPath.AddLine(x, y, x + r, y);
+ capPath.AddArc(x, y, r * 2, r * 2, 270.0f, -90.0f);
+ capPath.AddLine(x, y + r, x, y);
+ capPath.CloseFigure();
+ // 右上角:上边 → 右边 → 内凹弧
+ capPath.AddLine(x + w - r, y, x + w, y);
+ capPath.AddLine(x + w, y, x + w, y + r);
+ capPath.AddArc(x + w - r * 2, y, r * 2, r * 2, 0.0f, -90.0f);
+ capPath.CloseFigure();
+ // 右下角:右边 → 下边 → 内凹弧
+ capPath.AddLine(x + w, y + h - r, x + w, y + h);
+ capPath.AddLine(x + w, y + h, x + w - r, y + h);
+ capPath.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 90.0f, -90.0f);
+ capPath.CloseFigure();
+ // 左下角:左边 → 下边 → 内凹弧
+ capPath.AddLine(x, y + h - r, x, y + h);
+ capPath.AddLine(x, y + h, x + r, y + h);
+ capPath.AddArc(x, y + h - r * 2, r * 2, r * 2, 90.0f, 90.0f);
+ capPath.CloseFigure();
+ graphics.FillPath(&dimBrush, &capPath);
+ }
+ }
+}
+
+// 规范化矩形(保证 left= r.left && x < r.right && y >= r.top && y < r.bottom;
+}
+
+// 圆角手柄中心位置:选区四个角内侧,沿各自对角线内移 d = clamp(inset + radius, 0, maxR)。
+// radius 增大时四个手柄同步沿对角线向中心滑动(轨迹始终在该角的对角线上);
+// 半径与手柄位置一一对应,松手后 radius 保持,手柄即停在原地(不回弹到静止位)。
+// inset 为 DPI 缩放后的静止内缩(radius=0 时手柄位置,避开角 resize 手柄);
+// corner 取 RH_CornerRadiusTL/TR/BL/BR。sel 可为绝对坐标(命中测试用)或选区相对坐标(绘制用)。
+
+static void CornerRadiusHandleCenter(const RECT& sel, int inset, int radius, int corner, int& cx, int& cy) {
+ int w = sel.right - sel.left;
+ int h = sel.bottom - sel.top;
+ int maxR = (std::min)(w, h) / 2;
+ if (maxR < 0) maxR = 0;
+ int d = inset + radius;
+ if (d > maxR) d = maxR; // 不越过中心 / 不出选区
+ if (d < 0) d = 0;
+ switch (corner) {
+ case RH_CornerRadiusTL: cx = sel.left + d; cy = sel.top + d; break;
+ case RH_CornerRadiusTR: cx = sel.right - d; cy = sel.top + d; break;
+ case RH_CornerRadiusBL: cx = sel.left + d; cy = sel.bottom - d; break;
+ case RH_CornerRadiusBR: cx = sel.right - d; cy = sel.bottom - d; break;
+ default: cx = sel.left + d; cy = sel.top + d; break;
+ }
+}
+
+// 命中测试圆角手柄(绝对坐标)。依次测试四个角,返回命中的角手柄或 RH_None。
+// 命中框大小沿用 handleSize,与 resize 手柄一致;手柄位置随 radius 移动,故命中也按 radius 计算。
+
+int HitTestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius) {
+ int corners[] = { RH_CornerRadiusTL, RH_CornerRadiusTR, RH_CornerRadiusBL, RH_CornerRadiusBR };
+ for (int c : corners) {
+ int cx, cy;
+ CornerRadiusHandleCenter(sel, inset, radius, c, cx, cy);
+ RECT box = { cx - handleSize, cy - handleSize, cx + handleSize, cy + handleSize };
+ if (PointInRect(x, y, box)) return c;
+ }
+ return RH_None;
+}
+
+// 找出鼠标"靠近"的倒角手柄(绝对坐标):感应区 = 命中框外再扩 proximityMargin。
+// 感应区大于命中区,使手柄在鼠标靠近(尚未进入命中框)时即显现;仅返回最近的那一个角,
+// 选区四角通常互不相邻,足够大时鼠标只会靠近其一。
+
+int FindNearestCornerRadiusHandle(int x, int y, const RECT& sel,
+ int handleSize, int inset, int radius,
+ int proximityMargin) {
+ int corners[] = { RH_CornerRadiusTL, RH_CornerRadiusTR, RH_CornerRadiusBL, RH_CornerRadiusBR };
+ int sense = handleSize + proximityMargin; // 感应半宽 = 命中半宽 + 靠近余量
+ int best = RH_None;
+ int bestDist = 0x7FFFFFFF; // 切比雪夫距离,越小越近
+ for (int c : corners) {
+ int cx, cy;
+ CornerRadiusHandleCenter(sel, inset, radius, c, cx, cy);
+ int dx = x - cx; if (dx < 0) dx = -dx;
+ int dy = y - cy; if (dy < 0) dy = -dy;
+ if (dx <= sense && dy <= sense) {
+ int dist = (dx > dy) ? dx : dy;
+ if (dist < bestDist) { bestDist = dist; best = c; }
+ }
+ }
+ return best;
+}
+
+// 倒角手柄附近脏区(backDC 相对坐标):以手柄中心为基点,扩 handleMargin 覆盖半径+描边/抗锯齿。
+// 用于鼠标靠近/离开手柄时局部重绘,避免全屏刷新。
+
+RECT CornerHandleDirtyRect(const CaptureContext* ctx, int corner) {
+ RECT r = {0, 0, 0, 0};
+ if (!IsCornerRadiusHandle(corner)) return r;
+ int cx, cy;
+ CornerRadiusHandleCenter(ctx->selection, ctx->handleMetrics.cornerKnobInset,
+ ctx->selectionCornerRadius, corner, cx, cy);
+ int m = ctx->handleMetrics.handleMargin;
+ r.left = cx - m - ctx->virtualX;
+ r.top = cy - m - ctx->virtualY;
+ r.right = cx + m + 1 - ctx->virtualX;
+ r.bottom = cy + m + 1 - ctx->virtualY;
+ return r;
+}
+
+// 命中测试调整手柄,返回 ResizeHandle(绝对坐标)。handleSize 为 DPI 缩放后的命中半宽。
+
+int HitTestHandle(int x, int y, const RECT& sel, int handleSize) {
+ int hs = handleSize;
+ int cx = (sel.left + sel.right) / 2;
+ int cy = (sel.top + sel.bottom) / 2;
+ // 8 个手柄的判定矩形(顺序与 ResizeHandle 一致)
+ struct { int hx, hy; int handle; } tests[] = {
+ { sel.left, cy, RH_Left },
+ { sel.right, cy, RH_Right },
+ { cx, sel.top, RH_Top },
+ { cx, sel.bottom, RH_Bottom },
+ { sel.left, sel.top, RH_TopLeft },
+ { sel.right, sel.top, RH_TopRight },
+ { sel.left, sel.bottom, RH_BottomLeft },
+ { sel.right, sel.bottom, RH_BottomRight },
+ };
+ for (auto& t : tests) {
+ RECT box = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
+ if (PointInRect(x, y, box)) return t.handle;
+ }
+ return RH_None;
+}
+
+// 根据手柄返回对应的系统鼠标光标
+
+LPCWSTR HandleCursor(int handle) {
+ switch (handle) {
+ case RH_Left:
+ case RH_Right:
+ return (LPCWSTR)IDC_SIZEWE;
+ case RH_Top:
+ case RH_Bottom:
+ return (LPCWSTR)IDC_SIZENS;
+ case RH_TopLeft:
+ case RH_BottomRight:
+ return (LPCWSTR)IDC_SIZENWSE;
+ case RH_TopRight:
+ case RH_BottomLeft:
+ return (LPCWSTR)IDC_SIZENESW;
+ case RH_ArrowStart:
+ case RH_ArrowEnd:
+ // 箭头端点拖拽:固定四向箭头,与悬停态一致
+ return (LPCWSTR)IDC_SIZEALL;
+ // 圆角手柄:对角线方向拖拽(向内增大半径),按所在角选对应对角光标。
+ case RH_CornerRadiusTL:
+ case RH_CornerRadiusBR:
+ return (LPCWSTR)IDC_SIZENWSE;
+ case RH_CornerRadiusTR:
+ case RH_CornerRadiusBL:
+ return (LPCWSTR)IDC_SIZENESW;
+ default:
+ return (LPCWSTR)IDC_ARROW;
+ }
+}
+
+// 取包含参考矩形的显示器物理边界(MonitorFromRect 最近匹配兜底,绝对坐标)。
+// 多屏异分辨率下工具栏/子菜单的上下翻转判定必须以此为界:整个虚拟屏幕包围盒会被
+// 高分屏拉大,低分屏上选区已触本屏底边仍会被误判为"下方放得下",导致工具栏该
+// 上置时不上置。枚举/获取失败返回 false(调用方回退整虚拟屏幕,单屏时两者等价)。
+
+bool GetMonitorBoundsForRect(const RECT& refAbs, RECT& out) {
+ HMONITOR hmon = MonitorFromRect(&refAbs, MONITOR_DEFAULTTONEAREST);
+ if (!hmon) return false;
+ MONITORINFO mi = {};
+ mi.cbSize = sizeof(mi);
+ if (!GetMonitorInfo(hmon, &mi)) return false;
+ out = mi.rcMonitor;
+ return true;
+}
+
+// 计算工具栏位置:优先选区下方,放不下则上方(上置),上下都放不下则贴近底部。
+// 垂直/水平边界取「选区所在显示器」而非整个虚拟屏幕(多屏异分辨率见
+// GetMonitorBoundsForRect 注释)。selRel/out 均为相对虚拟屏幕坐标。
+
+void CalcToolbarPosition(const RECT& selRel, int virtualX, int virtualY,
+ int virtualW, int virtualH,
+ const SCToolbarMetrics& m, RECT& out) {
+ int tw = CalcToolbarWidth(m);
+ int th = m.h;
+ int margin = m.margin;
+
+ // 选区绝对坐标 -> 所在显示器边界(转回相对坐标参与计算);失败回退整虚拟屏幕
+ RECT selAbs = { selRel.left + virtualX, selRel.top + virtualY,
+ selRel.right + virtualX, selRel.bottom + virtualY };
+ RECT mon = {};
+ int bLeft = 0, bTop = 0, bRight = virtualW, bBottom = virtualH;
+ if (GetMonitorBoundsForRect(selAbs, mon)) {
+ bLeft = mon.left - virtualX; bTop = mon.top - virtualY;
+ bRight = mon.right - virtualX; bBottom = mon.bottom - virtualY;
+ }
+
+ // 默认水平居中于选区,下方
+ int x = selRel.left + ((selRel.right - selRel.left) - tw) / 2;
+ int y = selRel.bottom + margin;
+
+ // 下方放不下 -> 上方(选区触到所在显示器底边即触发)
+ if (y + th > bBottom) {
+ y = selRel.top - margin - th;
+ }
+ // 上方也放不下(选区纵向占满屏幕),贴近底部(选区内底边)
+ if (y < bTop) {
+ y = selRel.bottom - margin - th;
+ if (y < selRel.top) y = selRel.top + margin;
+ // 极端兜底:钳回显示器范围内(选区+工具栏高过屏幕等病态情形)
+ if (y + th > bBottom) y = bBottom - th;
+ if (y < bTop) y = bTop;
+ }
+ // 水平边界约束(同显示器内)
+ if (x + tw > bRight) x = bRight - tw - margin;
+ if (x < bLeft) x = bLeft + margin;
+
+ out.left = x;
+ out.top = y;
+ out.right = x + tw;
+ out.bottom = y + th;
+}
+
+// 命中测试工具栏按钮(相对虚拟屏幕坐标),返回值约定:
+// -1 = 未命中
+// SC_TB_GRIP = 第 0 格「6 点拖拽把手」(按住可拖动工具栏)
+// >=0 = ToolButton 序号(按钮自第 1 格起排布,与 DrawToolbar 同式)
+
+int HitTestToolbar(int x, int y, const RECT& toolbarRect, const SCToolbarMetrics& m) {
+ if (!PointInRect(x, y, toolbarRect)) return -1;
+ int idx = (x - toolbarRect.left - m.border - m.pad) / (m.btn + m.gap);
+ if (idx < 0 || idx > TB_Count) return -1;
+ if (idx == 0) return SC_TB_GRIP; // 第 0 格 = 拖拽把手
+ return idx - 1; // 其后依次为各工具按钮
+}
+
+// 绘制单个工具图标:从 SCIconCache 取预渲染位图,AlphaBlend 居中绘制。
+// cx,cy 为按钮中心;iconSize 为缓存位图边长(物理像素)。
+
+static void DrawToolbarIcon(HDC hdc, int cx, int cy, int btn, bool active,
+ const SCIconCache& icons) {
+ HBITMAP bmp = icons.Get(btn, active);
+ if (!bmp) return;
+
+ int sz = icons.iconSize;
+ int x = cx - sz / 2;
+ int y = cy - sz / 2;
+
+ HDC srcDC = CreateCompatibleDC(hdc);
+ if (!srcDC) return;
+ HGDIOBJ oldBmp = SelectObject(srcDC, bmp);
+
+ BLENDFUNCTION blend;
+ blend.BlendOp = AC_SRC_OVER;
+ blend.BlendFlags = 0;
+ blend.SourceConstantAlpha = 255;
+ blend.AlphaFormat = AC_SRC_ALPHA; // 使用 per-pixel alpha(位图已预乘)
+ AlphaBlend(hdc, x, y, sz, sz, srcDC, 0, 0, sz, sz, blend);
+
+ SelectObject(srcDC, oldBmp);
+ DeleteDC(srcDC);
+}
+
+// 绘制选区调整手柄(8 个),传入相对坐标矩形。handleSize 为 DPI 缩放后的边长。
+
+void DrawResizeHandles(HDC hdc, const RECT& selRel, int handleSize) {
+ int hs = handleSize;
+ int half = hs / 2;
+ int cx = (selRel.left + selRel.right) / 2;
+ int cy = (selRel.top + selRel.bottom) / 2;
+ int pts[][2] = {
+ { selRel.left, cy },
+ { selRel.right, cy },
+ { cx, selRel.top },
+ { cx, selRel.bottom },
+ { selRel.left, selRel.top },
+ { selRel.right, selRel.top },
+ { selRel.left, selRel.bottom },
+ { selRel.right, selRel.bottom },
+ };
+ // GDI+ 抗锯齿绘制:方块边缘像素半透明过渡,消除 GDI Rectangle 的硬锯齿。
+ // 颜色沿用原 GDI 资源:蓝色填充 RGB(0,136,255) + 白色 1px 描边 RGB(255,255,255)。
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::SolidBrush fillBrush(ScOpaqueColor(SC_THEME_ACCENT_BLUE));
+ Gdiplus::Pen borderPen(Gdiplus::Color(255, 255, 255, 255), 1.0f);
+ Gdiplus::GraphicsPath path;
+ for (auto& p : pts) {
+ // hs×hs 匹配原 GDI Rectangle(l,t,r,b) 的 [l,r-1]×[t,b-1] 像素范围(2*half=hs)。
+ path.AddRectangle(Gdiplus::Rect(p[0] - half, p[1] - half, hs, hs));
+ }
+ graphics.FillPath(&fillBrush, &path);
+ graphics.DrawPath(&borderPen, &path);
+}
+
+// 绘制单个倒角拖拽手柄(选区四角内侧其一):白底圆 + 蓝环 + 朝向选区角的四分之一弧点缀。
+// 圆形与方形 resize 手柄区分;GDI+ 抗锯齿。selRel 为选区相对 backDC 坐标。
+// corner 指定画哪一个角(RH_CornerRadiusTL/TR/BL/BR),仅绘制该角手柄(默认隐藏,靠近/拖拽时调用方传入)。
+// 沿对角线内移 d = clamp(inset+radius, 0, maxR):radius 增大时沿对角线向中心滑动,轨迹恒在对角线上;
+// 松手后 radius 保持,手柄停在原地(不回弹到静止位 inset)。
+// handleSize/inset 为 DPI 缩放后值;radius = ctx->selectionCornerRadius。
+
+void DrawCornerRadiusHandle(HDC hdc, const RECT& selRel, int handleSize, int inset, int radius, int corner) {
+ if (!IsCornerRadiusHandle(corner)) return;
+ int half = handleSize / 2;
+ int gr = (std::max)(1, half - 2);
+ int cx, cy;
+ CornerRadiusHandleCenter(selRel, inset, radius, corner, cx, cy);
+ // 弧起始角(GDI+ 顺时针,0°=右/3 点):落在朝向选区角的象限。
+ // TL: 180°→270°(左上象限) TR: 270°→0°(右上象限)
+ // BR: 0°→90°(右下象限) BL: 90°→180°(左下象限)
+ float start = 0.0f;
+ switch (corner) {
+ case RH_CornerRadiusTL: start = 180.0f; break;
+ case RH_CornerRadiusTR: start = 270.0f; break;
+ case RH_CornerRadiusBR: start = 0.0f; break;
+ case RH_CornerRadiusBL: start = 90.0f; break;
+ }
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::SolidBrush fillBrush(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::Pen ringPen(ScOpaqueColor(SC_THEME_ACCENT_BLUE), 1.5f);
+ Gdiplus::Pen glyphPen(ScOpaqueColor(SC_THEME_ACCENT_BLUE), 1.0f);
+ Gdiplus::GraphicsPath path;
+ path.AddEllipse(cx - half, cy - half, half * 2, half * 2);
+ graphics.FillPath(&fillBrush, &path);
+ graphics.DrawPath(&ringPen, &path);
+ graphics.DrawArc(&glyphPen, cx - gr, cy - gr, gr * 2, gr * 2, start, 90.0f);
+}
+
+// 绘制确认态选区边框(细蓝框)。radius<1 为直角(沿用 GDI Rectangle);
+// radius≥1 改用 GDI+ 圆角路径描边,抗锯齿且与 selectionPen(蓝 1px)一致。
+
+void DrawConfirmedBorder(HDC hdc, const RECT& selRel, const SCGdiResources& gdi, int radius) {
+ if (radius < 1) {
+ HGDIOBJ oldPen = SelectObject(hdc, gdi.selectionPen);
+ HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
+ Rectangle(hdc, selRel.left, selRel.top, selRel.right, selRel.bottom);
+ SelectObject(hdc, oldPen);
+ SelectObject(hdc, oldBrush);
+ return;
+ }
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::Pen pen(ScOpaqueColor(SC_THEME_ACCENT_BLUE), 1.0f);
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, selRel.left, selRel.top,
+ selRel.right - selRel.left, selRel.bottom - selRel.top, radius);
+ graphics.DrawPath(&pen, &path);
+}
+
+// 用 GDI+ 圆角矩形路径填充 outPath(抗锯齿绘制的基础)。x,y,w,h 为整数像素矩形,
+// radius 为圆角半径(自动钳制为不超过短边一半,避免重叠畸变)。
+// GDI+ 的 GraphicsPath 不可拷贝,故用 out 参数而非返回值。
+
+void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius) {
+ int r = (std::min)(radius, (std::min)(w, h) / 2);
+ if (r < 1) r = 1;
+ Gdiplus::Rect rect(x, y, w, h);
+ outPath.AddArc(rect.X, rect.Y, r * 2, r * 2, 180, 90);
+ outPath.AddArc(rect.GetRight() - r * 2, rect.Y, r * 2, r * 2, 270, 90);
+ outPath.AddArc(rect.GetRight() - r * 2, rect.GetBottom() - r * 2, r * 2, r * 2, 0, 90);
+ outPath.AddArc(rect.X, rect.GetBottom() - r * 2, r * 2, r * 2, 90, 90);
+ outPath.CloseFigure();
+}
+
+// 绘制工具栏最左「6 点拖拽把手」:2 列 × 3 排共 6 个小圆点,居中于第 0 格单元格。
+// hover(或拖拽中)时铺与按钮一致的浅蓝圆角底提示可拖;可拖拽光标由 OnSetCursor 切换。
+// cell 为第 0 格的按钮区矩形(含上下留白定位,由调用方算好)。
+
+static void DrawToolbarGrip(HDC hdc, const RECT& cellRect, int hoverBtn,
+ const SCToolbarMetrics& m) {
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+
+ bool hot = (hoverBtn == SC_TB_GRIP);
+ if (hot) {
+ Gdiplus::GraphicsPath hlPath;
+ int hlRadius = m.btn / 8;
+ int hlInset = 2;
+ int hlSize = m.btn - hlInset * 2;
+ AddRoundedRect(hlPath, cellRect.left + hlInset, cellRect.top + hlInset,
+ hlSize, hlSize, hlRadius);
+ Gdiplus::SolidBrush hlBrush(ScOpaqueColor(SC_THEME_HOVER_BG));
+ graphics.FillPath(&hlBrush, &hlPath);
+ }
+
+ // 圆点几何随按钮格尺寸(DPI)缩放:列距 ±11%、行距 0/±16%、点半径 ~5%
+ float cx = (cellRect.left + cellRect.right) * 0.5f;
+ float cy = (cellRect.top + cellRect.bottom) * 0.5f;
+ float colGap = m.btn * 0.11f;
+ float rowGap = m.btn * 0.16f;
+ float r = (std::max)(1.2f, m.btn * 0.05f);
+ Gdiplus::SolidBrush dotBrush(Gdiplus::Color(255, 165, 165, 165));
+ for (int row = -1; row <= 1; row++) {
+ for (int col = -1; col <= 1; col += 2) {
+ float dx = cx + col * colGap;
+ float dy = cy + row * rowGap;
+ graphics.FillEllipse(&dotBrush, dx - r, dy - r, r * 2, r * 2);
+ }
+ }
+}
+
+// 几何与图标尺寸均按 metrics(DPI 缩放)计算;图标从 icons 缓存取。
+
+// 绘制悬浮工具栏(白底圆角 + 最左拖拽把手 + 按钮图标 + 分组分隔线)
+
+void DrawToolbar(HDC hdc, const RECT& toolbarRect,
+ int hoverBtn, int activeTool, const SCGdiResources& gdi,
+ const SCToolbarMetrics& m, const SCIconCache& icons) {
+ // 按钮在工具栏高度内的垂直留白
+ int btnPad = (m.h - m.btn) / 2;
+
+ // ---- 第一遍:GDI+ 抗锯齿绘制工具栏圆角背景 + 按钮圆角高亮 ----
+ // GDI 的 RoundRect/FillRect 不支持抗锯齿,圆角边缘有锯齿,故改用 GDI+。
+ // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
+ {
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+
+ int tw = toolbarRect.right - toolbarRect.left;
+ int th = toolbarRect.bottom - toolbarRect.top;
+
+ // 白色圆角背景填充(圆角外保持透明,露出后方截图)
+ Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
+ Gdiplus::GraphicsPath bgPath;
+ AddRoundedRect(bgPath, toolbarRect.left, toolbarRect.top, tw, th, m.radius);
+ graphics.FillPath(&whiteBrush, &bgPath);
+
+ // 1px 浅灰边框
+ Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
+ graphics.DrawPath(&borderPen, &bgPath);
+
+ // 各按钮圆角高亮(hover/active)
+ // 按钮高亮圆角半径:约为按钮边长的 1/8,视觉柔和
+ int hlRadius = m.btn / 8;
+ int hlInset = 2;
+ int hlSize = m.btn - hlInset * 2;
+ for (int i = 0; i < TB_Count; i++) {
+ if (i == TB_Separator1 || i == TB_Separator2) continue;
+ bool hover = (i == hoverBtn);
+ bool active = (i == activeTool);
+ if (!hover && !active) continue;
+
+ // 第 0 格为拖拽把手,各按钮自第 1 格起排布(与 HitTestToolbar 映射一致)
+ int bx = toolbarRect.left + m.border + m.pad + (i + 1) * (m.btn + m.gap);
+ int by = toolbarRect.top + btnPad;
+ // 选中态:主题蓝 #3B8BF2 叠白底 ~15% 的预混合浅蓝(225,237,253)
+ // hover 态:极浅蓝(235,243,255)
+ Gdiplus::Color hlColor = active
+ ? ScOpaqueColor(SC_THEME_SEL_BG)
+ : ScOpaqueColor(SC_THEME_HOVER_BG);
+ Gdiplus::SolidBrush hlBrush(hlColor);
+ Gdiplus::GraphicsPath hlPath;
+ AddRoundedRect(hlPath, bx + hlInset, by + hlInset, hlSize, hlSize, hlRadius);
+ graphics.FillPath(&hlBrush, &hlPath);
+ }
+ }
+
+ // 最左「6 点拖拽把手」:hover 铺浅蓝底 + 灰色圆点(GDI+ 抗锯齿)
+ RECT gripCell = {
+ toolbarRect.left + m.border + m.pad,
+ toolbarRect.top + btnPad,
+ toolbarRect.left + m.border + m.pad + m.btn,
+ toolbarRect.top + btnPad + m.btn
+ };
+ DrawToolbarGrip(hdc, gripCell, hoverBtn, m);
+
+ // ---- 第二遍:GDI 绘制分隔线 + 图标(位图直接 AlphaBlend,无需 AA)----
+ for (int i = 0; i < TB_Count; i++) {
+ int bx = toolbarRect.left + m.border + m.pad + (i + 1) * (m.btn + m.gap);
+ int by = toolbarRect.top + btnPad;
+ RECT btnRect = { bx, by, bx + m.btn, by + m.btn };
+
+ bool isSep = (i == TB_Separator1 || i == TB_Separator2);
+ if (isSep) {
+ // 分隔线
+ int sx = bx + m.btn / 2;
+ int sepInset = m.btn / 8 + 2;
+ HGDIOBJ op = SelectObject(hdc, gdi.toolbarSepPen);
+ MoveToEx(hdc, sx, btnRect.top + sepInset, NULL);
+ LineTo(hdc, sx, btnRect.bottom - sepInset);
+ SelectObject(hdc, op);
+ continue;
+ }
+
+ bool active = (i == activeTool);
+ int cx = bx + m.btn / 2;
+ int cy = by + m.btn / 2;
+ DrawToolbarIcon(hdc, cx, cy, i, active, icons);
+ }
+}
+
+// ==================== 工具栏 title 式 tooltip ====================
+// 网页 title 属性同款交互:图标悬停 ~0.5s 停顿后出现深色圆角小气泡,移开/点击立即消失。
+// 覆盖层窗口不接收稳定鼠标流(无焦点、依赖轮询),停顿判定由会话空闲循环每拍调用
+// TickToolbarTooltip 维护;气泡画进 backDC(OnPaint 末尾经 DrawToolbarTooltip),
+// 显示/隐藏各自失效气泡矩形,与局部刷新管线兼容。视觉与长截图工具栏 tooltip 一致。
+
+static const int SC_TIP_DELAY_MS = 500; // 悬停多久后显示(网页 title 同款节奏)
+
+static const int SC_TIP_GAP = 6; // 气泡与锚点按钮的间距
+
+static const int SC_TIP_PAD_X = 8; // 气泡水平内边距
+
+static const int SC_TIP_PAD_Y = 5; // 气泡垂直内边距
+
+static const int SC_TIP_RADIUS = 4; // 气泡圆角半径
+
+// 各按钮的 tooltip 文案(分隔线返回 nullptr)
+
+static const wchar_t* ToolbarButtonTip(int btn) {
+ switch (btn) {
+ case SC_TB_GRIP: return L"拖动工具栏";
+ case TB_Drag: return L"拖拽";
+ case TB_Rect: return L"矩形";
+ case TB_Circle: return L"圆形";
+ case TB_Arrow: return L"箭头";
+ case TB_Brush: return L"画笔";
+ case TB_Mosaic: return L"马赛克";
+ case TB_Text: return L"文字";
+ case TB_Translate: return L"翻译";
+ case TB_LongCapture: return L"长截图";
+ case TB_Undo: return L"撤销";
+ case TB_Redo: return L"重做";
+ case TB_Save: return L"保存到本地";
+ case TB_Cancel: return L"取消";
+ case TB_Confirm: return L"确定";
+ default: return nullptr;
+ }
+}
+
+// 按文本测量气泡尺寸(屏幕 DC + 同款字体;GDI DrawText 测量即可,绘制走 GDI+)
+
+static SIZE MeasureTipBubbleSize(const wchar_t* text, double ds) {
+ SIZE sz = {0, 0};
+ HDC screen = GetDC(NULL);
+ if (!screen) return sz;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ HFONT fnt = CreateFontW(-(int)(12 * ds + 0.5), 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE,
+ DEFAULT_CHARSET, 0, 0, CLEARTYPE_QUALITY, 0, SC_FONT_FACE);
+ HGDIOBJ oldF = SelectObject(screen, fnt);
+ RECT tr = {0, 0, 0, 0};
+ DrawTextW(screen, text, -1, &tr, DT_CALCRECT | DT_SINGLELINE);
+ SelectObject(screen, oldF);
+ DeleteObject(fnt);
+ ReleaseDC(NULL, screen);
+ sz.cx = (tr.right - tr.left) + sc(SC_TIP_PAD_X) * 2;
+ sz.cy = (tr.bottom - tr.top) + sc(SC_TIP_PAD_Y) * 2;
+ return sz;
+}
+
+// 收起气泡:失效旧矩形(含抗锯齿边缘余量)。tipBtn/tipDwellSince 由调用方维护。
+
+static void HideToolbarTooltip(CaptureContext* ctx, HWND overlayWnd) {
+ if (!ctx->tipShown) return;
+ ctx->tipShown = false;
+ RECT r = InflateRectBy(ctx->tipBubbleRect, 2);
+ InvalidateRect(overlayWnd, &r, FALSE);
+}
+
+// tooltip 停顿轮询(会话空闲循环每拍调用):光标所在工具栏按钮即目标;
+// 目标变化(含离开/按下左键视为无目标)立即收起并重新计时;同一目标停顿满
+// SC_TIP_DELAY_MS 且尚未显示时计算气泡矩形并失效触发绘制。
+
+void TickToolbarTooltip(CaptureContext* ctx, HWND overlayWnd) {
+ if (!ctx || !overlayWnd) return;
+ // 仅在工具栏可见且 hover 有意义的确认态/文字编辑态显示;按下左键期间视为无目标
+ bool toolbarVisible = (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing);
+ int btn = -1;
+ if (toolbarVisible && !(GetAsyncKeyState(VK_LBUTTON) & 0x8000)) {
+ POINT pt;
+ if (GetCursorPos(&pt)) {
+ btn = HitTestToolbar(pt.x - ctx->virtualX, pt.y - ctx->virtualY,
+ ctx->toolbarRect, ctx->toolbarMetrics);
+ }
+ }
+ DWORD now = GetTickCount();
+ if (btn != ctx->tipBtn) {
+ HideToolbarTooltip(ctx, overlayWnd); // 目标切换/离开:收起并重新停顿
+ ctx->tipBtn = btn;
+ ctx->tipDwellSince = now;
+ } else if (btn >= 0 && !ctx->tipShown
+ && now - ctx->tipDwellSince >= (DWORD)SC_TIP_DELAY_MS) {
+ const wchar_t* text = ToolbarButtonTip(btn);
+ if (text && *text) {
+ double ds = ctx->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ const SCToolbarMetrics& m = ctx->toolbarMetrics;
+ // 锚点 = 目标所在单元格矩形(把手占第 0 格、按钮自第 1 格起,与
+ // HitTestToolbar/DrawToolbar 映射一致),backDC 相对坐标
+ int cell = (btn == SC_TB_GRIP) ? 0 : btn + 1;
+ RECT anchor;
+ anchor.left = ctx->toolbarRect.left + m.border + m.pad + cell * (m.btn + m.gap);
+ anchor.top = ctx->toolbarRect.top + (m.h - m.btn) / 2;
+ anchor.right = anchor.left + m.btn;
+ anchor.bottom = anchor.top + m.btn;
+ SIZE sz = MeasureTipBubbleSize(text, ds);
+ int gap = sc(SC_TIP_GAP);
+ int x = (anchor.left + anchor.right) / 2 - sz.cx / 2;
+ if (x < sc(4)) x = sc(4);
+ if (x + sz.cx > ctx->virtualW - sc(4)) x = ctx->virtualW - sc(4) - sz.cx;
+ // 优先上方,放不下转下方(工具栏在选区上方时)
+ int y = anchor.top - gap - sz.cy;
+ if (y < sc(4)) y = anchor.bottom + gap;
+ ctx->tipText = text;
+ ctx->tipBubbleRect = {x, y, x + sz.cx, y + sz.cy};
+ ctx->tipShown = true;
+ InvalidateRect(overlayWnd, &ctx->tipBubbleRect, FALSE);
+ }
+ }
+}
+
+// 绘制当前在屏的气泡(深色圆角底 + 白色居中文本,GDI+ 抗锯齿;
+// OnPaint 在工具栏/子菜单之后调用,保证气泡位于最上层)
+
+void DrawToolbarTooltip(HDC hdc, CaptureContext* ctx) {
+ if (!ctx || !ctx->tipShown || ctx->tipText.empty()) return;
+ double ds = ctx->dpiScale;
+ auto sc = [ds](int v) { return (int)(v * ds + 0.5); };
+ const RECT& rc = ctx->tipBubbleRect;
+ Gdiplus::Graphics graphics(hdc);
+ graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
+ Gdiplus::GraphicsPath path;
+ AddRoundedRect(path, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top,
+ sc(SC_TIP_RADIUS));
+ Gdiplus::SolidBrush bg(Gdiplus::Color(255, 41, 41, 41));
+ graphics.FillPath(&bg, &path);
+ Gdiplus::FontFamily ff(SC_FONT_FACE);
+ Gdiplus::Font fnt(&ff, (Gdiplus::REAL)(12 * ds + 0.5),
+ Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
+ Gdiplus::StringFormat sf;
+ sf.SetAlignment(Gdiplus::StringAlignmentCenter);
+ sf.SetLineAlignment(Gdiplus::StringAlignmentCenter);
+ Gdiplus::RectF layout((Gdiplus::REAL)rc.left, (Gdiplus::REAL)rc.top,
+ (Gdiplus::REAL)(rc.right - rc.left),
+ (Gdiplus::REAL)(rc.bottom - rc.top));
+ Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
+ graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAliasGridFit);
+ graphics.DrawString(ctx->tipText.c_str(), -1, &fnt, layout, &sf, &white);
+}
diff --git a/src/screenshot_windows.h b/src/screenshot/screenshot_windows.h
similarity index 62%
rename from src/screenshot_windows.h
rename to src/screenshot/screenshot_windows.h
index 123d98b..b91c550 100644
--- a/src/screenshot_windows.h
+++ b/src/screenshot/screenshot_windows.h
@@ -7,10 +7,11 @@
Napi::Value StartRegionCapture(const Napi::CallbackInfo& info);
Napi::Value PrimeScreenshotFrame(const Napi::CallbackInfo& info);
Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info);
+// 中止进行中的长截图滚动捕获(在 Init 中注册为 "abortLongCapture" 导出)
+Napi::Value AbortLongCapture(const Napi::CallbackInfo& info);
// 供其他原生模块在截图触发前预抓取首帧。
bool PrimeScreenshotFrameNow();
-// 获取 PNG 编码器 CLSID
-// 实际定义在 binding_windows.cpp(应用图标提取模块也在使用),截图模块复用
+// 获取 PNG 编码器 CLSID(定义在 screenshot/output_windows.cpp,截图模块与 binding 应用图标模块共用)
int GetPngEncoderClsid(CLSID* pClsid);
diff --git a/src/screenshot/session_windows.cpp b/src/screenshot/session_windows.cpp
new file mode 100644
index 0000000..8ac1b6e
--- /dev/null
+++ b/src/screenshot/session_windows.cpp
@@ -0,0 +1,650 @@
+// 截图模块:会话线程、TSFN 桥与 NAPI 入口(公共 API 见 screenshot_windows.h)
+#include "internal.h"
+
+// 全局变量 - 区域截图
+
+HWND g_screenshotOverlayWindow = NULL;
+
+std::atomic g_isCapturing(false);
+
+napi_threadsafe_function g_screenshotTsfn = nullptr;
+
+std::thread g_screenshotThread;
+
+// 自动确认模式:选区完成后直接出图,跳过编辑态(工具栏/标注)。
+// 由 startRegionCaptureWithPrimedFrame 的 options.autoConfirm 设置,
+// 会话开始时拷贝进 CaptureContext 供窗口过程读取。
+std::atomic g_autoConfirm(false);
+
+PrimedScreenshotFrame g_primedScreenshotFrame;
+
+std::mutex g_primedScreenshotFrameMutex;
+
+// 保护 g_longCtx 指针生命周期(声明与协议见 internal.h)
+
+std::mutex g_longCtxMutex;
+
+// 截图上下文指针(窗口过程使用)
+
+CaptureContext* g_captureCtx = nullptr;
+
+// ==================== SCGdiResources 实现(CR-022 自 internal.h 下沉)====================
+// 原为 internal.h 内联定义,唯一调用方在本文件(会话初始化/失败清理/退出清理),
+// 下沉为外部定义以减少头文件内联实现体;签名/语义完全不变。
+
+// 创建基础画刷/画笔/字体 + 预建固定样式 Pen/Brush(会话级缓存)。
+// fontPx:小字号像素;crosshairPx:放大镜准星线宽。
+void SCGdiResources::Init(int fontPx, int crosshairPx) {
+ if (fontPx < 8) fontPx = 8;
+ if (crosshairPx < 1) crosshairPx = 1;
+ smallFontPx = fontPx;
+ crosshairWidth = crosshairPx;
+ bgBrush = CreateSolidBrush(RGB(52, 52, 53));
+ borderPen = CreatePen(PS_SOLID, 0, RGB(102, 102, 102));
+ crosshairPen = CreatePen(PS_SOLID, crosshairWidth, SC_THEME_ACCENT_BLUE);
+ selectionPen = CreatePen(PS_SOLID, 1, SC_THEME_ACCENT_BLUE);
+ highlightPen = CreatePen(PS_SOLID, 3, SC_THEME_ACCENT_BLUE);
+ // 创建字体
+ LOGFONTW lf = {};
+ lf.lfHeight = -smallFontPx;
+ lf.lfCharSet = DEFAULT_CHARSET;
+ wcscpy_s(lf.lfFaceName, L"微软雅黑");
+ smallFont = CreateFontIndirectW(&lf);
+ maskDC = NULL;
+ maskBitmap = NULL;
+ // P2:预建固定样式 Pen/Brush,会话内复用。
+ toolbarSepPen = CreatePen(PS_SOLID, 1, RGB(230, 230, 230));
+ textSelBrush = CreateSolidBrush(RGB(51, 153, 255));
+ annHoverPen = CreatePen(PS_DASH, 1, SC_THEME_ACCENT_BLUE);
+ annTextSelPen = CreatePen(PS_SOLID, 2, SC_THEME_ACCENT_BLUE);
+}
+
+// 创建遮罩缓冲(纯黑位图,配合常量 alpha 实现 40%+ 半透明遮罩)
+// 须在 CaptureContext 虚拟屏幕尺寸确定后调用
+void SCGdiResources::InitMask(int virtualW, int virtualH) {
+ HDC screenDC = GetDC(NULL);
+ if (!screenDC) return;
+ maskDC = CreateCompatibleDC(screenDC);
+ if (maskDC) {
+ maskBitmap = CreateCompatibleBitmap(screenDC, virtualW, virtualH);
+ if (maskBitmap) {
+ SelectObject(maskDC, maskBitmap);
+ // 填充纯黑(AlphaBlend 用常量 alpha,源颜色为黑)
+ RECT rc = { 0, 0, virtualW, virtualH };
+ HBRUSH black = CreateSolidBrush(RGB(0, 0, 0));
+ FillRect(maskDC, &rc, black);
+ DeleteObject(black);
+ } else {
+ DeleteDC(maskDC);
+ maskDC = NULL;
+ }
+ }
+ ReleaseDC(NULL, screenDC);
+}
+
+void SCGdiResources::Cleanup() {
+ if (bgBrush) { DeleteObject(bgBrush); bgBrush = NULL; }
+ if (borderPen) { DeleteObject(borderPen); borderPen = NULL; }
+ if (crosshairPen) { DeleteObject(crosshairPen); crosshairPen = NULL; }
+ if (selectionPen) { DeleteObject(selectionPen); selectionPen = NULL; }
+ if (highlightPen) { DeleteObject(highlightPen); highlightPen = NULL; }
+ if (smallFont) { DeleteObject(smallFont); smallFont = NULL; }
+ if (maskBitmap) { DeleteObject(maskBitmap); maskBitmap = NULL; }
+ if (maskDC) { DeleteDC(maskDC); maskDC = NULL; }
+ // P2:释放缓存的固定样式 Pen/Brush。
+ if (toolbarSepPen) { DeleteObject(toolbarSepPen); toolbarSepPen = NULL; }
+ if (textSelBrush) { DeleteObject(textSelBrush); textSelBrush = NULL; }
+ if (annHoverPen) { DeleteObject(annHoverPen); annHoverPen = NULL; }
+ if (annTextSelPen) { DeleteObject(annTextSelPen); annTextSelPen = NULL; }
+}
+
+// ==================== GDI+ 会话级资源管理(性能优化) ====================
+// 所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内,故可在会话开始 Startup 一次、
+// 结束 Shutdown 一次,避免每个绘制/测量函数反复 Startup/Shutdown(每帧 WM_PAINT 触发 6~10 次)。
+
+// 前置声明:InitGdipResources 在分配失败时复用 Shutdown 清理。
+static void ShutdownGdipResources(CaptureContext* ctx);
+
+// 初始化会话级 GDI+ 资源:Startup + 预建可复用的 FontFamily/StringFormat/Font。
+// 必须在任何 GDI+ 调用(含 InitMosaicBrushCursors)之前调用。
+// 成功返回 true;失败(Startup 失败)返回 false,调用方应中止会话。
+
+bool InitGdipResources(CaptureContext* ctx) {
+ ctx->gdipToken = 0;
+ ctx->gdipInited = false;
+ ctx->gdipFontFamily = nullptr;
+ ctx->gdipStrFmt = nullptr;
+ for (int i = 0; i < SC_FONT_COUNT; i++) ctx->gdipFonts[i] = nullptr;
+
+ if (Gdiplus::GdiplusStartup(&ctx->gdipToken, &ctx->gdipStartupInput, NULL) != Gdiplus::Ok) {
+ return false;
+ }
+ ctx->gdipInited = true;
+
+ // 注意:GDI+ 类继承自 GdiplusBase,其 operator new 不接受 std::nothrow 参数,
+ // 故用普通 new(GDI+ 对象通过 GetLastStatus() 而非空指针报告失败)。
+ ctx->gdipFontFamily = new Gdiplus::FontFamily(SC_FONT_FACE);
+ ctx->gdipStrFmt = new Gdiplus::StringFormat();
+ if (!ctx->gdipFontFamily || !ctx->gdipStrFmt
+ || ctx->gdipFontFamily->GetLastStatus() != Gdiplus::Ok) {
+ ShutdownGdipResources(ctx); return false;
+ }
+ ctx->gdipStrFmt->SetAlignment(Gdiplus::StringAlignmentNear);
+ ctx->gdipStrFmt->SetLineAlignment(Gdiplus::StringAlignmentNear);
+
+ // 按文字字号预设预建 Font(与 SC_FONT_SIZES 一一对应)
+ for (int i = 0; i < SC_FONT_COUNT; i++) {
+ ctx->gdipFonts[i] = new Gdiplus::Font(
+ ctx->gdipFontFamily, (Gdiplus::REAL)SC_FONT_SIZES[i],
+ Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
+ if (!ctx->gdipFonts[i] || ctx->gdipFonts[i]->GetLastStatus() != Gdiplus::Ok) {
+ ShutdownGdipResources(ctx); return false;
+ }
+ }
+ return true;
+}
+
+// 释放会话级 GDI+ 资源:先 delete 所有 GDI+ 对象,再 Shutdown(对象须在 Shutdown 前析构)。
+// 安全可重入:重复调用无副作用。
+
+static void ShutdownGdipResources(CaptureContext* ctx) {
+ // Font 先于 FontFamily 析构(Font 内部引用 FontFamily)
+ for (int i = 0; i < SC_FONT_COUNT; i++) {
+ delete ctx->gdipFonts[i];
+ ctx->gdipFonts[i] = nullptr;
+ }
+ delete ctx->gdipStrFmt;
+ ctx->gdipStrFmt = nullptr;
+ delete ctx->gdipFontFamily;
+ ctx->gdipFontFamily = nullptr;
+
+ if (ctx->gdipInited) {
+ Gdiplus::GdiplusShutdown(ctx->gdipToken);
+ ctx->gdipInited = false;
+ ctx->gdipToken = 0;
+ }
+}
+
+// 取 fontPx 对应的缓存 Font。文字标注字号仅取自 SC_FONT_SIZES,故优先查缓存表;
+// 若 fontPx 不在预设表内(理论上不会发生),用 thread_local 临时对象兜底,避免泄漏。
+// 返回的 Font 所有权归会话/兜底存储,调用方不得 delete。
+
+Gdiplus::Font* GetGdipFont(CaptureContext* ctx, int fontPx) {
+ for (int i = 0; i < SC_FONT_COUNT; i++) {
+ if (SC_FONT_SIZES[i] == fontPx && ctx->gdipFonts[i]) {
+ return ctx->gdipFonts[i];
+ }
+ }
+ // 兜底:thread_local 临时 Font(仅当前线程有效,覆盖极少见的非预设字号)
+ static thread_local Gdiplus::Font* sFallback = nullptr;
+ static thread_local int sFallbackPx = -1;
+ static thread_local Gdiplus::FontFamily* sFallbackFam = nullptr;
+ if (!sFallbackFam) sFallbackFam = new Gdiplus::FontFamily(SC_FONT_FACE);
+ if (!sFallback || sFallbackPx != fontPx) {
+ delete sFallback;
+ sFallbackPx = fontPx;
+ sFallback = new Gdiplus::Font(sFallbackFam, (Gdiplus::REAL)fontPx,
+ Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
+ }
+ return sFallback;
+}
+
+// 在主线程调用 JS 回调(截图完成)
+
+static void CallScreenshotJs(napi_env env, napi_value js_callback, void* context, void* data) {
+ if (env != nullptr && js_callback != nullptr && data != nullptr) {
+ ScreenshotResult* result = static_cast(data);
+
+ napi_value resultObj;
+ napi_create_object(env, &resultObj);
+
+ napi_value success;
+ napi_get_boolean(env, result->success, &success);
+ napi_set_named_property(env, resultObj, "success", success);
+
+ if (result->success) {
+ napi_value x, y, x2, y2, width, height, base64;
+ napi_create_int32(env, result->x, &x);
+ napi_set_named_property(env, resultObj, "x", x);
+ napi_create_int32(env, result->y, &y);
+ napi_set_named_property(env, resultObj, "y", y);
+ napi_create_int32(env, result->x2, &x2);
+ napi_set_named_property(env, resultObj, "x2", x2);
+ napi_create_int32(env, result->y2, &y2);
+ napi_set_named_property(env, resultObj, "y2", y2);
+ napi_create_int32(env, result->width, &width);
+ napi_set_named_property(env, resultObj, "width", width);
+ napi_create_int32(env, result->height, &height);
+ napi_set_named_property(env, resultObj, "height", height);
+ napi_create_string_utf8(env, result->base64.c_str(), result->base64.size(), &base64);
+ napi_set_named_property(env, resultObj, "base64", base64);
+ }
+
+ napi_value global;
+ napi_get_global(env, &global);
+ napi_call_function(env, global, js_callback, 1, &resultObj, nullptr);
+ delete result;
+ }
+}
+
+// 统一 ScreenshotResult 发射口(CR-017):构造结果并经截图会话 TSFN 回传 JS。
+// 统一守卫:TSFN 未就绪或 napi_tsfn_nonblocking 因队列满返回非 napi_ok 时,自行
+// delete 分配的 result 防泄漏(CallScreenshotJs 只在成功入队时才 delete)。
+// 仅允许在截图线程内调用。各发射点(确认/取消/保存/ESC/长截图完成等)统一走此函数。
+// success=false 时坐标/尺寸/base64 全置 0/空(与取消路径语义一致)。
+void EmitScreenshotResult(bool success, int x, int y, int x2, int y2,
+ int width, int height, const std::string& base64) {
+ ScreenshotResult* result = new ScreenshotResult();
+ result->success = success;
+ result->x = x; result->y = y;
+ result->x2 = x2; result->y2 = y2;
+ result->width = width; result->height = height;
+ result->base64 = base64;
+ if (g_screenshotTsfn == nullptr) {
+ delete result;
+ return;
+ }
+ if (napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking) != napi_ok) {
+ // nonblocking 入队失败(队列满等):回调不会取走所有权,自行释放防泄漏。
+ delete result;
+ }
+}
+
+// 会话初始化失败快速回传:构造 {success:false} 结果并经 EmitScreenshotResult 回传 JS,
+// 唤醒 await 方避免永久挂起(CR-002 早退路径统一收口点)。
+void FailFast() {
+ EmitScreenshotResult(false);
+}
+
+// 会话出口统一收口:释放截图会话的 threadsafe function 并置空。
+// 前提(本文件与 wndproc/overlay_input/long_capture 各 Emit 点共同保证):所有
+// napi_call_threadsafe_function 调用端都运行在截图线程内,且都在会话结束前完成;
+// 故整个进程内唯一的释放点就是本函数,各出口汇聚到此处释放一次即可把
+// initial_thread_count 从 1 归 0,不再阻碍 Node 进程优雅退出。置空防止释放后误用。
+static void ReleaseScreenshotTsfn() {
+ if (g_screenshotTsfn != nullptr) {
+ napi_release_threadsafe_function(g_screenshotTsfn, napi_tsfn_release);
+ g_screenshotTsfn = nullptr;
+ }
+}
+
+// 截图线程(预截屏 + 双缓冲架构)
+
+void ScreenshotCaptureThread() {
+ // 设置 DPI 感知
+ typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextProc)(DPI_AWARENESS_CONTEXT);
+ HMODULE user32 = GetModuleHandleW(L"user32.dll");
+ if (user32) {
+ auto setDpiProc = (SetThreadDpiAwarenessContextProc)GetProcAddress(user32, "SetThreadDpiAwarenessContext");
+ if (setDpiProc) {
+ setDpiProc(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
+ }
+ }
+
+ double uiScale = GetDpiScaleFactor();
+ double dpiScale = uiScale;
+
+ // 预截屏整个虚拟屏幕
+ HDC memDC = NULL;
+ HBITMAP screenBitmap = NULL;
+ int vx, vy, vw, vh;
+ if (!AcquireScreenshotBase(memDC, screenBitmap, vx, vy, vw, vh, dpiScale)) {
+ FailFast();
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+ return;
+ }
+
+ // 创建双缓冲
+ HDC backDC = NULL;
+ HBITMAP backBmp = NULL;
+ if (!CreateBackBuffer(backDC, backBmp, vw, vh)) {
+ DeleteDC(memDC);
+ DeleteObject(screenBitmap);
+ FailFast();
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+ return;
+ }
+
+ // 枚举窗口
+ std::vector windows = EnumWindowsForCapture();
+
+ // 初始化 GDI 资源
+ SCPanelMetrics panelMetrics = CalcPanelMetrics(uiScale);
+ SCGdiResources gdi;
+ gdi.Init(panelMetrics.fontPx, panelMetrics.crosshair);
+ // 创建选区外遮罩缓冲(需虚拟屏幕尺寸)
+ gdi.InitMask(vw, vh);
+
+ // 初始化上下文
+ CaptureContext ctx = {};
+ ctx.state = CS_Idle;
+ ctx.autoConfirm = g_autoConfirm.load();
+ ctx.virtualX = vx; ctx.virtualY = vy;
+ ctx.virtualW = vw; ctx.virtualH = vh;
+ ctx.startX = 0; ctx.startY = 0;
+ ctx.endX = 0; ctx.endY = 0;
+ ctx.hoveredWindow = -1;
+ ctx.screenBitmap = screenBitmap;
+ ctx.memDC = memDC;
+ ctx.backDC = backDC;
+ ctx.backBitmap = backBmp;
+ ctx.lastPanelRect = {0,0,0,0};
+ ctx.lastSelectionRect = {0,0,0,0};
+ ctx.lastLabelRect = {0,0,0,0};
+ ctx.lastHighlightRect = {0,0,0,0};
+ ctx.lastToolbarRect = {0,0,0,0};
+ ctx.lastPopupRect = {0,0,0,0};
+ ctx.lastCaretRect = {0,0,0,0};
+ ctx.hasLastCaret = false;
+ ctx.lastAnnotationBox = {0,0,0,0};
+ ctx.hasLastAnnotationBox = false;
+ ctx.lastDrawingBox = {0,0,0,0};
+ ctx.hasLastDrawingBox = false;
+ ctx.selection = {0,0,0,0};
+ ctx.resizeHandle = RH_None;
+ ctx.dragStartX = 0; ctx.dragStartY = 0;
+ ctx.dragStartSelection = {0,0,0,0};
+ ctx.toolbarRect = {0,0,0,0};
+ ctx.hoverToolbarBtn = -1;
+ ctx.activeTool = -1;
+ ctx.popupTool = -1;
+ ctx.needFullRedraw = true;
+ ctx.dpiScale = dpiScale;
+ ctx.gdi = gdi;
+ ctx.panelMetrics = panelMetrics;
+ ctx.windows = std::move(windows);
+
+ // 工具栏几何(按 DPI 缩放)+ 图标位图缓存(按 DPI 预渲染)
+ ctx.toolbarMetrics = CalcToolbarMetrics(uiScale);
+ ctx.handleMetrics = CalcHandleMetrics(uiScale);
+ ctx.iconCache.Init(ctx.toolbarMetrics.iconSize);
+ // GDI+ 会话级初始化(必须在 InitMosaicBrushCursors 及任何 GDI+ 调用之前):
+ // 会话内单次 Startup,避免每帧反复初始化导致拖拽卡顿。
+ if (!InitGdipResources(&ctx)) {
+ gdi.Cleanup();
+ ctx.iconCache.Cleanup();
+ DeleteDC(backDC); DeleteObject(backBmp);
+ DeleteDC(memDC); DeleteObject(screenBitmap);
+ g_captureCtx = nullptr;
+ FailFast();
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+ return;
+ }
+ // 涂抹光标缓存(按半径预生成,DPI 缩放半径)
+ for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) ctx.mosaicBrushCursors[i] = NULL;
+ ctx.mosaicBrushCursorsInited = false;
+ InitMosaicBrushCursors(&ctx);
+ // 子菜单几何(按 DPI 缩放)+ 标注绘制默认值
+ ctx.popupMetrics = CalcPopupMetrics(uiScale);
+ ctx.popupOpen = false;
+ ctx.popupRect = {0,0,0,0};
+ ctx.drawColorIdx = SC_DEFAULT_COLOR_IDX;
+ ctx.drawThickIdx = SC_DEFAULT_THICK_IDX;
+ ctx.fontSizeIdx = SC_DEFAULT_FONT_IDX;
+ ctx.mosaicSizeIdx = SC_DEFAULT_MOSAIC_IDX;
+ ctx.mosaicRadiusIdx = SC_DEFAULT_MOSAIC_RADIUS_IDX;
+ ctx.mosaicRectMode = false; // 默认涂抹模式
+ ctx.mosaicBaseDC = NULL;
+ ctx.mosaicBaseBitmap = NULL;
+ ctx.mosaicBaseW = 0;
+ ctx.mosaicBaseH = 0;
+ ctx.mosaicBaseBlockPx = 0;
+ ctx.mosaicDrawLastIdx = 0;
+ ctx.hasCurDrawing = false;
+ // 文字编辑初始化
+ ctx.textBuf.clear();
+ ctx.textAnchorX = 0;
+ ctx.textAnchorY = 0;
+ ctx.textCaretPos = 0;
+ ctx.textCaretVisible = true;
+ ctx.textCaretLastBlink = GetTickCount();
+ ctx.textSelStart = -1;
+ ctx.textSelEnd = -1;
+ ctx.textDraggingSelection = false;
+ ctx.hoveredTextAnnotation = -1;
+ ctx.selectedTextAnnotation = -1;
+ ctx.draggingTextAnnotation = -1;
+ ctx.textDragStartX = 0;
+ ctx.textDragStartY = 0;
+ // 非文字标注选中/拖拽/缩放状态初始化(与文字机制互斥)
+ ctx.hoveredAnnotation = -1;
+ ctx.selectedAnnotation = -1;
+ ctx.draggingAnnotation = -1;
+ ctx.resizingAnnotation = -1;
+ ctx.annotationResizeHandle = RH_None;
+ ctx.annotationDragStartX = 0;
+ ctx.annotationDragStartY = 0;
+ ctx.annotationOpHistoryPushed = false;
+ ctx.dragStartAnnotation = {};
+ ctx.annotationResizeStartBox = { 0, 0, 0, 0 };
+
+ // 获取初始鼠标位置和颜色
+ POINT pt;
+ GetCursorPos(&pt);
+ ctx.mouseX = pt.x;
+ ctx.mouseY = pt.y;
+ ctx.currentColor = GetPixelColorFromBitmap(memDC, pt.x, pt.y, vx, vy, dpiScale);
+
+ g_captureCtx = &ctx;
+
+ // 注册窗口类
+ WNDCLASSEXW wc = {0};
+ wc.cbSize = sizeof(WNDCLASSEXW);
+ // 必须含 CS_DBLCLKS,否则系统不合成 WM_LBUTTONDBLCLK,
+ // wndproc 中「确认态双击选区 → 确认截图」分支永远不会触发。
+ wc.style |= CS_DBLCLKS;
+ wc.lpfnWndProc = ScreenshotOverlayWndProc;
+ wc.hInstance = GetModuleHandle(NULL);
+ wc.hCursor = LoadCursor(NULL, IDC_ARROW); // 默认鼠标样式
+ wc.lpszClassName = L"ZToolsScreenshotOverlay";
+
+ if (!RegisterClassExW(&wc)) {
+ gdi.Cleanup();
+ ctx.iconCache.Cleanup();
+ DeleteDC(backDC); DeleteObject(backBmp);
+ DeleteDC(memDC); DeleteObject(screenBitmap);
+ g_captureCtx = nullptr;
+ FailFast();
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+ return;
+ }
+
+ // 创建普通 WS_POPUP 窗口(非分层窗口)
+ g_screenshotOverlayWindow = CreateWindowExW(
+ WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
+ L"ZToolsScreenshotOverlay",
+ L"Screenshot Overlay",
+ WS_POPUP,
+ vx, vy, vw, vh,
+ NULL, NULL, GetModuleHandle(NULL), NULL
+ );
+
+ if (g_screenshotOverlayWindow == NULL) {
+ UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL));
+ gdi.Cleanup();
+ ctx.iconCache.Cleanup();
+ DeleteDC(backDC); DeleteObject(backBmp);
+ DeleteDC(memDC); DeleteObject(screenBitmap);
+ g_captureCtx = nullptr;
+ FailFast();
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+ return;
+ }
+
+ ShowWindow(g_screenshotOverlayWindow, SW_SHOW);
+ SetForegroundWindow(g_screenshotOverlayWindow);
+
+ // 消息循环
+ MSG msg;
+ while (true) {
+ if (ctx.state == CS_Done || ctx.state == CS_Cancelled) break;
+
+ if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
+ if (msg.message == WM_QUIT) break;
+ TranslateMessage(&msg);
+ DispatchMessage(&msg);
+ } else {
+ // 工具栏 title 式 tooltip 停顿轮询(悬停 ~0.5s 出气泡;内部按状态门控)
+ TickToolbarTooltip(&ctx, g_screenshotOverlayWindow);
+
+ // 文字编辑态:光标闪烁(每 500ms 切换)
+ if (ctx.state == CS_TextEditing) {
+ DWORD now = GetTickCount();
+ if (now - ctx.textCaretLastBlink >= 500) {
+ ctx.textCaretVisible = !ctx.textCaretVisible;
+ ctx.textCaretLastBlink = now;
+ // 仅刷新光标区域(光标位置不变,只切换可见性)。首次无缓存时全屏。
+ if (ctx.hasLastCaret) {
+ RECT r = InflateRectBy(ctx.lastCaretRect, 2);
+ InvalidateRect(g_screenshotOverlayWindow, &r, FALSE);
+ } else {
+ InvalidateRect(g_screenshotOverlayWindow, NULL, FALSE);
+ }
+ }
+ }
+
+ // 检查 ESC 键(窗口可能没有焦点)
+ if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) {
+ if (ctx.state != CS_Done && ctx.state != CS_Cancelled) {
+ ctx.state = CS_Cancelled;
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放防泄漏。
+ EmitScreenshotResult(false);
+ DestroyWindow(g_screenshotOverlayWindow);
+ break;
+ }
+ }
+ Sleep(1);
+ }
+ }
+
+ // 清理
+ g_captureCtx = nullptr;
+ gdi.Cleanup();
+ ctx.iconCache.Cleanup();
+ FreeMosaicBase(&ctx);
+ FreeMosaicBrushCursors(&ctx);
+ DeleteDC(backDC); DeleteObject(backBmp);
+ DeleteDC(memDC); DeleteObject(screenBitmap);
+ // GDI+ 会话级资源最后释放(所有 GDI+ 调用均已结束后才可 Shutdown)
+ ShutdownGdipResources(&ctx);
+ UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL));
+
+ // 会话出口统一收口(CR-002):正常出口均已在确认/取消分支各回调一次
+ // (ESC 取消、右键取消、确认/保存完成、长截图 RunLongCapture 各自的结果发射)。
+ // 这里兜底:若循环未经任何确认/取消分支退出(如外部投递 WM_QUIT),补发一次
+ // 失败结果,保证 JS 端 await 必然被唤醒;随后释放 TSFN,防止每次 start() 的
+ // 线程计数滞留阻碍 Node 进程优雅退出。g_isCapturing 置 false 必须最后执行,
+ // 使下一次 start() 观察到 false 时旧 TSFN 已必然释放完毕。
+ if (ctx.state != CS_Done && ctx.state != CS_Cancelled) {
+ FailFast();
+ }
+ ReleaseScreenshotTsfn();
+ g_isCapturing = false;
+}
+
+// 启动区域截图
+
+Napi::Value StartRegionCapture(const Napi::CallbackInfo& info) {
+ return StartRegionCaptureWithPrimedFrame(info);
+}
+
+// 供 JS 主动触发首帧预抓取。
+
+Napi::Value PrimeScreenshotFrame(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+ const bool success = PrimeScreenshotFrameNow();
+ return Napi::Boolean::New(env, success);
+}
+
+Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) {
+ Napi::Env env = info.Env();
+
+ if (g_isCapturing) {
+ Napi::Error::New(env, "Screenshot already in progress").ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+
+ // 解析可选参数(顺序无关,便于后续扩展):
+ // - 回调函数:截图完成后回调
+ // - 选项对象:目前支持 { autoConfirm: boolean, longCapture: {...} }
+ // 默认 autoConfirm=true,选区确定后直接出图,跳过编辑态;
+ // 传 false 才进入编辑态(工具栏/标注)
+ // longCapture 为编辑态工具栏「长截图」按钮的手动滚动捕获参数:
+ // { maxFrames?: number, interval?: number }
+ // 每次会话显式解析长截图参数到默认值后再按 JS 覆盖,不再依赖跨会话残留的粘滞全局
+ // (旧实现用文件级全局 g_lcMaxFrames/g_lcInterval 承载,第二次 start 不传 longCapture
+ // 时会沿用上一次的值,属跨会话状态泄漏)。
+ bool autoConfirm = true;
+ int lcMaxFrames = 100; // 默认最大拼接帧数(与 LongCaptureContext.maxFrames 一致)
+ int lcInterval = 250; // 默认滚轮防抖间隔 ms
+ for (int i = 0; i < (int)info.Length(); i++) {
+ if (info[i].IsFunction()) {
+ Napi::Function callback = info[i].As();
+ napi_value resource_name;
+ napi_create_string_utf8(env, "ScreenshotCallback", NAPI_AUTO_LENGTH, &resource_name);
+
+ napi_status status = napi_create_threadsafe_function(
+ env, callback, nullptr, resource_name,
+ 0, 1, nullptr, nullptr, nullptr,
+ CallScreenshotJs, &g_screenshotTsfn
+ );
+
+ if (status != napi_ok) {
+ Napi::Error::New(env, "Failed to create threadsafe function").ThrowAsJavaScriptException();
+ return env.Undefined();
+ }
+ } else if (info[i].IsObject()) {
+ Napi::Object opts = info[i].As();
+ if (opts.Has("autoConfirm")) {
+ Napi::Value v = opts.Get("autoConfirm");
+ if (v.IsBoolean()) {
+ autoConfirm = v.As().Value();
+ }
+ }
+ if (opts.Has("longCapture")) {
+ Napi::Value v = opts.Get("longCapture");
+ if (v.IsObject()) {
+ Napi::Object lc = v.As();
+ if (lc.Has("maxFrames")) {
+ Napi::Value f = lc.Get("maxFrames");
+ if (f.IsNumber()) {
+ int mf = f.As().Int32Value();
+ if (mf >= 1 && mf <= 200) lcMaxFrames = mf;
+ }
+ }
+ if (lc.Has("interval")) {
+ Napi::Value t = lc.Get("interval");
+ if (t.IsNumber()) {
+ int iv = t.As().Int32Value();
+ if (iv >= 50 && iv <= 2000) lcInterval = iv;
+ }
+ }
+ }
+ }
+ }
+ }
+ g_autoConfirm = autoConfirm;
+ // 每次会话重置为本次解析值(默认值或 JS 覆盖),消除跨会话粘滞
+ g_lcMaxFrames = lcMaxFrames;
+ g_lcInterval = lcInterval;
+
+ g_isCapturing = true;
+
+ g_screenshotThread = std::thread(ScreenshotCaptureThread);
+ g_screenshotThread.detach();
+
+ return env.Undefined();
+}
+
+// 中止进行中的长截图滚动捕获(注册为 "abortLongCapture" 导出)。
+// 仅设置中止标记:由滚动循环在下一帧检查点退出,并按失败结果回调 JS。
+
+Napi::Value AbortLongCapture(const Napi::CallbackInfo& info) {
+ LongCaptureAbort();
+ return info.Env().Undefined();
+}
diff --git a/src/screenshot/wndproc_windows.cpp b/src/screenshot/wndproc_windows.cpp
new file mode 100644
index 0000000..7003aba
--- /dev/null
+++ b/src/screenshot/wndproc_windows.cpp
@@ -0,0 +1,287 @@
+// 截图模块:覆盖层窗口过程与选区状态机
+#include "internal.h"
+#include "long_capture_internal.h"
+
+// 钳制选区圆角半径到合理范围:[0, min(w,h)/2]。
+// 选区尺寸变化(确认/调整/移动)后调用,避免半径越界导致手柄命中与渲染不一致。
+
+void ClampCornerRadius(CaptureContext* ctx) {
+ int w = ctx->selection.right - ctx->selection.left;
+ int h = ctx->selection.bottom - ctx->selection.top;
+ int maxR = (std::min)(w, h) / 2;
+ if (maxR < 0) maxR = 0;
+ if (ctx->selectionCornerRadius > maxR) ctx->selectionCornerRadius = maxR;
+ if (ctx->selectionCornerRadius < 0) ctx->selectionCornerRadius = 0;
+}
+
+// 进入确认态:规范化选区并切换状态
+
+void EnterConfirmed(CaptureContext* ctx, const RECT& sel) {
+ RECT n = NormalizeRect(sel);
+ // 约束到虚拟屏幕内
+ if (n.left < ctx->virtualX) { n.right += ctx->virtualX - n.left; n.left = ctx->virtualX; }
+ if (n.top < ctx->virtualY) { n.bottom += ctx->virtualY - n.top; n.top = ctx->virtualY; }
+ if (n.right > ctx->virtualX + ctx->virtualW) n.right = ctx->virtualX + ctx->virtualW;
+ if (n.bottom > ctx->virtualY + ctx->virtualH) n.bottom = ctx->virtualY + ctx->virtualH;
+ // 最小尺寸保护
+ if (n.right - n.left < SC_MIN_SELECTION) n.right = n.left + SC_MIN_SELECTION;
+ if (n.bottom - n.top < SC_MIN_SELECTION) n.bottom = n.top + SC_MIN_SELECTION;
+ ctx->selection = n;
+ ctx->resizeHandle = RH_None;
+ ctx->hoveredCornerHandle = RH_None; // 进入确认态先置无,由后续 MOUSEMOVE 重新靠近探测
+ ClampCornerRadius(ctx);
+ if (ctx->activeTool < 0) ctx->activeTool = TB_Drag;
+ if (ctx->popupTool < 0) ctx->popupTool = -1;
+ ctx->state = CS_Confirmed;
+ ctx->needFullRedraw = true;
+}
+
+// 约束单轴 resize 的活动端坐标,固定端始终保持按下时的位置。
+// rawActive/originalActive 分别是当前候选值和按下时的活动端;screenMin/screenMax 是
+// 虚拟屏幕在该轴上的绝对边界。已有标注时优先保证内容不被裁掉;松开时可按活动端
+// 当前所在侧补足最小尺寸,整个过程只移动活动端,因此穿越后不会带动固定端漂移。
+
+static int ConstrainResizeActiveCoordinate(int rawActive, int originalActive, int fixed,
+ int screenMin, int screenMax,
+ bool hasContent, int contentMin, int contentMax,
+ bool enforceMinSize) {
+ int active = (std::max)(screenMin, (std::min)(rawActive, screenMax));
+
+ if (hasContent) {
+ if (fixed <= contentMin) {
+ // 内容位于固定端高侧:活动端必须覆盖内容高边,不能穿越后把内容留在选区外。
+ active = (std::max)(active, contentMax);
+ } else if (fixed >= contentMax) {
+ // 内容位于固定端低侧:活动端必须覆盖内容低边。
+ active = (std::min)(active, contentMin);
+ } else {
+ // 固定端落在内容内部时不存在可完整覆盖内容的单侧区间,保留按下时活动端。
+ active = originalActive;
+ }
+ active = (std::max)(screenMin, (std::min)(active, screenMax));
+ }
+
+ if (enforceMinSize) {
+ // 活动端恰好落在固定端时沿按下时的方向补足,避免释放后方向不确定。
+ bool onLowSide = active < fixed || (active == fixed && originalActive < fixed);
+ if (onLowSide) {
+ active = (std::min)(active, fixed - SC_MIN_SELECTION);
+ } else {
+ active = (std::max)(active, fixed + SC_MIN_SELECTION);
+ }
+ // 固定端靠近虚拟屏幕边缘时,目标侧可能不足最小尺寸;此时边界优先且固定端不动。
+ active = (std::max)(screenMin, (std::min)(active, screenMax));
+ }
+
+ return active;
+}
+
+// 从鼠标按下时的选区快照计算本帧 resize 结果(绝对虚拟屏幕坐标)。
+// 每个活动轴都只更新对应手柄端,固定边/固定对角点始终取 startSelection;活动端可穿过
+// 固定端,最后仅为绘制和导出调用一次 NormalizeRect。contentBounds 在 hasContent=true 时
+// 限制活动端以保留已有标注;enforceMinSize 仅用于松开时沿最终方向补足最小尺寸。
+
+RECT ResizeSelectionFromHandle(const RECT& startSelection, int handle, int dx, int dy,
+ const RECT& virtualBounds,
+ bool hasContent, const RECT& contentBounds,
+ bool enforceMinSize) {
+ RECT resized = startSelection;
+
+ bool movesLeft = handle == RH_Left || handle == RH_TopLeft || handle == RH_BottomLeft;
+ bool movesRight = handle == RH_Right || handle == RH_TopRight || handle == RH_BottomRight;
+ bool movesTop = handle == RH_Top || handle == RH_TopLeft || handle == RH_TopRight;
+ bool movesBottom = handle == RH_Bottom || handle == RH_BottomLeft || handle == RH_BottomRight;
+
+ if (movesLeft) {
+ resized.left = ConstrainResizeActiveCoordinate(
+ startSelection.left + dx, startSelection.left, startSelection.right,
+ virtualBounds.left, virtualBounds.right,
+ hasContent, contentBounds.left, contentBounds.right, enforceMinSize);
+ } else if (movesRight) {
+ resized.right = ConstrainResizeActiveCoordinate(
+ startSelection.right + dx, startSelection.right, startSelection.left,
+ virtualBounds.left, virtualBounds.right,
+ hasContent, contentBounds.left, contentBounds.right, enforceMinSize);
+ }
+
+ if (movesTop) {
+ resized.top = ConstrainResizeActiveCoordinate(
+ startSelection.top + dy, startSelection.top, startSelection.bottom,
+ virtualBounds.top, virtualBounds.bottom,
+ hasContent, contentBounds.top, contentBounds.bottom, enforceMinSize);
+ } else if (movesBottom) {
+ resized.bottom = ConstrainResizeActiveCoordinate(
+ startSelection.bottom + dy, startSelection.bottom, startSelection.top,
+ virtualBounds.top, virtualBounds.bottom,
+ hasContent, contentBounds.top, contentBounds.bottom, enforceMinSize);
+ }
+
+ return NormalizeRect(resized);
+}
+
+// 取调整手柄在选区上的锚点(绝对虚拟屏幕坐标),作为放大镜焦点。
+// 锚点 = 手柄所在边/角的端点:左右手柄取边中点,顶/底取中点,四角取角点。
+
+void GetResizeHandleAnchor(int handle, const RECT& sel, int& ax, int& ay) {
+ int cx = (sel.left + sel.right) / 2, cy = (sel.top + sel.bottom) / 2;
+ switch (handle) {
+ case RH_Left: ax = sel.left; ay = cy; break;
+ case RH_Right: ax = sel.right; ay = cy; break;
+ case RH_Top: ax = cx; ay = sel.top; break;
+ case RH_Bottom: ax = cx; ay = sel.bottom; break;
+ case RH_TopLeft: ax = sel.left; ay = sel.top; break;
+ case RH_TopRight: ax = sel.right; ay = sel.top; break;
+ case RH_BottomLeft: ax = sel.left; ay = sel.bottom; break;
+ case RH_BottomRight: ax = sel.right; ay = sel.bottom; break;
+ default: ax = cx; ay = cy; break;
+ }
+}
+
+// 从按下快照 + (鼠标位移 + 键盘微调) 实时重算选区,并刷新放大镜焦点像素色。
+// MOUSEMOVE 与 KEYDOWN 共用,保证两者一致。拖拽过程不强制最小尺寸(释放时再补)。
+
+void ApplyResizeSelection(HWND hwnd, CaptureContext* ctx) {
+ const RECT& start = ctx->dragStartSelection;
+ int dx = (ctx->mouseX - ctx->dragStartX) + ctx->kbDX;
+ int dy = (ctx->mouseY - ctx->dragStartY) + ctx->kbDY;
+ RECT vb = { ctx->virtualX, ctx->virtualY,
+ ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
+ RECT cb = {0, 0, 0, 0};
+ bool hasContent = CalcAnnotationsBounds(ctx->annotations, cb, ctx->backDC);
+ ctx->selection = ResizeSelectionFromHandle(start, ctx->resizeHandle, dx, dy,
+ vb, hasContent, cb, false);
+ // 放大镜焦点取活动手柄锚点(随活动边移动,键盘微调时鼠标不动也能跟随)
+ int ax, ay;
+ GetResizeHandleAnchor(ctx->resizeHandle, ctx->selection, ax, ay);
+ ctx->currentColor = GetPixelColorFromBitmap(ctx->memDC, ax, ay,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale);
+ InvalidateRect(hwnd, NULL, FALSE);
+}
+
+// 方向键微调选区:CS_Resizing 微调活动边(累加 kbDX/kbDY);CS_Confirmed 整体平移 1px。
+// Shift 加速到 10px。返回是否已处理(已处理则调用方 return 0)。
+
+bool HandleSelectionNudgeKey(HWND hwnd, CaptureContext* ctx, WPARAM vk) {
+ int step = (GetKeyState(VK_SHIFT) & 0x8000) ? 10 : 1;
+ int ddx = 0, ddy = 0;
+ switch (vk) {
+ case VK_LEFT: ddx = -step; break;
+ case VK_RIGHT: ddx = step; break;
+ case VK_UP: ddy = -step; break;
+ case VK_DOWN: ddy = step; break;
+ default: return false;
+ }
+ if (ctx->state == CS_Resizing && !IsCornerRadiusHandle(ctx->resizeHandle)) {
+ ctx->kbDX += ddx;
+ ctx->kbDY += ddy;
+ ApplyResizeSelection(hwnd, ctx);
+ return true;
+ }
+ if (ctx->state == CS_Confirmed && !ctx->popupOpen) {
+ RECT& s = ctx->selection;
+ int w = s.right - s.left, h = s.bottom - s.top;
+ int nl = s.left + ddx, nt = s.top + ddy;
+ if (nl < ctx->virtualX) nl = ctx->virtualX;
+ if (nt < ctx->virtualY) nt = ctx->virtualY;
+ if (nl + w > ctx->virtualX + ctx->virtualW) nl = ctx->virtualX + ctx->virtualW - w;
+ if (nt + h > ctx->virtualY + ctx->virtualH) nt = ctx->virtualY + ctx->virtualH - h;
+ s.left = nl; s.right = nl + w; s.top = nt; s.bottom = nt + h;
+ ctx->needFullRedraw = true;
+ InvalidateRect(hwnd, NULL, FALSE);
+ return true;
+ }
+ return false;
+}
+
+// 截图覆盖层窗口过程(双缓冲渲染)
+
+LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
+ CaptureContext* ctx = g_captureCtx;
+ if (!ctx) return DefWindowProc(hwnd, msg, wParam, lParam);
+
+ switch (msg) {
+ case WM_LONGCAPTURE_RUN: {
+ // BeginLongCapture 已切到 CS_LongCapturing(黑色遮罩 + 选区框 + 底部工具栏),
+ // 此处执行手动滚动捕获主循环。RunLongCapture 内部自行泵消息(面板可响应),
+ // 结束后统一清理会话(销毁小地图面板、释放上下文、结束截图会话)。
+ LongCaptureContext* lc = g_longCtx.load();
+ if (lc) {
+ RunLongCapture(lc);
+ // 清理:释放采样 DIB 段(dibDC/dibBmp 跨界所有权收进 DestroyLongCaptureContext,
+ // CR-021)、销毁灰蒙版/小地图面板、释放上下文、结束截图会话
+ DestroyLongCaptureContext(lc);
+ if (g_longMaskWindow) {
+ DestroyWindow(g_longMaskWindow);
+ g_longMaskWindow = NULL;
+ }
+ if (g_longToolbarWindow) {
+ DestroyWindow(g_longToolbarWindow);
+ g_longToolbarWindow = NULL;
+ }
+ if (g_longControlWindow) {
+ DestroyWindow(g_longControlWindow);
+ g_longControlWindow = NULL;
+ }
+ {
+ // 释放指针与销毁对象必须在同一临界区内完成(协议见 internal.h):
+ // JS 线程 LongCaptureAbort 可能刚 load 到 lc 还没写 abortFlag,
+ // 若先释放锁再 delete 会复现 use-after-free;锁内只有指针写与
+ // 内存释放,不触碰窗口消息 API。
+ std::lock_guard lock(g_longCtxMutex);
+ g_longCtx.store(nullptr);
+ delete lc;
+ }
+ }
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ return 0;
+ }
+ case WM_PAINT: return OnPaint(hwnd, ctx);
+
+ case WM_LBUTTONDOWN: return OnLButtonDown(hwnd, ctx);
+
+ case WM_MOUSEMOVE: return OnMouseMove(hwnd, ctx);
+
+ case WM_LBUTTONUP: return OnLButtonUp(hwnd, ctx);
+
+ case WM_LBUTTONDBLCLK: {
+ // 确认态下双击选区内部 -> 确认截图
+ if ((ctx->state == CS_Confirmed) && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
+ ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
+ ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius,
+ ctx->mosaicSizeIdx);
+ // 统一走 EmitScreenshotResult:TSFN 空 / nonblocking 失败均自动释放 result 防泄漏。
+ EmitScreenshotResult(result->success, result->x, result->y, result->x2, result->y2,
+ result->width, result->height, result->base64);
+ delete result;
+ ctx->state = CS_Done;
+ DestroyWindow(hwnd);
+ }
+ return 0;
+ }
+
+ case WM_RBUTTONDOWN: {
+ ctx->state = CS_Cancelled;
+ // 回调失败结果(统一走 EmitScreenshotResult)
+ EmitScreenshotResult(false);
+ DestroyWindow(hwnd);
+ return 0;
+ }
+
+ case WM_KEYDOWN: return OnKeyDown(hwnd, wParam, ctx);
+
+ case WM_IME_COMPOSITION: return OnImeComposition(hwnd, msg, wParam, lParam, ctx);
+
+ case WM_CHAR: return OnChar(hwnd, wParam, ctx);
+
+ case WM_SETCURSOR: return OnSetCursor(hwnd, msg, wParam, lParam, ctx);
+
+ case WM_DESTROY: {
+ g_screenshotOverlayWindow = NULL;
+ PostQuitMessage(0);
+ return 0;
+ }
+ }
+
+ return DefWindowProc(hwnd, msg, wParam, lParam);
+}
diff --git a/src/screenshot_windows.cpp b/src/screenshot_windows.cpp
deleted file mode 100644
index 49cfcde..0000000
--- a/src/screenshot_windows.cpp
+++ /dev/null
@@ -1,6809 +0,0 @@
-#include
-#include
-#include
-#include // For GET_X_LPARAM, GET_Y_LPARAM
-#include
-#include // For IME (Input Method Editor) support
-#include // For GetSaveFileNameW(保存对话框)
-#include // For SHGetKnownFolderPath(已知文件夹路径,如图片库)
-#include
-#include
-#include // For std::min, std::max
-#include
-#include
-#include // For std::sqrt, std::fabs
-#include
-#include
-
-// DWMWA_CLOAKED 在较新的 Windows SDK 中定义,为了兼容性手动定义
-#ifndef DWMWA_CLOAKED
-#define DWMWA_CLOAKED 14
-#endif
-
-// GDI+ 需要 min/max
-namespace Gdiplus {
- using std::min;
- using std::max;
-}
-#include
-
-// AlphaBlend 需要 msimg32
-#pragma comment(lib, "msimg32.lib")
-
-#include "screenshot_windows.h"
-
-// ---- nanosvg:SVG 光栅化(单文件库,宏实例化)----
-// 两个 .h 必须在同一编译单元用宏实例化一次;这里在 screenshot_windows.cpp 内实例化。
-#define NANOSVG_IMPLEMENTATION
-#include "third_party/nanosvg.h"
-#define NANOSVGRAST_IMPLEMENTATION
-#include "third_party/nanosvgrast.h"
-
-// ---- 截图工具栏图标 SVG 文本(构建期由 scripts/gen-icons.js 从 src/assets 生成)----
-#include "generated/icon_svgs.h"
-
-// 全局变量 - 区域截图
-static HWND g_screenshotOverlayWindow = NULL;
-static std::atomic g_isCapturing(false);
-static napi_threadsafe_function g_screenshotTsfn = nullptr;
-static std::thread g_screenshotThread;
-// 自动确认模式:选区完成后直接出图,跳过编辑态(工具栏/标注)。
-// 由 startRegionCaptureWithPrimedFrame 的 options.autoConfirm 设置,
-// 会话开始时拷贝进 CaptureContext 供窗口过程读取。
-static std::atomic g_autoConfirm(false);
-static const auto SC_PRIMED_FRAME_TTL = std::chrono::seconds(2);
-
-struct PrimedScreenshotFrame {
- HBITMAP bitmap = NULL;
- int vx = 0;
- int vy = 0;
- int vw = 0;
- int vh = 0;
- double dpiScale = 1.0;
- std::chrono::steady_clock::time_point capturedAt{};
- bool valid = false;
-};
-
-static PrimedScreenshotFrame g_primedScreenshotFrame;
-static std::mutex g_primedScreenshotFrameMutex;
-
-static void ReleasePrimedScreenshotFrameLocked() {
- if (g_primedScreenshotFrame.bitmap) {
- DeleteObject(g_primedScreenshotFrame.bitmap);
- g_primedScreenshotFrame.bitmap = NULL;
- }
- g_primedScreenshotFrame.vx = 0;
- g_primedScreenshotFrame.vy = 0;
- g_primedScreenshotFrame.vw = 0;
- g_primedScreenshotFrame.vh = 0;
- g_primedScreenshotFrame.dpiScale = 1.0;
- g_primedScreenshotFrame.capturedAt = std::chrono::steady_clock::time_point{};
- g_primedScreenshotFrame.valid = false;
-}
-
-// ==================== 区域截图功能(预截屏 + 双缓冲架构) ====================
-
-// 截图常量
-static const int SC_PANEL_WIDTH = 140;
-static const int SC_PANEL_HEIGHT = 140;
-static const int SC_MAGNIFIER_HEIGHT = 74;
-static const int SC_PANEL_MARGIN = 15;
-static const int SC_PANEL_CORNER_RADIUS = 8;
-static const int SC_ZOOM_FACTOR = 4;
-
-// 选区外遮罩:微信风格,选区内部保持清晰,外部覆盖半透明黑色
-// 取值 0~255,数值越大越暗(0 = 无遮罩,255 = 全黑)
-static const BYTE SC_MASK_ALPHA = 120;
-
-// 截图状态枚举
-enum CaptureState {
- CS_Idle, // 等待选择(hover 窗口/拖拽开始)
- CS_Selecting, // 正在拖拽框选
- CS_Confirmed, // 已确认选区,可调整/拖动/打开工具栏
- CS_Resizing, // 正在拖拽手柄调整选区
- CS_Moving, // 正在整体拖动选区
- CS_Drawing, // 正在绘制标注(矩形/圆/箭头/画笔)
- CS_TextEditing, // 正在输入文字
- CS_Done,
- CS_Cancelled
-};
-
-// 选区调整手柄(8 个方向)
-enum ResizeHandle {
- RH_None = -1,
- RH_Left = 0,
- RH_Right = 1,
- RH_Top = 2,
- RH_Bottom = 3,
- RH_TopLeft = 4,
- RH_TopRight = 5,
- RH_BottomLeft = 6,
- RH_BottomRight = 7,
- RH_ArrowStart = 8, // 箭头起点端点手柄(仅箭头用,拖动改起点)
- RH_ArrowEnd = 9, // 箭头终点端点手柄(仅箭头用,拖动改终点)
- RH_CornerRadiusTL = 10, // 选区左上角内倒角手柄(拖动改选区圆角半径,不改变选区矩形)
- RH_CornerRadiusTR = 11, // 选区右上角内倒角手柄
- RH_CornerRadiusBL = 12, // 选区左下角内倒角手柄
- RH_CornerRadiusBR = 13 // 选区右下角内倒角手柄
-};
-
-// 是否为圆角(内倒角)手柄:四个角共用同一套拖拽逻辑(改 selectionCornerRadius)
-static bool IsCornerRadiusHandle(int h) {
- return h == RH_CornerRadiusTL || h == RH_CornerRadiusTR ||
- h == RH_CornerRadiusBL || h == RH_CornerRadiusBR;
-}
-
-// 工具栏按钮
-enum ToolButton {
- TB_Drag = 0, // 拖拽
- TB_Rect, // 矩形
- TB_Circle, // 圆形(含椭圆)
- TB_Arrow, // 箭头
- TB_Brush, // 画笔
- TB_Mosaic, // 马赛克
- TB_Text, // 文字
- TB_Translate, // 翻译
- TB_Separator1, // 分隔线
- TB_Undo, // 撤销
- TB_Redo, // 重做
- TB_Separator2, // 分隔线
- TB_Save, // 保存到本地
- TB_Cancel, // 取消
- TB_Confirm, // 确定
- TB_Count
-};
-
-// ==================== 标注绘制(矩形/圆/箭头/画笔) ====================
-// 所有标注统一用「选区相对逻辑坐标」存储(相对 selection.left/top 的偏移):
-// - 实时渲染时:backDC 局部坐标 = curSelRect.left + relX
-// - 合成进 PNG 时:finalDC 原点正好是选区原点,标注直接画在 (relX, relY)
-// - 移动选区时:relX/relY 不变,标注自动跟随,无需额外换算
-enum AnnotationType {
- AT_Rect,
- AT_Circle,
- AT_Arrow,
- AT_Brush,
- AT_Text,
- AT_Mosaic // 马赛克(框选区域 或 鼠标涂抹)
-};
-
-struct Annotation {
- AnnotationType type;
- COLORREF color;
- int thickness; // 逻辑像素(矢量=线宽;文字=字号)
- // 绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同坐标系)。
- // 用绝对坐标而非选区相对,保证选区缩放/移动时标注位置固定不动。
- int x1, y1, x2, y2; // Rect / Circle / Arrow 的起止(绝对坐标);AT_Text 的 x1/y1 为文字锚点;
- // AT_Mosaic 框选模式的矩形起止(绝对坐标)
- std::vector pts; // Brush 自由路径(绝对坐标);AT_Mosaic 涂抹模式的路径(绝对坐标)
- std::wstring text; // AT_Text 的文字内容
- // ---- AT_Mosaic 专用 ----
- bool mosaicRect; // true=框选区域马赛克;false=鼠标涂抹马赛克
- int mosaicSize; // 马赛克块大小(逻辑像素)
- int brushRadius; // 涂抹半径(逻辑像素,仅涂抹模式有效)
-
- // ---- 文字测量缓存(仅 AT_Text 有效)----
- // 缓存"相对锚点的字形偏移与尺寸"(与 GDI+ MeasureString 同源)。
- // 有效性条件 = (text, fontPx) 未变;锚点(x1,y1)变化不影响缓存值(外部加偏移即可),
- // 故 TransformAnnotationByBox 的 AT_Text 分支(仅平移锚点)无需失效缓存。
- // textCacheValid=false 表示未计算或已失效,下次 MeasureTextAnnotation 会重算并回填。
- bool textCacheValid;
- int textCacheFontPx; // 生成缓存时的 fontPx(= thickness),用于校验
- float textCacheOffX, textCacheOffY; // 字形左上角相对锚点的偏移
- float textCacheW, textCacheH; // 字形紧凑宽高
-};
-
-// 粗细预设(逻辑像素,实际绘制粗细,渲染时乘 dpiScale)
-static const int SC_THICK_PRESETS[] = { 1, 2, 4 };
-static const int SC_THICK_COUNT = sizeof(SC_THICK_PRESETS) / sizeof(SC_THICK_PRESETS[0]);
-static const int SC_DEFAULT_THICK_IDX = 1; // 默认中粗
-// 子菜单圆点预览直径(逻辑像素,仅用于界面显示,与实际绘制粗细解耦)
-static const int SC_THICK_DOT_SIZES[] = { 5, 10, 16 };
-static const int SC_THICK_DOT_COUNT = sizeof(SC_THICK_DOT_SIZES) / sizeof(SC_THICK_DOT_SIZES[0]);
-
-// 文字字号预设(逻辑像素),文字工具激活时子菜单第一组显示
-static const int SC_FONT_SIZES[] = { 16, 24, 36 };
-static const int SC_FONT_COUNT = sizeof(SC_FONT_SIZES) / sizeof(SC_FONT_SIZES[0]);
-static const int SC_DEFAULT_FONT_IDX = 1; // 默认中号
-static const wchar_t* SC_FONT_FACE = L"微软雅黑";
-
-// 马赛克块大小预设(逻辑像素),马赛克工具子菜单显示
-static const int SC_MOSAIC_SIZES[] = { 6, 10, 16 };
-static const int SC_MOSAIC_COUNT = sizeof(SC_MOSAIC_SIZES) / sizeof(SC_MOSAIC_SIZES[0]);
-static const int SC_DEFAULT_MOSAIC_IDX = 1; // 默认中等块
-// 涂抹半径预设(逻辑像素),马赛克涂抹模式使用,与画笔粗细预设共用同一组子菜单第二组无效,
-// 这里单独定义便于扩展。半径越大涂抹范围越宽。
-static const int SC_MOSAIC_RADIUS[] = { 12, 22, 36 };
-static const int SC_MOSAIC_RADIUS_COUNT = sizeof(SC_MOSAIC_RADIUS) / sizeof(SC_MOSAIC_RADIUS[0]);
-static const int SC_DEFAULT_MOSAIC_RADIUS_IDX = 1; // 默认中等半径
-
-// 颜色预设
-static const COLORREF SC_COLOR_PRESETS[] = {
- RGB(0xE5, 0x39, 0x35), // 红
- RGB(0xFB, 0x8C, 0x00), // 橙
- RGB(0xFD, 0xD8, 0x35), // 黄
- RGB(0x43, 0xA0, 0x47), // 绿
- RGB(0x00, 0xAC, 0xC1), // 青
- RGB(0x1E, 0x88, 0xE5), // 蓝
- RGB(0xFF, 0xFF, 0xFF), // 白
- RGB(0x33, 0x33, 0x33), // 黑
-};
-static const int SC_COLOR_COUNT = sizeof(SC_COLOR_PRESETS) / sizeof(SC_COLOR_PRESETS[0]);
-static const int SC_DEFAULT_COLOR_IDX = 0; // 默认红
-
-// 判断某工具按钮是否为可绘制矢量工具
-static bool IsVectorTool(int btn) {
- return btn == TB_Rect || btn == TB_Circle || btn == TB_Arrow || btn == TB_Brush;
-}
-
-static bool IsDragTool(int btn) {
- return btn == TB_Drag;
-}
-
-static bool CanShowStylePopupTool(int btn) {
- return IsVectorTool(btn) || btn == TB_Text;
-}
-
-// ToolButton -> AnnotationType
-static AnnotationType ToolToAnnotationType(int btn) {
- switch (btn) {
- case TB_Rect: return AT_Rect;
- case TB_Circle: return AT_Circle;
- case TB_Arrow: return AT_Arrow;
- case TB_Brush: return AT_Brush;
- default: return AT_Rect;
- }
-}
-
-// AnnotationType -> ToolButton(ToolToAnnotationType 的逆映射)。
-// 用途:选中已有标注时,工具栏回显该标注对应的工具按钮(高亮 + 打开粗细/颜色子菜单)。
-// AT_Mosaic 无对应工具按钮(马赛克标注不可选中),返回 -1。
-static int AnnotationTypeToTool(AnnotationType t) {
- switch (t) {
- case AT_Rect: return TB_Rect;
- case AT_Circle: return TB_Circle;
- case AT_Arrow: return TB_Arrow;
- case AT_Brush: return TB_Brush;
- case AT_Text: return TB_Text;
- default: return -1; // AT_Mosaic 等无对应工具按钮
- }
-}
-
-// 手柄/工具栏几何常量
-static const int SC_HANDLE_SIZE = 10; // 调整手柄边长(100% DPI 基准,运行时按 dpiScale 缩放)
-static const int SC_TOOLBAR_BTN = 32; // 按钮尺寸(正方形)
-static const int SC_TOOLBAR_PAD = 6; // 按钮↔工具栏边缘内边距(四边一致)
-static const int SC_TOOLBAR_H = SC_TOOLBAR_BTN + SC_TOOLBAR_PAD * 2; // 工具栏高度 = 按钮 + 上下内边距
-static const int SC_TOOLBAR_GAP = 1; // 按钮间距
-static const int SC_TOOLBAR_RADIUS = 8; // 工具栏圆角
-static const int SC_TOOLBAR_MARGIN = 6; // 选区到工具栏间距
-static const int SC_TOOLBAR_BORDER = 1; // 工具栏边框
-static const int SC_MIN_SELECTION = 10; // 最小选区尺寸
-static const int SC_CORNER_KNOB_INSET = 18; // 圆角拖拽手柄距选区角的内缩距离(100% DPI 基准,运行时按 dpiScale 缩放)
-static const int SC_CORNER_PROXIMITY = 14; // 倒角手柄"靠近"感应余量:在命中框外再扩此距离即显示该角手柄(100% DPI 基准)
-
-// 手柄几何(DPI 缩放后)。选区/标注 resize 手柄与圆角手柄共用同一套尺寸,
-// 保证 1080p → 4K 下手柄与工具栏/图标同步放大,避免高 DPI 下手柄过小。
-struct SCHandleMetrics {
- int handleSize; // 手柄边长(绘制 + 命中框半宽基准)
- int cornerKnobInset; // 圆角手柄距选区角的内缩距离
- int handleMargin; // 脏区扩张余量 = handleSize/2 + 4(覆盖手柄半径 + 描边/抗锯齿)
- int cornerProximity; // 倒角手柄靠近感应余量(命中框外扩展距离,鼠标进入即显示该角手柄)
-};
-
-// 子菜单几何常量(100% DPI 基准值,运行时按 dpiScale 缩放)
-// 单行布局:[粗细圆点×3] | [分隔线] | [颜色圆点×8],无文案。
-// 单元格(点击区 + 选中背景区)大小与工具栏按钮一致,便于视觉对齐。
-static const int SC_POPUP_CELL = SC_TOOLBAR_BTN; // 单元格尺寸(= 工具栏按钮大小)
-static const int SC_POPUP_PAD = 4; // 内边距
-static const int SC_POPUP_RADIUS = 8; // 圆角
-static const int SC_POPUP_COLOR_DOT = 18; // 颜色圆点直径(图标本身)
-static const int SC_POPUP_SEP_GAP = 6; // 分隔线两侧间距
-static const int SC_POPUP_SEP_H = 20; // 分隔线高度
-static const int SC_POPUP_BORDER = 1; // 边框
-static const int SC_POPUP_MARGIN = 4; // 工具栏与子菜单间距
-
-// 子菜单几何(DPI 缩放后)
-struct SCPopupMetrics {
- int pad;
- int radius;
- int cell; // 单元格尺寸(点击区 + 选中背景区,= 工具栏按钮大小)
- int colorDot; // 颜色圆点直径(图标本身)
- int sepGap; // 分隔线两侧间距
- int sepH; // 分隔线高度
- int border;
- int margin;
-};
-
-// ---- 信息面板 DPI 缩放几何 ----
-struct SCPanelMetrics {
- int w;
- int h;
- int magnifierH;
- int margin;
- int radius;
- int fontPx;
- int crosshair;
- int borderPad;
- int labelPad;
- int sizeLabelPadX;
- int sizeLabelPadY;
- int sizeLabelGap;
-};
-
-static SCPanelMetrics CalcPanelMetrics(double dpiScale) {
- auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
- SCPanelMetrics m;
- m.w = scale(SC_PANEL_WIDTH);
- m.h = scale(SC_PANEL_HEIGHT);
- m.magnifierH = scale(SC_MAGNIFIER_HEIGHT);
- m.margin = scale(SC_PANEL_MARGIN);
- m.radius = scale(SC_PANEL_CORNER_RADIUS);
- m.fontPx = scale(12);
- m.crosshair = (std::max)(1, scale(1));
- m.borderPad = (std::max)(1, scale(2));
- m.labelPad = scale(6);
- m.sizeLabelPadX = scale(12);
- m.sizeLabelPadY = scale(4);
- m.sizeLabelGap = scale(5);
- return m;
-}
-
-// 窗口信息
-struct SCWindowInfo {
- HWND hwnd;
- RECT rect;
- std::wstring title;
-};
-
-// 截图结果结构
-struct ScreenshotResult {
- bool success;
- int x;
- int y;
- int x2;
- int y2;
- int width;
- int height;
- std::string base64;
-};
-
-// GDI 资源缓存
-struct SCGdiResources {
- HBRUSH bgBrush;
- HPEN borderPen;
- HPEN crosshairPen;
- HPEN selectionPen;
- HPEN highlightPen;
- HFONT smallFont;
- int smallFontPx;
- int crosshairWidth;
- // 选区外遮罩缓冲(虚拟屏幕大小,纯黑 + 常量 alpha),用于 AlphaBlend
- HDC maskDC;
- HBITMAP maskBitmap;
- // ---- P2 性能优化:固定样式 Pen/Brush 会话级缓存,避免每帧 Create/Delete ----
- // 工具栏分隔线笔(DrawToolbar)。
- HPEN toolbarSepPen; // PS_SOLID, 1, RGB(230,230,230)
- // 文字选择高亮画刷(AlphaBlend 半透明选区底色)。
- HBRUSH textSelBrush; // RGB(51,153,255)
- // 悬停/选中标注边框:蓝色虚线笔(悬停文字/非文字标注 + 选中非文字标注共用)。
- HPEN annHoverPen; // PS_DASH, 1, RGB(0,136,255)
- // 选中文字标注边框:蓝色实线粗笔(与 selectionPen 的宽度 1 区别)。
- HPEN annTextSelPen; // PS_SOLID, 2, RGB(0,136,255)
-
- void Init(int fontPx = 12, int crosshairPx = 1) {
- if (fontPx < 8) fontPx = 8;
- if (crosshairPx < 1) crosshairPx = 1;
- smallFontPx = fontPx;
- crosshairWidth = crosshairPx;
- bgBrush = CreateSolidBrush(RGB(52, 52, 53));
- borderPen = CreatePen(PS_SOLID, 0, RGB(102, 102, 102));
- crosshairPen = CreatePen(PS_SOLID, crosshairWidth, RGB(0, 136, 255));
- selectionPen = CreatePen(PS_SOLID, 1, RGB(0, 136, 255));
- highlightPen = CreatePen(PS_SOLID, 3, RGB(0, 136, 255));
- // 创建字体
- LOGFONTW lf = {};
- lf.lfHeight = -smallFontPx;
- lf.lfCharSet = DEFAULT_CHARSET;
- wcscpy_s(lf.lfFaceName, L"微软雅黑");
- smallFont = CreateFontIndirectW(&lf);
- maskDC = NULL;
- maskBitmap = NULL;
- // P2:预建固定样式 Pen/Brush,会话内复用。
- toolbarSepPen = CreatePen(PS_SOLID, 1, RGB(230, 230, 230));
- textSelBrush = CreateSolidBrush(RGB(51, 153, 255));
- annHoverPen = CreatePen(PS_DASH, 1, RGB(0, 136, 255));
- annTextSelPen = CreatePen(PS_SOLID, 2, RGB(0, 136, 255));
- }
-
- // 创建遮罩缓冲(纯黑位图,配合常量 alpha 实现 40%+ 半透明遮罩)
- // 须在 CaptureContext 虚拟屏幕尺寸确定后调用
- void InitMask(int virtualW, int virtualH) {
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return;
- maskDC = CreateCompatibleDC(screenDC);
- if (maskDC) {
- maskBitmap = CreateCompatibleBitmap(screenDC, virtualW, virtualH);
- if (maskBitmap) {
- SelectObject(maskDC, maskBitmap);
- // 填充纯黑(AlphaBlend 用常量 alpha,源颜色为黑)
- RECT rc = { 0, 0, virtualW, virtualH };
- HBRUSH black = CreateSolidBrush(RGB(0, 0, 0));
- FillRect(maskDC, &rc, black);
- DeleteObject(black);
- } else {
- DeleteDC(maskDC);
- maskDC = NULL;
- }
- }
- ReleaseDC(NULL, screenDC);
- }
-
- void Cleanup() {
- if (bgBrush) { DeleteObject(bgBrush); bgBrush = NULL; }
- if (borderPen) { DeleteObject(borderPen); borderPen = NULL; }
- if (crosshairPen) { DeleteObject(crosshairPen); crosshairPen = NULL; }
- if (selectionPen) { DeleteObject(selectionPen); selectionPen = NULL; }
- if (highlightPen) { DeleteObject(highlightPen); highlightPen = NULL; }
- if (smallFont) { DeleteObject(smallFont); smallFont = NULL; }
- if (maskBitmap) { DeleteObject(maskBitmap); maskBitmap = NULL; }
- if (maskDC) { DeleteDC(maskDC); maskDC = NULL; }
- // P2:释放缓存的固定样式 Pen/Brush。
- if (toolbarSepPen) { DeleteObject(toolbarSepPen); toolbarSepPen = NULL; }
- if (textSelBrush) { DeleteObject(textSelBrush); textSelBrush = NULL; }
- if (annHoverPen) { DeleteObject(annHoverPen); annHoverPen = NULL; }
- if (annTextSelPen) { DeleteObject(annTextSelPen); annTextSelPen = NULL; }
- }
-};
-
-// ---- 工具栏 DPI 缩放几何 ----
-// 基础逻辑尺寸(100% DPI)按 dpiScale 放大,保证 1080p → 4K 下工具栏尺寸与图标同步。
-// 基础值与原 SC_TOOLBAR_* 常量保持一致,便于回归。
-struct SCToolbarMetrics {
- int btn; // 按钮边长
- int h; // 工具栏高度
- int gap; // 按钮间距
- int pad; // 按钮↔工具栏边缘内边距(四边一致)
- int radius; // 圆角半径
- int margin; // 选区到工具栏间距
- int border; // 工具栏边框宽度
- int iconSize; // 图标光栅化尺寸(物理像素)
-};
-
-static SCToolbarMetrics CalcToolbarMetrics(double dpiScale) {
- auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
- SCToolbarMetrics m;
- m.btn = scale(SC_TOOLBAR_BTN);
- m.h = scale(SC_TOOLBAR_H);
- m.gap = scale(SC_TOOLBAR_GAP);
- m.pad = scale(SC_TOOLBAR_PAD);
- m.radius = scale(SC_TOOLBAR_RADIUS);
- m.margin = scale(SC_TOOLBAR_MARGIN);
- m.border = scale(SC_TOOLBAR_BORDER);
- // 图标视觉内容约占按钮 ~72%,留出内边距;额外 +2px 余量提升抗锯齿质量
- m.iconSize = scale(SC_TOOLBAR_BTN - 8) + 2;
- return m;
-}
-
-// 按当前 DPI 计算手柄几何:选区/标注 resize 手柄与圆角手柄尺寸同步缩放。
-static SCHandleMetrics CalcHandleMetrics(double dpiScale) {
- auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
- SCHandleMetrics m;
- m.handleSize = scale(SC_HANDLE_SIZE);
- m.cornerKnobInset = scale(SC_CORNER_KNOB_INSET);
- m.handleMargin = m.handleSize / 2 + 4;
- m.cornerProximity = scale(SC_CORNER_PROXIMITY);
- return m;
-}
-
-// ---- SVG 图标光栅化 + 缓存 ----
-// 将单个 SVG 文本光栅化为 32bpp 预乘 ARGB HBITMAP,尺寸 px×px。
-// 把 SVG 中的 currentColor 替换为目标 color(normal/active 两色复用同一文本)。
-static HBITMAP RenderSvgToBitmap(const char* svgText, COLORREF color, int px) {
- if (!svgText || px <= 0) return NULL;
-
- // 1. 替换 currentColor -> #RRGGBB(nanosvg 解析会改写 buffer,需可写副本)
- char colorHex[8];
- sprintf_s(colorHex, sizeof(colorHex), "#%02X%02X%02X",
- color & 0xFF, (color >> 8) & 0xFF, (color >> 16) & 0xFF);
- std::string svg(svgText);
- const std::string token = "currentColor";
- size_t pos = 0;
- while ((pos = svg.find(token, pos)) != std::string::npos) {
- svg.replace(pos, token.size(), colorHex);
- pos += 6;
- }
-
- // 2. 解析(nsvgParse 会就地修改传入字符串)
- NSVGimage* image = nsvgParse(&svg[0], "px", 96.0f);
- if (!image) return NULL;
-
- // 3. 光栅化到 RGBA 缓冲(非预乘)
- std::vector rgba(px * px * 4, 0);
- NSVGrasterizer* rast = nsvgCreateRasterizer();
- if (!rast) { nsvgDelete(image); return NULL; }
- // 内容缩放到 72% 并居中,四周各留 14% 内边距,避免图标顶满按钮。
- // nanosvg 解析后 shape 已在 image->width 坐标系(viewBox 已折算),
- // 故 scale = px * 0.72 / image->width,偏移 tx = ty = px * 0.14。
- const float contentScale = 0.72f;
- const float pad = (1.0f - contentScale) * 0.5f;
- float refSize = (image->width > 0) ? image->width
- : (image->height > 0) ? image->height : 24.0f;
- float scale = (float)px * contentScale / refSize;
- float tx = px * pad;
- float ty = px * pad;
- nsvgRasterize(rast, image, tx, ty, scale, rgba.data(), px, px, px * 4);
- nsvgDeleteRasterizer(rast);
- nsvgDelete(image);
-
- // 4. RGBA -> 预乘 BGRA(用于 AlphaBlend,避免黑边)
- // nanosvg 输出 RGBA 字节序 [R,G,B,A];而 32bpp BI_RGB DIB 内存布局为
- // BGRA(像素值 0xAARRGGBB 在小端内存里是 B,G,R,A)。故预乘时需把 R、B
- // 对调写入,否则 memcpy 后通道会反,蓝色被画成黄色(灰度色看不出来)。
- for (int i = 0; i < px * px; i++) {
- unsigned char r = rgba[i * 4 + 0];
- unsigned char g = rgba[i * 4 + 1];
- unsigned char b = rgba[i * 4 + 2];
- unsigned char a = rgba[i * 4 + 3];
- // 预乘后按 DIB 的 BGRA 字节序写入
- rgba[i * 4 + 0] = (unsigned char)((b * a + 127) / 255); // B
- rgba[i * 4 + 1] = (unsigned char)((g * a + 127) / 255); // G
- rgba[i * 4 + 2] = (unsigned char)((r * a + 127) / 255); // R
- rgba[i * 4 + 3] = a; // A
- }
-
- // 5. 创建 32bpp ARGB HBITMAP
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return NULL;
- HDC memDC = CreateCompatibleDC(screenDC);
- if (!memDC) { ReleaseDC(NULL, screenDC); return NULL; }
-
- BITMAPINFO bmi = {};
- bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
- bmi.bmiHeader.biWidth = px;
- bmi.bmiHeader.biHeight = -px; // 自上而下,避免垂直翻转
- bmi.bmiHeader.biPlanes = 1;
- bmi.bmiHeader.biBitCount = 32;
- bmi.bmiHeader.biCompression = BI_RGB;
-
- void* bits = nullptr;
- HBITMAP bmp = CreateDIBSection(memDC, &bmi, DIB_RGB_COLORS, &bits, NULL, 0);
- if (bmp && bits) {
- memcpy(bits, rgba.data(), px * px * 4);
- }
- DeleteDC(memDC);
- ReleaseDC(NULL, screenDC);
- return bmp;
-}
-
-// 工具栏图标位图缓存:按当前 DPI 渲染一次,dark/white 两色版本。
-// dark = normal/hover 图标色,white = active(蓝底)图标色。
-struct SCIconCache {
- bool inited;
- int iconSize;
- HBITMAP dark[TB_Count]; // 普通态:深灰图标
- HBITMAP active[TB_Count]; // 选中态:主题蓝图标(搭配浅蓝高亮底)
-
- SCIconCache() : inited(false), iconSize(0) {
- for (int i = 0; i < TB_Count; i++) { dark[i] = NULL; active[i] = NULL; }
- }
-
- void Init(int physicalIconSize) {
- if (inited) Cleanup();
- iconSize = physicalIconSize;
- COLORREF darkColor = RGB(60, 60, 60);
- // 选中态图标用主题蓝 #3B8BF2,与浅蓝高亮底搭配
- COLORREF activeColor = RGB(0x3B, 0x8B, 0xF2);
- for (int i = 0; i < TB_Count; i++) {
- // 分隔线(TB_Separator1/2)及未映射的项:kIconSvgs[i] 为 nullptr,跳过
- if (kIconSvgs[i]) {
- dark[i] = RenderSvgToBitmap(kIconSvgs[i], darkColor, iconSize);
- active[i] = RenderSvgToBitmap(kIconSvgs[i], activeColor, iconSize);
- }
- }
- inited = true;
- }
-
- // 取按钮位图:isActive 时用主题蓝版本,其余用深灰
- HBITMAP Get(int btn, bool isActive) const {
- if (btn < 0 || btn >= TB_Count) return NULL;
- return isActive ? active[btn] : dark[btn];
- }
-
- void Cleanup() {
- for (int i = 0; i < TB_Count; i++) {
- if (dark[i]) { DeleteObject(dark[i]); dark[i] = NULL; }
- if (active[i]) { DeleteObject(active[i]); active[i] = NULL; }
- }
- inited = false;
- }
-};
-
-// 计算工具栏宽度(所有按钮 + 间距 + 左右内边距 + 边框),按 metrics 缩放
-static int CalcToolbarWidth(const SCToolbarMetrics& m) {
- return TB_Count * (m.btn + m.gap) - m.gap + m.pad * 2 + m.border * 2;
-}
-
-// 前向声明:PointInRect / AddRoundedRect 定义在本文件靠后位置,子菜单命中/绘制需要提前使用。
-static bool PointInRect(int x, int y, const RECT& r);
-static void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius);
-
-// ==================== 粗细/颜色子菜单 ====================
-
-// 按 DPI 计算子菜单几何(单行布局)。
-// 布局:[粗细圆点×3] sepGap | 分隔线 | sepGap [颜色圆点×8]
-// 单元格尺寸与工具栏按钮一致(= SC_TOOLBAR_BTN),单元格间距 = SC_TOOLBAR_GAP,视觉对齐。
-// 粗细圆点本身大小随预设值变化,颜色圆点固定直径,均居中在单元格内。
-static SCPopupMetrics CalcPopupMetrics(double dpiScale) {
- auto scale = [&](int v) { return (int)(v * dpiScale + 0.5); };
- SCPopupMetrics m;
- m.pad = scale(SC_POPUP_PAD);
- m.radius = scale(SC_POPUP_RADIUS);
- m.cell = scale(SC_POPUP_CELL);
- m.colorDot = scale(SC_POPUP_COLOR_DOT);
- m.sepGap = scale(SC_POPUP_SEP_GAP);
- m.sepH = scale(SC_POPUP_SEP_H);
- m.border = scale(SC_POPUP_BORDER);
- m.margin = scale(SC_POPUP_MARGIN);
- return m;
-}
-
-// 子菜单总宽/高(单行)。单元格间距沿用工具栏按钮间距 SC_TOOLBAR_GAP。
-static void CalcPopupSize(const SCPopupMetrics& m, int& outW, int& outH) {
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- // 粗细组宽
- int thickW = SC_THICK_COUNT * m.cell + (SC_THICK_COUNT - 1) * cellGap;
- // 颜色组宽
- int colorW = SC_COLOR_COUNT * m.cell + (SC_COLOR_COUNT - 1) * cellGap;
- int contentW = thickW + m.sepGap * 2 + 1 + colorW; // 1 = 分隔线宽度
- outW = contentW + m.pad * 2 + m.border * 2;
- outH = m.cell + m.pad * 2 + m.border * 2;
-}
-
-// 计算子菜单位置(通用):贴工具栏下方,放不下则上方,左右钳制到虚拟屏幕内。
-// toolbarRect / out 均为相对虚拟屏幕坐标;pw/ph 为子菜单尺寸。
-static void CalcPopupPlacement(const RECT& toolbarRect,
- int virtualW, int virtualH,
- const SCPopupMetrics& m, int pw, int ph, RECT& out) {
- // 水平:与工具栏左对齐
- int x = toolbarRect.left;
- // 垂直:优先工具栏下方
- int y = toolbarRect.bottom + m.margin;
- if (y + ph > virtualH) {
- y = toolbarRect.top - m.margin - ph;
- }
- // 左右钳制
- if (x + pw > virtualW) x = virtualW - pw - m.margin;
- if (x < 0) x = m.margin;
-
- out.left = x;
- out.top = y;
- out.right = x + pw;
- out.bottom = y + ph;
-}
-
-// 计算子菜单位置:贴工具栏下方,放不下则上方,左右钳制到虚拟屏幕内。
-// toolbarRect / out 均为相对虚拟屏幕坐标(与工具栏一致)。
-static void CalcPopupPosition(const RECT& toolbarRect,
- int virtualW, int virtualH,
- const SCPopupMetrics& m, RECT& out) {
- int pw, ph;
- CalcPopupSize(m, pw, ph);
- CalcPopupPlacement(toolbarRect, virtualW, virtualH, m, pw, ph, out);
-}
-
-// 马赛克子菜单几何常量
-// 模式切换组:2 个单元格(涂抹 / 框选);块大小组:3 个单元格。
-static const int SC_MOSAIC_MODE_COUNT = 2; // 涂抹、框选
-// 涂抹模式用半径圆点表示,块大小用马赛克方块网格表示(绘制时按预设值缩放)
-
-// 计算马赛克子菜单总宽/高(单行)。
-// 布局:[模式×2] sepGap | 分隔线 | sepGap [块大小×3] sepGap | 分隔线 | sepGap [涂抹半径×3]
-static void CalcMosaicPopupSize(const SCPopupMetrics& m, int& outW, int& outH) {
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- int modeW = SC_MOSAIC_MODE_COUNT * m.cell + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
- int sizeW = SC_MOSAIC_COUNT * m.cell + (SC_MOSAIC_COUNT - 1) * cellGap;
- int radiusW = SC_MOSAIC_RADIUS_COUNT * m.cell + (SC_MOSAIC_RADIUS_COUNT - 1) * cellGap;
- // 两组分隔线,每组分隔线宽 = sepGap*2 + 1
- int contentW = modeW + (m.sepGap * 2 + 1) + sizeW + (m.sepGap * 2 + 1) + radiusW;
- outW = contentW + m.pad * 2 + m.border * 2;
- outH = m.cell + m.pad * 2 + m.border * 2;
-}
-
-// 命中测试马赛克子菜单,返回值约定:
-// +1 = 涂抹模式;+2 = 框选模式
-// +101.. = 第 N 个块大小(100 + sizeIdx + 1)
-// +201.. = 第 N 个涂抹半径(200 + radiusIdx + 1)
-// 0 = 未命中
-static int HitTestMosaicPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m) {
- if (!PointInRect(x, y, popupRect)) return 0;
- int contentLeft = popupRect.left + m.border + m.pad;
- int contentTop = popupRect.top + m.border + m.pad;
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- if (y < contentTop || y >= contentTop + m.cell) return 0;
-
- // 模式组
- for (int i = 0; i < SC_MOSAIC_MODE_COUNT; i++) {
- int ix = contentLeft + i * (m.cell + cellGap);
- if (x >= ix && x < ix + m.cell) return i + 1; // +1=涂抹 +2=框选
- }
- int modeEndX = contentLeft + SC_MOSAIC_MODE_COUNT * m.cell
- + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
- int sizeStartX = modeEndX + m.sepGap * 2 + 1;
- if (x < sizeStartX) return 0; // 第一条分隔线区域
- // 块大小组
- int sizeEndX = sizeStartX + SC_MOSAIC_COUNT * m.cell
- + (SC_MOSAIC_COUNT - 1) * cellGap;
- for (int i = 0; i < SC_MOSAIC_COUNT; i++) {
- int ix = sizeStartX + i * (m.cell + cellGap);
- if (x >= ix && x < ix + m.cell) return 100 + i + 1;
- }
- // 涂抹半径组
- int radiusStartX = sizeEndX + m.sepGap * 2 + 1;
- if (x < radiusStartX) return 0; // 第二条分隔线区域
- for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
- int ix = radiusStartX + i * (m.cell + cellGap);
- if (x >= ix && x < ix + m.cell) return 200 + i + 1;
- }
- return 0;
-}
-
-// 绘制马赛克子菜单(单行)。
-// modeIdx:当前模式(0=涂抹 1=框选);sizeIdx:当前块大小索引;radiusIdx:涂抹半径索引。
-static void DrawMosaicPopup(HDC hdc, const RECT& popupRect,
- int modeIdx, int sizeIdx, int radiusIdx,
- const SCPopupMetrics& m) {
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
- {
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
-
- int pw = popupRect.right - popupRect.left;
- int ph = popupRect.bottom - popupRect.top;
-
- // 白色圆角背景 + 浅灰边框
- Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::GraphicsPath bgPath;
- AddRoundedRect(bgPath, popupRect.left, popupRect.top, pw, ph, m.radius);
- graphics.FillPath(&whiteBrush, &bgPath);
- Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
- graphics.DrawPath(&borderPen, &bgPath);
-
- int contentLeft = popupRect.left + m.border + m.pad;
- int contentTop = popupRect.top + m.border + m.pad;
- int midY = contentTop + m.cell / 2;
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
-
- auto drawCellBg = [&](int cellLeft) {
- Gdiplus::GraphicsPath p;
- AddRoundedRect(p, cellLeft, contentTop, m.cell, m.cell, m.cell / 4);
- Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, 225, 237, 253));
- graphics.FillPath(&bgBrush, &p);
- Gdiplus::Pen edgePen(Gdiplus::Color(90, 160, 160, 160), 1.0f);
- graphics.DrawPath(&edgePen, &p);
- };
- auto cellColor = [&](bool sel) -> Gdiplus::Color {
- return sel ? Gdiplus::Color(255, 0x3B, 0x8B, 0xF2)
- : Gdiplus::Color(255, 0x33, 0x33, 0x33);
- };
-
- // 模式组
- for (int i = 0; i < SC_MOSAIC_MODE_COUNT; i++) {
- int cellLeft = contentLeft + i * (m.cell + cellGap);
- bool sel = (i == modeIdx);
- if (sel) drawCellBg(cellLeft);
- int cx = cellLeft + m.cell / 2;
- Gdiplus::Color c = cellColor(sel);
- if (i == 0) {
- // 涂抹模式:画一个画笔/毛刷图标(一条波浪线 + 圆头)
- Gdiplus::Pen pen(c, (Gdiplus::REAL)2.0f);
- pen.SetLineJoin(Gdiplus::LineJoinRound);
- pen.SetStartCap(Gdiplus::LineCapRound);
- pen.SetEndCap(Gdiplus::LineCapRound);
- int r = (int)(m.cell * 0.22);
- // 自由曲线(模拟涂抹轨迹)
- Gdiplus::PointF curve[] = {
- Gdiplus::PointF((float)(cx - m.cell * 0.28), (float)(midY + m.cell * 0.18)),
- Gdiplus::PointF((float)(cx - m.cell * 0.10), (float)(midY - m.cell * 0.18)),
- Gdiplus::PointF((float)(cx + m.cell * 0.10), (float)(midY + m.cell * 0.18)),
- Gdiplus::PointF((float)(cx + m.cell * 0.28), (float)(midY - m.cell * 0.18)),
- };
- graphics.DrawLines(&pen, curve, 4);
- } else {
- // 框选模式:画一个虚线矩形
- Gdiplus::Pen pen(c, (Gdiplus::REAL)2.0f);
- int r = (int)(m.cell * 0.24);
- graphics.DrawRectangle(&pen, cx - r, midY - r, r * 2, r * 2);
- }
- }
-
- // 分隔线
- int modeEndX = contentLeft + SC_MOSAIC_MODE_COUNT * m.cell
- + (SC_MOSAIC_MODE_COUNT - 1) * cellGap;
- int sepX = modeEndX + m.sepGap;
- Gdiplus::Pen sepPen(Gdiplus::Color(255, 220, 220, 220), 1.0f);
- graphics.DrawLine(&sepPen, sepX, midY - m.sepH / 2, sepX, midY + m.sepH / 2);
-
- // 块大小组:每个单元格画一个 N×N 的马赛克方块网格,块越大网格越粗
- int sizeStartX = sepX + m.sepGap + 1;
- for (int i = 0; i < SC_MOSAIC_COUNT; i++) {
- int cellLeft = sizeStartX + i * (m.cell + cellGap);
- bool sel = (i == sizeIdx);
- if (sel) drawCellBg(cellLeft);
- Gdiplus::Color c = cellColor(sel);
- int cx = cellLeft + m.cell / 2;
- // 网格区域边长(占单元格约 0.6)
- int gridHalf = (int)(m.cell * 0.26);
- int gridSize = gridHalf * 2;
- int gx = cx - gridHalf;
- int gy = midY - gridHalf;
- // 块数随预设递增:i=0 -> 2x2, i=1 -> 3x3, i=2 -> 4x4
- int n = 2 + i;
- int cellSz = gridSize / n;
- if (cellSz < 1) cellSz = 1;
- Gdiplus::SolidBrush b(c);
- // 交错填充模拟马赛克质感(棋盘格)
- for (int ry = 0; ry < n; ry++) {
- for (int rx = 0; rx < n; rx++) {
- if (((rx + ry) & 1) == 0) {
- graphics.FillRectangle(&b, gx + rx * cellSz, gy + ry * cellSz,
- cellSz, cellSz);
- }
- }
- }
- // 网格描边(未填充格用半透明)
- Gdiplus::Pen gridPen(Gdiplus::Color(sel ? 200 : 120,
- c.GetRed(), c.GetGreen(), c.GetBlue()),
- 1.0f);
- for (int k = 0; k <= n; k++) {
- graphics.DrawLine(&gridPen, gx + k * cellSz, gy,
- gx + k * cellSz, gy + n * cellSz);
- graphics.DrawLine(&gridPen, gx, gy + k * cellSz,
- gx + n * cellSz, gy + k * cellSz);
- }
- }
-
- // 第二条分隔线
- int sizeEndX = sizeStartX + SC_MOSAIC_COUNT * m.cell
- + (SC_MOSAIC_COUNT - 1) * cellGap;
- int sep2X = sizeEndX + m.sepGap;
- graphics.DrawLine(&sepPen, sep2X, midY - m.sepH / 2, sep2X, midY + m.sepH / 2);
-
- // 涂抹半径组:用不同直径的圆点表示半径大小(类似画笔粗细)。
- // 仅涂抹模式下有意义;框选模式下置灰但仍可点击(切换后立即生效)。
- int radiusStartX = sep2X + m.sepGap + 1;
- bool radiusEnabled = (modeIdx == 0);
- for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
- int cellLeft = radiusStartX + i * (m.cell + cellGap);
- bool sel = (i == radiusIdx) && radiusEnabled;
- if (sel) drawCellBg(cellLeft);
- Gdiplus::Color c = cellColor(sel);
- int cx = cellLeft + m.cell / 2;
- // 圆点直径随预设递增:小/中/大
- int dotD = (int)(SC_MOSAIC_RADIUS[i] * 0.5 * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- if (dotD < 5) dotD = 5;
- if (dotD > m.cell - 4) dotD = m.cell - 4;
- int r = dotD / 2;
- Gdiplus::Color drawC = radiusEnabled ? c
- : Gdiplus::Color(160, c.GetRed(), c.GetGreen(), c.GetBlue());
- Gdiplus::SolidBrush brush(drawC);
- graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
- }
- }
-}
-
-// 命中测试子菜单,返回值约定:
-// +1..+SC_THICK_COUNT = 第 N 个粗细
-// -1..-SC_COLOR_COUNT = 第 N 个颜色(取负为索引+1)
-// 0 = 未命中(包括点在分隔线上)
-static int HitTestPopup(int x, int y, const RECT& popupRect, const SCPopupMetrics& m) {
- if (!PointInRect(x, y, popupRect)) return 0;
-
- // 内容左边界(绝对)
- int contentLeft = popupRect.left + m.border + m.pad;
- int contentTop = popupRect.top + m.border + m.pad;
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- // y 必须在单元格高度内
- if (y < contentTop || y >= contentTop + m.cell) return 0;
-
- // 粗细组:contentLeft 起
- int thickX0 = contentLeft;
- for (int i = 0; i < SC_THICK_COUNT; i++) {
- int ix = thickX0 + i * (m.cell + cellGap);
- if (x >= ix && x < ix + m.cell) return i + 1;
- }
- int thickEndX = thickX0 + SC_THICK_COUNT * m.cell
- + (SC_THICK_COUNT - 1) * cellGap;
- // 分隔线区域(不命中)
- int colorStartX = thickEndX + m.sepGap * 2 + 1;
- if (x < colorStartX) return 0;
-
- // 颜色组
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- int ix = colorStartX + i * (m.cell + cellGap);
- if (x >= ix && x < ix + m.cell) return -(i + 1);
- }
- return 0;
-}
-
-// 绘制子菜单(GDI+ 抗锯齿白底圆角,单行布局)。
-// 第一组:isTextTool 时显示字号(不同大小 'A'),否则显示粗细(圆点直径区分)。
-// 第二组:颜色(固定直径圆点)。中间一条竖直分隔线。
-static void DrawPopup(HDC hdc, const RECT& popupRect,
- int colorIdx, int firstIdx, bool isTextTool,
- const SCPopupMetrics& m) {
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
- {
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
-
- int pw = popupRect.right - popupRect.left;
- int ph = popupRect.bottom - popupRect.top;
-
- // 白色圆角背景
- Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::GraphicsPath bgPath;
- AddRoundedRect(bgPath, popupRect.left, popupRect.top, pw, ph, m.radius);
- graphics.FillPath(&whiteBrush, &bgPath);
- // 浅灰边框
- Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
- graphics.DrawPath(&borderPen, &bgPath);
-
- int contentLeft = popupRect.left + m.border + m.pad;
- int contentTop = popupRect.top + m.border + m.pad;
- int midY = contentTop + m.cell / 2;
- int cellGap = (int)(SC_TOOLBAR_GAP * (m.cell / (double)SC_POPUP_CELL) + 0.5);
-
- // 选中态背景:单元格大小的圆角矩形。
- // 粗细沿用主工具栏选中高亮底(不透明浅蓝 RGB(225,237,253));
- // 颜色用带透明(alpha 80)的选中色。统一加一条带透明灰色描边。
- auto drawCellBg = [&](int cellLeft, const Gdiplus::Color& bg) {
- Gdiplus::GraphicsPath p;
- AddRoundedRect(p, cellLeft, contentTop, m.cell, m.cell, m.cell / 4);
- Gdiplus::SolidBrush bgBrush(bg);
- graphics.FillPath(&bgBrush, &p);
- // 背景边缘描边(带透明灰,alpha 90)
- Gdiplus::Pen edgePen(Gdiplus::Color(90, 160, 160, 160), 1.0f);
- graphics.DrawPath(&edgePen, &p);
- };
-
- // 第一组:文字工具显示字号(不同大小 'A'),矢量工具显示粗细(圆点直径区分)
- int firstCount = isTextTool ? SC_FONT_COUNT : SC_THICK_COUNT;
- int firstX0 = contentLeft;
- for (int i = 0; i < firstCount; i++) {
- int cellLeft = firstX0 + i * (m.cell + cellGap);
- if (i == firstIdx) {
- // 与主工具栏选中态一致的不透明浅蓝底
- drawCellBg(cellLeft, Gdiplus::Color(255, 225, 237, 253));
- }
- Gdiplus::Color iconC = (i == firstIdx)
- ? Gdiplus::Color(255, 0x3B, 0x8B, 0xF2)
- : Gdiplus::Color(255, 0x33, 0x33, 0x33);
- int cx = cellLeft + m.cell / 2;
- if (isTextTool) {
- // 字号:用不同大小的字母 'A' 表示,居中绘制。
- // 缩放系数:最大字号(36)额外缩小到 0.62,前两个字号(16/24)保持 0.72,
- // 避免最大字号的 'A' 仍偏大溢出。
- double sizeScale = (i == SC_FONT_COUNT - 1) ? 0.62 : 0.72;
- int fontPx = (int)(SC_FONT_SIZES[i] * (m.cell / (double)SC_POPUP_CELL) * sizeScale + 0.5);
- if (fontPx < 6) fontPx = 6;
- Gdiplus::FontFamily fontFamily(SC_FONT_FACE);
- Gdiplus::FontStyle fs = Gdiplus::FontStyleRegular;
- // 'A' 像素高 = fontPx,按高度反推 emSize(GDI+ 用 em)
- Gdiplus::Font font(&fontFamily, (Gdiplus::REAL)fontPx, fs, Gdiplus::UnitPixel);
- Gdiplus::SolidBrush b(iconC);
- Gdiplus::StringFormat sf;
- sf.SetAlignment(Gdiplus::StringAlignmentCenter);
- sf.SetLineAlignment(Gdiplus::StringAlignmentCenter);
- Gdiplus::RectF cellRect((Gdiplus::REAL)cellLeft, (Gdiplus::REAL)contentTop,
- (Gdiplus::REAL)m.cell, (Gdiplus::REAL)m.cell);
- graphics.DrawString(L"A", 1, &font, cellRect, &sf, &b);
- } else {
- // 粗细:圆点直径取自 SC_THICK_DOT_SIZES
- int dotD = (int)(SC_THICK_DOT_SIZES[i] * (m.cell / (double)SC_POPUP_CELL) + 0.5);
- if (dotD < 4) dotD = 4;
- if (dotD > m.cell) dotD = m.cell;
- int r = dotD / 2;
- Gdiplus::SolidBrush brush(iconC);
- graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
- }
- }
-
- // 分隔线
- int firstEndX = firstX0 + firstCount * m.cell
- + (firstCount - 1) * cellGap;
- int sepX = firstEndX + m.sepGap;
- Gdiplus::Pen sepPen(Gdiplus::Color(255, 220, 220, 220), 1.0f);
- graphics.DrawLine(&sepPen, sepX, midY - m.sepH / 2, sepX, midY + m.sepH / 2);
-
- // 颜色组:选中时背景变为带透明的选中色
- int colorStartX = sepX + m.sepGap + 1;
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- COLORREF c = SC_COLOR_PRESETS[i];
- int cellLeft = colorStartX + i * (m.cell + cellGap);
- int cx = cellLeft + m.cell / 2;
- int r = m.colorDot / 2;
- if (i == colorIdx) {
- drawCellBg(cellLeft,
- Gdiplus::Color(80, GetRValue(c), GetGValue(c), GetBValue(c)));
- }
- Gdiplus::Color gc(GetRValue(c), GetGValue(c), GetBValue(c));
- Gdiplus::SolidBrush brush(gc);
- graphics.FillEllipse(&brush, cx - r, midY - r, r * 2, r * 2);
- // 圆点本身始终保留极浅描边(白色块可见性),选中也保留
- Gdiplus::Pen outline(Gdiplus::Color(255, 220, 220, 220), 1.0f);
- graphics.DrawEllipse(&outline, cx - r, midY - r, r * 2, r * 2);
- }
- }
-}
-
-// 截图上下文
-struct CaptureContext {
- CaptureState state;
- // 自动确认模式:选区确定后直接提取并完成截图,不进入编辑态(工具栏/标注)。
- // 仅在 WM_LBUTTONUP 的 CS_Selecting 分支生效。
- bool autoConfirm;
- int virtualX, virtualY, virtualW, virtualH;
- int startX, startY, endX, endY;
- int mouseX, mouseY;
- COLORREF currentColor;
- std::vector windows;
- int hoveredWindow; // -1 = none
- // 预截屏
- HBITMAP screenBitmap;
- HDC memDC;
- // 双缓冲
- HDC backDC;
- HBITMAP backBitmap;
- // 脏区域追踪
- RECT lastPanelRect;
- RECT lastSelectionRect;
- RECT lastLabelRect;
- RECT lastHighlightRect;
- RECT lastToolbarRect;
- RECT lastPopupRect;
- // P1 局部刷新用:上帧光标/被操作标注/正在绘制标注的包围盒(供 InvalidateRect 计算旧位置)
- RECT lastCaretRect; // 上帧文字光标矩形(backDC 坐标),hasLastCaret=false 表示无效
- bool hasLastCaret;
- RECT lastAnnotationBox; // 上帧被拖拽/缩放标注的包围盒(绝对虚拟屏幕坐标)
- bool hasLastAnnotationBox;
- RECT lastDrawingBox; // 上帧 curDrawing 包围盒(绝对虚拟屏幕坐标)
- bool hasLastDrawingBox;
- bool needFullRedraw;
- // DPI
- double dpiScale;
- // GDI 资源
- SCGdiResources gdi;
- SCPanelMetrics panelMetrics;
-
- // ---- 确认态:可调整选区 ----
- // 已确认的选区(绝对屏幕坐标)
- RECT selection;
- // 当前正在拖拽的手柄(CS_Resizing 时有效),CS_Confirmed 下表示 hover 手柄
- int resizeHandle;
- // 整体拖动/调整起点(绝对屏幕坐标)
- int dragStartX, dragStartY;
- RECT dragStartSelection;
- // 选区圆角半径(0=直角;上限=min(w,h)/2,由 ClampCornerRadius 保证)
- int selectionCornerRadius = 0;
- // 圆角手柄拖拽起始半径(RH_CornerRadiusTL/TR/BL/BR 拖拽用,增量映射)
- int dragStartRadius = 0;
- // 当前"靠近/拖拽"的倒角手柄角(RH_CornerRadiusTL/TR/BL/BR 之一;RH_None=未靠近)。
- // 仅鼠标靠近某角或正拖拽某角时显示该角一个倒角手柄,其余时刻隐藏。
- int hoveredCornerHandle = RH_None;
- // 键盘方向键微调累计位移(CS_Resizing 时叠加到鼠标位移上,松开时一并固化)
- int kbDX = 0;
- int kbDY = 0;
-
- // ---- 悬浮工具栏 ----
- // 工具栏矩形(相对虚拟屏幕坐标,绘制用)
- RECT toolbarRect;
- // 工具栏 hover 按钮,-1 = none
- int hoverToolbarBtn;
- // 当前激活的工具(高亮显示,仅界面)
- int activeTool;
- // 当前子菜单/参数面板对应的工具来源;拖拽工具下选中覆盖物时可继续回显其参数。
- int popupTool;
- // 工具栏图标位图缓存(按 DPI 预渲染,dark/white 双色)
- SCIconCache iconCache;
- // 当前 DPI 下的工具栏几何(缓存,避免每次绘制重算)
- SCToolbarMetrics toolbarMetrics;
- // 当前 DPI 下的手柄几何(缓存:选区/标注 resize 手柄 + 圆角手柄)
- SCHandleMetrics handleMetrics;
-
- // ---- 标注绘制 ----
- std::vector annotations; // 已提交标注
- std::vector> undoStack;
- std::vector> redoStack;
- Annotation curDrawing; // CS_Drawing 中正在绘制的标注
- bool hasCurDrawing; // curDrawing 是否有效
- int drawColorIdx; // 当前选中颜色索引
- int drawThickIdx; // 当前选中粗细索引(矢量工具)
- int fontSizeIdx; // 当前选中字号索引(文字工具)
- // 马赛克工具属性
- int mosaicSizeIdx; // 当前选中马赛克块大小索引
- int mosaicRadiusIdx; // 当前选中涂抹半径索引
- bool mosaicRectMode; // true=框选区域模式;false=涂抹模式
- // 涂抹模式光标:用系统光标机制(SetCursor)显示半径圆,由 OS 跟随鼠标,
- // 无 WM_PAINT 重绘延迟(之前的 overlay 圆走 MOUSEMOVE→InvalidateRect→WM_PAINT 链路,
- // 全屏重绘开销大导致不跟手)。按半径预设预生成彩色光标并缓存。
- HCURSOR mosaicBrushCursors[3]; // 对应 SC_MOSAIC_RADIUS_COUNT 个半径预设的光标
- bool mosaicBrushCursorsInited;
- // ---- 马赛克渲染(reveal-mask 模型,消除不连续感)----
- // 预先把整张截图按当前块大小马赛克化得到 mosaicBase(逻辑像素,与 backDC 同尺寸)。
- // 马赛克标注只是「蒙版」:涂抹=路径圆形区域、框选=矩形区域,揭示其背后的 mosaicBase。
- // 这样任意区域、任意顺序叠加都连续无缝;切换块大小时只需重建 base,已揭示区域自动更新。
- // mosaicBase 覆盖整虚拟屏幕(绝对坐标),与选区无关,resize/move 无需重建。
- HDC mosaicBaseDC;
- HBITMAP mosaicBaseBitmap;
- int mosaicBaseW, mosaicBaseH; // base 尺寸(= 虚拟屏幕逻辑尺寸)
- int mosaicBaseBlockPx; // 生成 base 时的块大小(检测变更触发重建)
- // 涂抹模式增量绘制:记录上一帧最后绘制的路径点索引(reveal 模型下未使用,保留扩展)。
- int mosaicDrawLastIdx;
- // 粗细/颜色子菜单
- bool popupOpen;
- RECT popupRect;
- SCPopupMetrics popupMetrics;
-
- // ---- 文字输入(CS_TextEditing)----
- std::wstring textBuf; // 正在输入的文字缓冲
- int textAnchorX, textAnchorY; // 文字锚点(绝对虚拟屏幕坐标)
- int textCaretPos; // 插入符在 textBuf 中的 wchar 位置
- bool textCaretVisible; // 光标是否可见(闪烁控制)
- DWORD textCaretLastBlink; // 上次光标闪烁时间(毫秒)
- int textSelStart; // 文字选择起始位置(-1 表示无选择)
- int textSelEnd; // 文字选择结束位置
- bool textDraggingSelection; // 是否正在拖动选择文字
- int hoveredTextAnnotation; // 悬浮命中的文字标注索引(-1 表示无,仅用于光标/即时反馈)
- int selectedTextAnnotation; // 已选中的文字标注索引(-1 表示无,持久保持直到点空白)
- int draggingTextAnnotation; // 正在拖动的文字标注索引(-1 表示无)
- int textDragStartX, textDragStartY; // 文字拖动起始位置
- // ---- 非文字标注的选中/拖拽/缩放(与文字机制互斥:选中非文字时清文字选中,反之亦然)----
- int hoveredAnnotation; // 悬浮命中的非文字标注索引(-1=无,用于虚线框/光标即时反馈)
- int selectedAnnotation; // 已选中的非文字标注索引(-1=无,持久保持直到点空白/进入其他操作)
- int draggingAnnotation; // 正在拖拽的非文字标注索引(-1=无)
- int resizingAnnotation; // 正在缩放的非文字标注索引(-1=无)
- int annotationResizeHandle; // 当前缩放手柄(RH_None=无;CS_Resizing 时为四角之一)
- int annotationDragStartX, annotationDragStartY; // 鼠标按下位置(绝对坐标,拖拽/缩放共用)
- Annotation dragStartAnnotation; // 按下时标注快照(拖拽时还原+平移)
- bool annotationOpHistoryPushed;
- RECT annotationResizeStartBox; // 按下时包围盒(缩放时基准)
-
- // ---- GDI+ 会话级资源(性能优化:会话内单次 Startup/Shutdown)----
- // 原实现每个绘制/测量函数各自 GdiplusStartup/Shutdown,每帧 WM_PAINT 触发 6~10 次昂贵的
- // GDI+ 初始化,是拖拽卡顿的主因。由于所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内,
- // 改为会话开始 Startup 一次、结束 Shutdown 一次。FontFamily(SC_FONT_FACE) 与
- // StringFormat(总是 Near/Near) 为常量;Font 仅依赖 fontPx(文字字号仅 SC_FONT_SIZES 三档),
- // 均缓存复用。Graphics 仍每次按 hdc 新建(必须,因为绑定不同 DC)。
- ULONG_PTR gdipToken; // GDI+ 启动令牌(0 = 未初始化)
- Gdiplus::GdiplusStartupInput gdipStartupInput;
- bool gdipInited; // GDI+ 是否已 Startup
- Gdiplus::FontFamily* gdipFontFamily; // SC_FONT_FACE,会话内唯一
- Gdiplus::StringFormat* gdipStrFmt; // Near/Near,会话内唯一
- Gdiplus::Font* gdipFonts[3]; // 按 SC_FONT_SIZES 预建的 Font(索引对齐 SC_FONT_COUNT)
-};
-
-// 截图上下文指针(窗口过程使用)
-static CaptureContext* g_captureCtx = nullptr;
-
-static void PushAnnotationHistory(CaptureContext* ctx) {
- ctx->undoStack.push_back(ctx->annotations);
- ctx->redoStack.clear();
-}
-
-static void ResetAnnotationInteraction(CaptureContext* ctx) {
- ctx->selectedTextAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- ctx->draggingTextAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->draggingAnnotation = -1;
- ctx->resizingAnnotation = -1;
- ctx->annotationResizeHandle = RH_None;
- ctx->annotationOpHistoryPushed = false;
- ctx->hasLastAnnotationBox = false;
-}
-
-static bool UndoAnnotations(CaptureContext* ctx) {
- if (ctx->undoStack.empty()) return false;
- ctx->redoStack.push_back(ctx->annotations);
- ctx->annotations = ctx->undoStack.back();
- ctx->undoStack.pop_back();
- ResetAnnotationInteraction(ctx);
- return true;
-}
-
-static bool RedoAnnotations(CaptureContext* ctx) {
- if (ctx->redoStack.empty()) return false;
- ctx->undoStack.push_back(ctx->annotations);
- ctx->annotations = ctx->redoStack.back();
- ctx->redoStack.pop_back();
- ResetAnnotationInteraction(ctx);
- return true;
-}
-
-// ==================== GDI+ 会话级资源管理(性能优化) ====================
-// 所有 GDI+ 调用均在 ScreenshotCaptureThread 单线程内,故可在会话开始 Startup 一次、
-// 结束 Shutdown 一次,避免每个绘制/测量函数反复 Startup/Shutdown(每帧 WM_PAINT 触发 6~10 次)。
-
-// 前置声明:InitGdipResources 在分配失败时复用 Shutdown 清理。
-static void ShutdownGdipResources(CaptureContext* ctx);
-
-// 初始化会话级 GDI+ 资源:Startup + 预建可复用的 FontFamily/StringFormat/Font。
-// 必须在任何 GDI+ 调用(含 InitMosaicBrushCursors)之前调用。
-// 成功返回 true;失败(Startup 失败)返回 false,调用方应中止会话。
-static bool InitGdipResources(CaptureContext* ctx) {
- ctx->gdipToken = 0;
- ctx->gdipInited = false;
- ctx->gdipFontFamily = nullptr;
- ctx->gdipStrFmt = nullptr;
- for (int i = 0; i < SC_FONT_COUNT; i++) ctx->gdipFonts[i] = nullptr;
-
- if (Gdiplus::GdiplusStartup(&ctx->gdipToken, &ctx->gdipStartupInput, NULL) != Gdiplus::Ok) {
- return false;
- }
- ctx->gdipInited = true;
-
- // 注意:GDI+ 类继承自 GdiplusBase,其 operator new 不接受 std::nothrow 参数,
- // 故用普通 new(GDI+ 对象通过 GetLastStatus() 而非空指针报告失败)。
- ctx->gdipFontFamily = new Gdiplus::FontFamily(SC_FONT_FACE);
- ctx->gdipStrFmt = new Gdiplus::StringFormat();
- if (!ctx->gdipFontFamily || !ctx->gdipStrFmt
- || ctx->gdipFontFamily->GetLastStatus() != Gdiplus::Ok) {
- ShutdownGdipResources(ctx); return false;
- }
- ctx->gdipStrFmt->SetAlignment(Gdiplus::StringAlignmentNear);
- ctx->gdipStrFmt->SetLineAlignment(Gdiplus::StringAlignmentNear);
-
- // 按文字字号预设预建 Font(与 SC_FONT_SIZES 一一对应)
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- ctx->gdipFonts[i] = new Gdiplus::Font(
- ctx->gdipFontFamily, (Gdiplus::REAL)SC_FONT_SIZES[i],
- Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
- if (!ctx->gdipFonts[i] || ctx->gdipFonts[i]->GetLastStatus() != Gdiplus::Ok) {
- ShutdownGdipResources(ctx); return false;
- }
- }
- return true;
-}
-
-// 释放会话级 GDI+ 资源:先 delete 所有 GDI+ 对象,再 Shutdown(对象须在 Shutdown 前析构)。
-// 安全可重入:重复调用无副作用。
-static void ShutdownGdipResources(CaptureContext* ctx) {
- // Font 先于 FontFamily 析构(Font 内部引用 FontFamily)
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- delete ctx->gdipFonts[i];
- ctx->gdipFonts[i] = nullptr;
- }
- delete ctx->gdipStrFmt;
- ctx->gdipStrFmt = nullptr;
- delete ctx->gdipFontFamily;
- ctx->gdipFontFamily = nullptr;
-
- if (ctx->gdipInited) {
- Gdiplus::GdiplusShutdown(ctx->gdipToken);
- ctx->gdipInited = false;
- ctx->gdipToken = 0;
- }
-}
-
-// 取 fontPx 对应的缓存 Font。文字标注字号仅取自 SC_FONT_SIZES,故优先查缓存表;
-// 若 fontPx 不在预设表内(理论上不会发生),用 thread_local 临时对象兜底,避免泄漏。
-// 返回的 Font 所有权归会话/兜底存储,调用方不得 delete。
-static Gdiplus::Font* GetGdipFont(CaptureContext* ctx, int fontPx) {
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- if (SC_FONT_SIZES[i] == fontPx && ctx->gdipFonts[i]) {
- return ctx->gdipFonts[i];
- }
- }
- // 兜底:thread_local 临时 Font(仅当前线程有效,覆盖极少见的非预设字号)
- static thread_local Gdiplus::Font* sFallback = nullptr;
- static thread_local int sFallbackPx = -1;
- static thread_local Gdiplus::FontFamily* sFallbackFam = nullptr;
- if (!sFallbackFam) sFallbackFam = new Gdiplus::FontFamily(SC_FONT_FACE);
- if (!sFallback || sFallbackPx != fontPx) {
- delete sFallback;
- sFallbackPx = fontPx;
- sFallback = new Gdiplus::Font(sFallbackFam, (Gdiplus::REAL)fontPx,
- Gdiplus::FontStyleRegular, Gdiplus::UnitPixel);
- }
- return sFallback;
-}
-
-// Base64 编码表
-static const char base64_chars[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
-
-// Base64 编码
-static std::string Base64Encode(const BYTE* data, size_t len) {
- std::string result;
- result.reserve(((len + 2) / 3) * 4);
- for (size_t i = 0; i < len; i += 3) {
- unsigned int b = (data[i] << 16) | ((i + 1 < len ? data[i + 1] : 0) << 8) | (i + 2 < len ? data[i + 2] : 0);
- result.push_back(base64_chars[(b >> 18) & 0x3F]);
- result.push_back(base64_chars[(b >> 12) & 0x3F]);
- result.push_back(i + 1 < len ? base64_chars[(b >> 6) & 0x3F] : '=');
- result.push_back(i + 2 < len ? base64_chars[b & 0x3F] : '=');
- }
- return result;
-}
-
-// ---- 工具函数 ----
-
-// 获取 DPI 缩放因子
-static double GetDpiScaleFactor() {
- typedef UINT (WINAPI *GetDpiForSystemProc)();
- HMODULE user32 = GetModuleHandleW(L"user32.dll");
- if (user32) {
- auto proc = (GetDpiForSystemProc)GetProcAddress(user32, "GetDpiForSystem");
- if (proc) {
- UINT dpi = proc();
- double scale = dpi / 96.0;
- if (scale < 0.5) scale = 0.5;
- if (scale > 4.0) scale = 4.0;
- return scale;
- }
- }
- return 1.0;
-}
-
-// 显示器枚举回调数据
-struct MonitorEnumData {
- LONG minLeft, minTop, maxRight, maxBottom;
- double totalDpiScale;
- int monitorCount;
-};
-
-// 显示器枚举回调
-static BOOL CALLBACK MonitorEnumProc(HMONITOR hMonitor, HDC hdcMonitor, LPRECT lprcMonitor, LPARAM dwData) {
- MonitorEnumData* data = reinterpret_cast(dwData);
-
- // 获取显示器的物理尺寸
- MONITORINFOEXW mi;
- mi.cbSize = sizeof(MONITORINFOEXW);
- if (GetMonitorInfoW(hMonitor, &mi)) {
- // 在 DPI 感知模式下,rcMonitor 已经是物理像素坐标
- data->minLeft = (std::min)(data->minLeft, mi.rcMonitor.left);
- data->minTop = (std::min)(data->minTop, mi.rcMonitor.top);
- data->maxRight = (std::max)(data->maxRight, mi.rcMonitor.right);
- data->maxBottom = (std::max)(data->maxBottom, mi.rcMonitor.bottom);
- data->monitorCount++;
-
- // 获取显示器 DPI
- typedef HRESULT(WINAPI* GetDpiForMonitorProc)(HMONITOR, int, UINT*, UINT*);
- HMODULE shcore = LoadLibraryW(L"shcore.dll");
- if (shcore) {
- auto getDpiForMonitor = (GetDpiForMonitorProc)GetProcAddress(shcore, "GetDpiForMonitor");
- if (getDpiForMonitor) {
- UINT dpiX, dpiY;
- if (SUCCEEDED(getDpiForMonitor(hMonitor, 0/*MDT_EFFECTIVE_DPI*/, &dpiX, &dpiY))) {
- data->totalDpiScale = (std::max)(data->totalDpiScale, dpiX / 96.0);
- }
- }
- FreeLibrary(shcore);
- }
- }
- return TRUE;
-}
-
-// 截取整个虚拟屏幕到物理尺寸位图
-static bool CaptureVirtualScreen(HDC& outMemDC, HBITMAP& outBitmap,
- int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
- // 获取逻辑坐标的虚拟屏幕尺寸
- vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
- vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
- vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
- vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);
-
- // 枚举所有显示器获取物理像素边界
- MonitorEnumData enumData = { INT_MAX, INT_MAX, INT_MIN, INT_MIN, 1.0, 0 };
- EnumDisplayMonitors(NULL, NULL, MonitorEnumProc, reinterpret_cast(&enumData));
-
- // 计算物理尺寸(使用枚举得到的实际物理像素边界)
- int physVx = enumData.minLeft;
- int physVy = enumData.minTop;
- int physVw = enumData.maxRight - enumData.minLeft;
- int physVh = enumData.maxBottom - enumData.minTop;
-
- // 如果枚举失败,回退到 DPI 缩放计算
- if (physVw <= 0 || physVh <= 0 || enumData.monitorCount == 0) {
- physVx = (int)(vx * dpiScale);
- physVy = (int)(vy * dpiScale);
- physVw = (int)(vw * dpiScale + 0.5);
- physVh = (int)(vh * dpiScale + 0.5);
- }
-
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return false;
-
- outMemDC = CreateCompatibleDC(screenDC);
- if (!outMemDC) { ReleaseDC(NULL, screenDC); return false; }
-
- outBitmap = CreateCompatibleBitmap(screenDC, physVw, physVh);
- if (!outBitmap) { DeleteDC(outMemDC); ReleaseDC(NULL, screenDC); return false; }
-
- SelectObject(outMemDC, outBitmap);
-
- // 直接 BitBlt 物理像素(在 DPI 感知模式下,屏幕 DC 和坐标都是物理像素级别)
- BitBlt(outMemDC, 0, 0, physVw, physVh, screenDC, physVx, physVy, SRCCOPY | CAPTUREBLT);
-
- // 更新返回的 dpiScale 为实际的物理/逻辑比例
- // 这样后续的坐标转换才能正确
- if (vw > 0 && vh > 0) {
- dpiScale = (double)physVw / vw;
- }
-
- ReleaseDC(NULL, screenDC);
- return true;
-}
-
-// 创建双缓冲
-static bool CreateBackBuffer(HDC& outDC, HBITMAP& outBmp, int w, int h) {
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return false;
- outDC = CreateCompatibleDC(screenDC);
- if (!outDC) { ReleaseDC(NULL, screenDC); return false; }
- outBmp = CreateCompatibleBitmap(screenDC, w, h);
- if (!outBmp) { DeleteDC(outDC); ReleaseDC(NULL, screenDC); return false; }
- SelectObject(outDC, outBmp);
- ReleaseDC(NULL, screenDC);
- return true;
-}
-
-// 立即抓取当前虚拟屏幕首帧,供后续截图流程复用。
-bool PrimeScreenshotFrameNow() {
- HDC memDC = NULL;
- HBITMAP bitmap = NULL;
- int vx = 0, vy = 0, vw = 0, vh = 0;
- double dpiScale = 1.0;
- if (!CaptureVirtualScreen(memDC, bitmap, vx, vy, vw, vh, dpiScale)) {
- return false;
- }
-
- if (memDC) {
- DeleteDC(memDC);
- }
-
- std::lock_guard lock(g_primedScreenshotFrameMutex);
- ReleasePrimedScreenshotFrameLocked();
- g_primedScreenshotFrame.bitmap = bitmap;
- g_primedScreenshotFrame.vx = vx;
- g_primedScreenshotFrame.vy = vy;
- g_primedScreenshotFrame.vw = vw;
- g_primedScreenshotFrame.vh = vh;
- g_primedScreenshotFrame.dpiScale = dpiScale;
- g_primedScreenshotFrame.capturedAt = std::chrono::steady_clock::now();
- g_primedScreenshotFrame.valid = true;
- return true;
-}
-
-static bool ConsumePrimedScreenshotFrame(HDC& outMemDC, HBITMAP& outBitmap,
- int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
- std::lock_guard lock(g_primedScreenshotFrameMutex);
- if (!g_primedScreenshotFrame.valid || !g_primedScreenshotFrame.bitmap) {
- return false;
- }
-
- const auto now = std::chrono::steady_clock::now();
- if (now - g_primedScreenshotFrame.capturedAt > SC_PRIMED_FRAME_TTL) {
- ReleasePrimedScreenshotFrameLocked();
- return false;
- }
-
- HDC screenDC = GetDC(NULL);
- if (!screenDC) {
- ReleasePrimedScreenshotFrameLocked();
- return false;
- }
-
- outMemDC = CreateCompatibleDC(screenDC);
- ReleaseDC(NULL, screenDC);
- if (!outMemDC) {
- ReleasePrimedScreenshotFrameLocked();
- return false;
- }
-
- SelectObject(outMemDC, g_primedScreenshotFrame.bitmap);
- outBitmap = g_primedScreenshotFrame.bitmap;
- vx = g_primedScreenshotFrame.vx;
- vy = g_primedScreenshotFrame.vy;
- vw = g_primedScreenshotFrame.vw;
- vh = g_primedScreenshotFrame.vh;
- dpiScale = g_primedScreenshotFrame.dpiScale;
- g_primedScreenshotFrame.bitmap = NULL;
- g_primedScreenshotFrame.valid = false;
- return true;
-}
-
-static bool AcquireScreenshotBase(HDC& outMemDC, HBITMAP& outBitmap,
- int& vx, int& vy, int& vw, int& vh, double& dpiScale) {
- if (ConsumePrimedScreenshotFrame(outMemDC, outBitmap, vx, vy, vw, vh, dpiScale)) {
- return true;
- }
- return CaptureVirtualScreen(outMemDC, outBitmap, vx, vy, vw, vh, dpiScale);
-}
-
-// 从预截屏位图读取像素颜色(逻辑坐标)
-static COLORREF GetPixelColorFromBitmap(HDC memDC, int x, int y, int vx, int vy, double dpiScale) {
- int lx = x - vx;
- int ly = y - vy;
- int px = (int)(lx * dpiScale + 0.5);
- int py = (int)(ly * dpiScale + 0.5);
- return GetPixel(memDC, px, py);
-}
-
-// COLORREF 转 HEX/RGB 字符串
-static void ColorrefToStrings(COLORREF color, char* hexBuf, char* rgbBuf) {
- int r = color & 0xFF;
- int g = (color >> 8) & 0xFF;
- int b = (color >> 16) & 0xFF;
- sprintf_s(hexBuf, 32, "#%02X%02X%02X", r, g, b);
- sprintf_s(rgbBuf, 32, "%d, %d, %d", r, g, b);
-}
-
-// 枚举窗口回调
-static BOOL CALLBACK SCEnumWindowsProc(HWND hwnd, LPARAM lParam) {
- auto* windows = reinterpret_cast*>(lParam);
-
- if (!IsWindowVisible(hwnd)) return TRUE;
-
- LONG_PTR exStyle = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
- if (exStyle & WS_EX_TOOLWINDOW) return TRUE;
-
- LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
- if (style == 0) return TRUE;
-
- // 检查是否为幽灵窗口(cloaked window)
- // 幽灵窗口虽然 IsWindowVisible 返回 true,但实际上不可见
- BOOL isCloaked = FALSE;
- HRESULT hrCloaked = DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &isCloaked, sizeof(isCloaked));
- if (SUCCEEDED(hrCloaked) && isCloaked) {
- return TRUE; // 跳过幽灵窗口
- }
-
- // 获取窗口类名以进行额外过滤
- const int MAX_CLASS_NAME = 256;
- WCHAR className[MAX_CLASS_NAME] = {0};
- int classNameLen = GetClassNameW(hwnd, className, MAX_CLASS_NAME);
-
- // 过滤某些特殊的系统窗口类
- if (classNameLen > 0) {
- // Windows 输入法相关窗口(如 Microsoft Text Input Application)
- if (wcscmp(className, L"Windows.UI.Core.CoreWindow") == 0) {
- // 对于 CoreWindow,再次确认是否真的可见(通过检查是否有有效的可视化区域)
- RECT clientRect;
- if (!GetClientRect(hwnd, &clientRect)) return TRUE;
-
- // 如果客户区太小,很可能是输入法等后台窗口
- int clientW = clientRect.right - clientRect.left;
- int clientH = clientRect.bottom - clientRect.top;
- if (clientW < 100 || clientH < 100) return TRUE;
- }
-
- // 过滤 ApplicationFrameWindow 的空壳窗口
- // UWP 应用在未激活时可能留下空的 ApplicationFrameWindow
- if (wcscmp(className, L"ApplicationFrameWindow") == 0) {
- // 检查窗口是否被最小化或隐藏
- if (IsIconic(hwnd)) return TRUE;
-
- // 检查是否真的有内容(通过检查窗口透明度或其他属性)
- BYTE opacity = 255;
- DWORD cloakedReason = 0;
- DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &cloakedReason, sizeof(cloakedReason));
- if (cloakedReason != 0) return TRUE;
- }
- }
-
- int titleLen = GetWindowTextLengthW(hwnd);
- if (titleLen == 0) return TRUE;
-
- std::wstring title(titleLen + 1, L'\0');
- GetWindowTextW(hwnd, &title[0], titleLen + 1);
- title.resize(titleLen);
-
- if (hwnd == GetDesktopWindow()) return TRUE;
-
- // 使用 DWM 获取精确边界
- RECT rect = {};
- HRESULT hr = DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &rect, sizeof(rect));
- if (FAILED(hr)) {
- if (!GetWindowRect(hwnd, &rect)) return TRUE;
- }
-
- int w = rect.right - rect.left;
- int h = rect.bottom - rect.top;
- if (w < 50 || h < 50) return TRUE;
-
- SCWindowInfo info;
- info.hwnd = hwnd;
- info.rect = rect;
- info.title = title;
- windows->push_back(info);
- return TRUE;
-}
-
-// 枚举窗口
-static std::vector EnumWindowsForCapture() {
- std::vector windows;
- EnumWindows(SCEnumWindowsProc, reinterpret_cast(&windows));
- return windows;
-}
-
-// 查找鼠标下方的窗口
-static int FindWindowAtPoint(const std::vector& windows, int x, int y) {
- for (size_t i = 0; i < windows.size(); i++) {
- const RECT& r = windows[i].rect;
- if (x >= r.left && x < r.right && y >= r.top && y < r.bottom)
- return (int)i;
- }
- return -1;
-}
-
-// 计算浮窗位置(优先右下,超出则翻转)
-static void CalcPanelPosition(int mx, int my, int vx, int vy, int vw, int vh,
- const SCPanelMetrics& m, int& px, int& py) {
- int sr = vx + vw;
- int sb = vy + vh;
- px = mx + m.margin;
- py = my + m.margin;
- if (px + m.w > sr) px = mx - m.w - m.margin;
- if (py + m.h > sb) py = my - m.h - m.margin;
- if (px < vx) px = vx + m.margin;
- if (py < vy) py = vy + m.margin;
-}
-
-// 调整选区时放大镜面板位置:放在被拖手柄的"外侧",避免遮挡正在调整的选区。
-// 左/右手柄置选区左/右外侧,顶/底置上下外侧,四角置对角外侧;边手柄在垂直方向居中。
-// 贴屏边放不下则翻到对侧,仍放不下则贴屏边(覆盖选区可接受,属边界情况)。
-static void CalcResizePanelPosition(int handle, const RECT& sel,
- int vx, int vy, int vw, int vh, const SCPanelMetrics& m, int& px, int& py) {
- bool movesL = handle == RH_Left || handle == RH_TopLeft || handle == RH_BottomLeft;
- bool movesR = handle == RH_Right || handle == RH_TopRight || handle == RH_BottomRight;
- bool movesT = handle == RH_Top || handle == RH_TopLeft || handle == RH_TopRight;
- bool movesB = handle == RH_Bottom || handle == RH_BottomLeft || handle == RH_BottomRight;
- if (movesL) px = sel.left - m.w - m.margin;
- else if (movesR) px = sel.right + m.margin;
- else px = (sel.left + sel.right) / 2 - m.w / 2;
- if (movesT) py = sel.top - m.h - m.margin;
- else if (movesB) py = sel.bottom + m.margin;
- else py = (sel.top + sel.bottom) / 2 - m.h / 2;
- // 外侧放不下 -> 翻到对侧;对侧仍放不下 -> 贴屏边
- if (px < vx) px = sel.right + m.margin;
- if (px + m.w > vx + vw) px = sel.left - m.w - m.margin;
- if (px < vx) px = vx + m.margin;
- if (px + m.w > vx + vw) px = vx + vw - m.w - m.margin;
- if (py < vy) py = sel.bottom + m.margin;
- if (py + m.h > vy + vh) py = sel.top - m.h - m.margin;
- if (py < vy) py = vy + m.margin;
- if (py + m.h > vy + vh) py = vy + vh - m.h - m.margin;
-}
-
-// 从预截屏位图恢复脏区域到后台缓冲
-static void RestoreDirtyRegion(HDC backDC, HDC memDC, const RECT& dirty, double dpiScale) {
- int w = dirty.right - dirty.left;
- int h = dirty.bottom - dirty.top;
- if (w <= 0 || h <= 0) return;
- int x = (std::max)((int)dirty.left, 0);
- int y = (std::max)((int)dirty.top, 0);
- w = dirty.right - x;
- h = dirty.bottom - y;
- if (dpiScale > 1.01 || dpiScale < 0.99) {
- int px = (int)(x * dpiScale + 0.5);
- int py = (int)(y * dpiScale + 0.5);
- int pw = (int)(w * dpiScale + 0.5);
- int ph = (int)(h * dpiScale + 0.5);
- StretchBlt(backDC, x, y, w, h, memDC, px, py, pw, ph, SRCCOPY);
- } else {
- BitBlt(backDC, x, y, w, h, memDC, x, y, SRCCOPY);
- }
-}
-
-// 扩展矩形
-static RECT InflateRectBy(const RECT& r, int margin) {
- return { r.left - margin, r.top - margin, r.right + margin, r.bottom + margin };
-}
-
-// 矩形是否有效(宽高 > 0)
-static bool IsValidRect(const RECT& r) {
- return r.right > r.left && r.bottom > r.top;
-}
-
-// 两矩形并集的外包矩形(安全处理零矩形:若一方无效则返回另一方)。
-// 用于计算"旧位置 ∪ 新位置"的脏区域外包。
-static RECT UnionRectSafe(const RECT& a, const RECT& b) {
- bool va = IsValidRect(a), vb = IsValidRect(b);
- if (!va && !vb) return {0, 0, 0, 0};
- if (!va) return b;
- if (!vb) return a;
- return { (std::min)(a.left, b.left), (std::min)(a.top, b.top),
- (std::max)(a.right, b.right), (std::max)(a.bottom, b.bottom) };
-}
-
-// ---- 绘制函数 ----
-
-// 绘制放大镜 + 鼠标信息面板
-static void DrawInfoPanel(HDC hdc, int panelX, int panelY, COLORREF color,
- HDC memDC, int vx, int vy, int mx, int my, double dpiScale,
- const SCGdiResources& gdi, const SCPanelMetrics& m) {
- HGDIOBJ oldBrush = SelectObject(hdc, gdi.bgBrush);
- HGDIOBJ oldPen = SelectObject(hdc, gdi.borderPen);
-
- // 圆角矩形背景
- RoundRect(hdc, panelX, panelY, panelX + m.w, panelY + m.h,
- m.radius, m.radius);
-
- // 放大镜:从物理尺寸位图取像素
- int srcW = m.w / SC_ZOOM_FACTOR;
- int srcH = m.magnifierH / SC_ZOOM_FACTOR;
- int mxLogical = mx - vx;
- int myLogical = my - vy;
- int mxPhysical = (int)(mxLogical * dpiScale + 0.5);
- int myPhysical = (int)(myLogical * dpiScale + 0.5);
- int srcWPhysical = (int)(srcW * dpiScale + 0.5);
- int srcHPhysical = (int)(srcH * dpiScale + 0.5);
- int srcXPhysical = mxPhysical - srcWPhysical / 2;
- int srcYPhysical = myPhysical - srcHPhysical / 2;
-
- int magX = panelX + m.borderPad;
- int magY = panelY + m.borderPad;
- int magW = m.w - m.borderPad * 2;
- int magH = m.magnifierH - m.borderPad;
-
- StretchBlt(hdc, magX, magY, magW, magH, memDC,
- (std::max)(srcXPhysical, 0), (std::max)(srcYPhysical, 0),
- srcWPhysical, srcHPhysical, SRCCOPY);
-
- // 十字准星
- SelectObject(hdc, gdi.crosshairPen);
- int cx = magX + magW / 2;
- int cy = magY + magH / 2;
- MoveToEx(hdc, magX, cy, NULL); LineTo(hdc, magX + magW, cy);
- MoveToEx(hdc, cx, magY, NULL); LineTo(hdc, cx, magY + magH);
-
- // 文字信息
- SetBkMode(hdc, TRANSPARENT);
- SetTextColor(hdc, RGB(255, 255, 255));
- HGDIOBJ oldFont = SelectObject(hdc, gdi.smallFont);
-
- char hexBuf[32], rgbBuf[32];
- ColorrefToStrings(color, hexBuf, rgbBuf);
- char posBuf[64];
- sprintf_s(posBuf, "%d, %d", mx, my);
-
- int labelX = panelX + m.labelPad;
- int valueRightX = panelX + m.w - m.labelPad;
-
- // 获取文字高度
- SIZE textSize;
- GetTextExtentPoint32W(hdc, L"测试", 2, &textSize);
- int lineH = textSize.cy;
- int infoY = panelY + m.h - m.labelPad - lineH * 3;
-
- // 辅助:右对齐绘制
- auto drawRightAligned = [&](const wchar_t* text, int len, int rx, int ry) {
- SIZE sz;
- GetTextExtentPoint32W(hdc, text, len, &sz);
- TextOutW(hdc, rx - sz.cx, ry, text, len);
- };
-
- // 坐标
- TextOutW(hdc, labelX, infoY, L"坐标", 2);
- std::wstring posW(posBuf, posBuf + strlen(posBuf));
- drawRightAligned(posW.c_str(), (int)posW.size(), valueRightX, infoY);
-
- // HEX
- TextOutW(hdc, labelX, infoY + lineH, L"HEX", 3);
- std::wstring hexW(hexBuf, hexBuf + strlen(hexBuf));
- drawRightAligned(hexW.c_str(), (int)hexW.size(), valueRightX, infoY + lineH);
-
- // RGB
- TextOutW(hdc, labelX, infoY + lineH * 2, L"RGB", 3);
- std::wstring rgbW(rgbBuf, rgbBuf + strlen(rgbBuf));
- drawRightAligned(rgbW.c_str(), (int)rgbW.size(), valueRightX, infoY + lineH * 2);
-
- SelectObject(hdc, oldFont);
- SelectObject(hdc, oldBrush);
- SelectObject(hdc, oldPen);
-}
-
-// 绘制尺寸标签,返回标签矩形
-static RECT DrawSizeLabel(HDC hdc, int width, int height,
- int refLeft, int refTop, int refRight, int refBottom,
- int virtualW, int virtualH, const SCGdiResources& gdi, const SCPanelMetrics& m) {
- RECT empty = {0, 0, 0, 0};
- if (width < 0 || height < 0) return empty;
-
- wchar_t sizeBuf[64];
- swprintf_s(sizeBuf, L"%d × %d", width, height);
- int sizeLen = (int)wcslen(sizeBuf);
-
- HGDIOBJ oldFont = SelectObject(hdc, gdi.smallFont);
- SIZE textSize;
- GetTextExtentPoint32W(hdc, sizeBuf, sizeLen, &textSize);
-
- int labelW = textSize.cx + m.sizeLabelPadX * 2;
- int labelH = textSize.cy + m.sizeLabelPadY;
-
- int lx = refLeft;
- int ly = refTop - labelH - m.sizeLabelGap;
- if (ly < 0) {
- lx = refLeft + m.sizeLabelGap;
- ly = refTop + m.sizeLabelGap;
- if (lx + labelW > virtualW) lx = virtualW - labelW - m.sizeLabelGap;
- if (ly + labelH > virtualH) ly = virtualH - labelH - m.sizeLabelGap;
- if (lx + labelW > refRight) lx = refRight - labelW - m.sizeLabelGap;
- if (ly + labelH > refBottom) ly = refBottom - labelH - m.sizeLabelGap;
- }
- if (lx < 0) lx = 0;
- if (ly < 0) ly = 0;
- if (lx + labelW > virtualW) lx = virtualW - labelW;
- if (ly + labelH > virtualH) ly = virtualH - labelH;
-
- HGDIOBJ oldBrush = SelectObject(hdc, gdi.bgBrush);
- HGDIOBJ oldPen = SelectObject(hdc, gdi.borderPen);
- RoundRect(hdc, lx, ly, lx + labelW, ly + labelH, m.radius, m.radius);
-
- SetBkMode(hdc, TRANSPARENT);
- SetTextColor(hdc, RGB(255, 255, 255));
- TextOutW(hdc, lx + m.sizeLabelPadX, ly + m.borderPad, sizeBuf, sizeLen);
-
- SelectObject(hdc, oldFont);
- SelectObject(hdc, oldBrush);
- SelectObject(hdc, oldPen);
-
- RECT result = { lx, ly, lx + labelW, ly + labelH };
- return result;
-}
-
-// 绘制选区矩形边框 + 尺寸标签
-static RECT DrawSelection(HDC hdc, int x1, int y1, int x2, int y2,
- int vx, int vy, int vw, int vh, const SCGdiResources& gdi, const SCPanelMetrics& m) {
- int left = (std::min)(x1, x2) - vx;
- int top = (std::min)(y1, y2) - vy;
- int right = (std::max)(x1, x2) - vx;
- int bottom = (std::max)(y1, y2) - vy;
-
- HGDIOBJ oldPen = SelectObject(hdc, gdi.selectionPen);
- HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
- Rectangle(hdc, left, top, right, bottom);
-
- int sizeW = right - left;
- int sizeH = bottom - top;
- RECT labelRect = DrawSizeLabel(hdc, sizeW, sizeH, left, top, right, bottom, vw, vh, gdi, m);
-
- SelectObject(hdc, oldPen);
- SelectObject(hdc, oldBrush);
- return labelRect;
-}
-
-// 绘制窗口高亮边框
-static void DrawWindowHighlight(HDC hdc, const RECT& rect, int vx, int vy, const SCGdiResources& gdi) {
- int left = rect.left - vx;
- int top = rect.top - vy;
- int right = rect.right - vx;
- int bottom = rect.bottom - vy;
-
- HGDIOBJ oldPen = SelectObject(hdc, gdi.highlightPen);
- HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
- Rectangle(hdc, left, top, right, bottom);
- SelectObject(hdc, oldPen);
- SelectObject(hdc, oldBrush);
-}
-
-// 绘制选区外遮罩(微信风格)
-// 在 backDC 上对"选区外部"区域 AlphaBlend 一层半透明黑色,
-// 选区内部不绘制,保持原始截图清晰。
-// rect 为相对虚拟屏幕的逻辑坐标(已减去 virtualX/virtualY)。
-// radius>0 时额外给选区四角的"角帽"(方框内、圆角弧外)叠同色遮罩,
-// 使圆角内不残留清晰直角三角;角帽用 GDI+ 抗锯齿路径填充,与 AA 圆角边框一致。
-static void DrawDimMask(HDC backDC, const SCGdiResources& gdi,
- int selLeft, int selTop, int selRight, int selBottom,
- int virtualW, int virtualH, int radius) {
- if (!gdi.maskDC || !gdi.maskBitmap) return;
-
- BLENDFUNCTION blend;
- blend.BlendOp = AC_SRC_OVER;
- blend.BlendFlags = 0;
- blend.SourceConstantAlpha = SC_MASK_ALPHA;
- blend.AlphaFormat = 0; // 不使用 per-pixel alpha,仅用常量 alpha
-
- // 将虚拟屏幕按选区划分为四周四块,逐块 AlphaBlend 遮罩
- // 上:x ∈ [0, vw], y ∈ [0, selTop]
- if (selTop > 0) {
- AlphaBlend(backDC, 0, 0, virtualW, selTop,
- gdi.maskDC, 0, 0, virtualW, selTop, blend);
- }
- // 下:x ∈ [0, vw], y ∈ [selBottom, vh]
- if (selBottom < virtualH) {
- AlphaBlend(backDC, 0, selBottom, virtualW, virtualH - selBottom,
- gdi.maskDC, 0, selBottom, virtualW, virtualH - selBottom, blend);
- }
- // 左:x ∈ [0, selLeft], y ∈ [selTop, selBottom]
- if (selLeft > 0 && selBottom > selTop) {
- AlphaBlend(backDC, 0, selTop, selLeft, selBottom - selTop,
- gdi.maskDC, 0, selTop, selLeft, selBottom - selTop, blend);
- }
- // 右:x ∈ [selRight, vw], y ∈ [selTop, selBottom]
- if (selRight < virtualW && selBottom > selTop) {
- AlphaBlend(backDC, selRight, selTop, virtualW - selRight, selBottom - selTop,
- gdi.maskDC, selRight, selTop, virtualW - selRight, selBottom - selTop, blend);
- }
-
- // 圆角角帽:选区方框内、圆角弧外的四角区域,叠加同色遮罩。
- // 用 GDI+ GraphicsPath 在每角构造"外两条直边 + 内凹四分之一弧"的封闭图形,
- // FillPath 填充得到角帽,弧边抗锯齿。遮罩色与上面 AlphaBlend 一致(黑+SC_MASK_ALPHA)。
- if (radius > 0 && selRight > selLeft && selBottom > selTop) {
- int x = selLeft, y = selTop;
- int w = selRight - selLeft;
- int h = selBottom - selTop;
- int r = (std::min)(radius, (std::min)(w, h) / 2);
- if (r >= 1) {
- Gdiplus::Graphics graphics(backDC);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- Gdiplus::SolidBrush dimBrush(Gdiplus::Color(SC_MASK_ALPHA, 0, 0, 0));
- Gdiplus::GraphicsPath capPath;
- // 左上角:上边 → 内凹弧 → 左边
- capPath.AddLine(x, y, x + r, y);
- capPath.AddArc(x, y, r * 2, r * 2, 270.0f, -90.0f);
- capPath.AddLine(x, y + r, x, y);
- capPath.CloseFigure();
- // 右上角:上边 → 右边 → 内凹弧
- capPath.AddLine(x + w - r, y, x + w, y);
- capPath.AddLine(x + w, y, x + w, y + r);
- capPath.AddArc(x + w - r * 2, y, r * 2, r * 2, 0.0f, -90.0f);
- capPath.CloseFigure();
- // 右下角:右边 → 下边 → 内凹弧
- capPath.AddLine(x + w, y + h - r, x + w, y + h);
- capPath.AddLine(x + w, y + h, x + w - r, y + h);
- capPath.AddArc(x + w - r * 2, y + h - r * 2, r * 2, r * 2, 90.0f, -90.0f);
- capPath.CloseFigure();
- // 左下角:左边 → 下边 → 内凹弧
- capPath.AddLine(x, y + h - r, x, y + h);
- capPath.AddLine(x, y + h, x + r, y + h);
- capPath.AddArc(x, y + h - r * 2, r * 2, r * 2, 90.0f, 90.0f);
- capPath.CloseFigure();
- graphics.FillPath(&dimBrush, &capPath);
- }
- }
-}
-
-// ---- 确认态辅助函数 ----
-
-// 规范化矩形(保证 left= r.left && x < r.right && y >= r.top && y < r.bottom;
-}
-
-// 圆角手柄中心位置:选区四个角内侧,沿各自对角线内移 d = clamp(inset + radius, 0, maxR)。
-// radius 增大时四个手柄同步沿对角线向中心滑动(轨迹始终在该角的对角线上);
-// 半径与手柄位置一一对应,松手后 radius 保持,手柄即停在原地(不回弹到静止位)。
-// inset 为 DPI 缩放后的静止内缩(radius=0 时手柄位置,避开角 resize 手柄);
-// corner 取 RH_CornerRadiusTL/TR/BL/BR。sel 可为绝对坐标(命中测试用)或选区相对坐标(绘制用)。
-static void CornerRadiusHandleCenter(const RECT& sel, int inset, int radius, int corner, int& cx, int& cy) {
- int w = sel.right - sel.left;
- int h = sel.bottom - sel.top;
- int maxR = (std::min)(w, h) / 2;
- if (maxR < 0) maxR = 0;
- int d = inset + radius;
- if (d > maxR) d = maxR; // 不越过中心 / 不出选区
- if (d < 0) d = 0;
- switch (corner) {
- case RH_CornerRadiusTL: cx = sel.left + d; cy = sel.top + d; break;
- case RH_CornerRadiusTR: cx = sel.right - d; cy = sel.top + d; break;
- case RH_CornerRadiusBL: cx = sel.left + d; cy = sel.bottom - d; break;
- case RH_CornerRadiusBR: cx = sel.right - d; cy = sel.bottom - d; break;
- default: cx = sel.left + d; cy = sel.top + d; break;
- }
-}
-
-// 命中测试圆角手柄(绝对坐标)。依次测试四个角,返回命中的角手柄或 RH_None。
-// 命中框大小沿用 handleSize,与 resize 手柄一致;手柄位置随 radius 移动,故命中也按 radius 计算。
-static int HitTestCornerRadiusHandle(int x, int y, const RECT& sel, int handleSize, int inset, int radius) {
- int corners[] = { RH_CornerRadiusTL, RH_CornerRadiusTR, RH_CornerRadiusBL, RH_CornerRadiusBR };
- for (int c : corners) {
- int cx, cy;
- CornerRadiusHandleCenter(sel, inset, radius, c, cx, cy);
- RECT box = { cx - handleSize, cy - handleSize, cx + handleSize, cy + handleSize };
- if (PointInRect(x, y, box)) return c;
- }
- return RH_None;
-}
-
-// 找出鼠标"靠近"的倒角手柄(绝对坐标):感应区 = 命中框外再扩 proximityMargin。
-// 感应区大于命中区,使手柄在鼠标靠近(尚未进入命中框)时即显现;仅返回最近的那一个角,
-// 选区四角通常互不相邻,足够大时鼠标只会靠近其一。
-static int FindNearestCornerRadiusHandle(int x, int y, const RECT& sel,
- int handleSize, int inset, int radius,
- int proximityMargin) {
- int corners[] = { RH_CornerRadiusTL, RH_CornerRadiusTR, RH_CornerRadiusBL, RH_CornerRadiusBR };
- int sense = handleSize + proximityMargin; // 感应半宽 = 命中半宽 + 靠近余量
- int best = RH_None;
- int bestDist = 0x7FFFFFFF; // 切比雪夫距离,越小越近
- for (int c : corners) {
- int cx, cy;
- CornerRadiusHandleCenter(sel, inset, radius, c, cx, cy);
- int dx = x - cx; if (dx < 0) dx = -dx;
- int dy = y - cy; if (dy < 0) dy = -dy;
- if (dx <= sense && dy <= sense) {
- int dist = (dx > dy) ? dx : dy;
- if (dist < bestDist) { bestDist = dist; best = c; }
- }
- }
- return best;
-}
-
-// 倒角手柄附近脏区(backDC 相对坐标):以手柄中心为基点,扩 handleMargin 覆盖半径+描边/抗锯齿。
-// 用于鼠标靠近/离开手柄时局部重绘,避免全屏刷新。
-static RECT CornerHandleDirtyRect(const CaptureContext* ctx, int corner) {
- RECT r = {0, 0, 0, 0};
- if (!IsCornerRadiusHandle(corner)) return r;
- int cx, cy;
- CornerRadiusHandleCenter(ctx->selection, ctx->handleMetrics.cornerKnobInset,
- ctx->selectionCornerRadius, corner, cx, cy);
- int m = ctx->handleMetrics.handleMargin;
- r.left = cx - m - ctx->virtualX;
- r.top = cy - m - ctx->virtualY;
- r.right = cx + m + 1 - ctx->virtualX;
- r.bottom = cy + m + 1 - ctx->virtualY;
- return r;
-}
-
-// 命中测试调整手柄,返回 ResizeHandle(绝对坐标)。handleSize 为 DPI 缩放后的命中半宽。
-static int HitTestHandle(int x, int y, const RECT& sel, int handleSize) {
- int hs = handleSize;
- int cx = (sel.left + sel.right) / 2;
- int cy = (sel.top + sel.bottom) / 2;
- // 8 个手柄的判定矩形(顺序与 ResizeHandle 一致)
- struct { int hx, hy; int handle; } tests[] = {
- { sel.left, cy, RH_Left },
- { sel.right, cy, RH_Right },
- { cx, sel.top, RH_Top },
- { cx, sel.bottom, RH_Bottom },
- { sel.left, sel.top, RH_TopLeft },
- { sel.right, sel.top, RH_TopRight },
- { sel.left, sel.bottom, RH_BottomLeft },
- { sel.right, sel.bottom, RH_BottomRight },
- };
- for (auto& t : tests) {
- RECT box = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
- if (PointInRect(x, y, box)) return t.handle;
- }
- return RH_None;
-}
-
-// 根据手柄返回对应的系统鼠标光标
-static LPCWSTR HandleCursor(int handle) {
- switch (handle) {
- case RH_Left:
- case RH_Right:
- return (LPCWSTR)IDC_SIZEWE;
- case RH_Top:
- case RH_Bottom:
- return (LPCWSTR)IDC_SIZENS;
- case RH_TopLeft:
- case RH_BottomRight:
- return (LPCWSTR)IDC_SIZENWSE;
- case RH_TopRight:
- case RH_BottomLeft:
- return (LPCWSTR)IDC_SIZENESW;
- case RH_ArrowStart:
- case RH_ArrowEnd:
- // 箭头端点拖拽:固定四向箭头,与悬停态一致
- return (LPCWSTR)IDC_SIZEALL;
- // 圆角手柄:对角线方向拖拽(向内增大半径),按所在角选对应对角光标。
- case RH_CornerRadiusTL:
- case RH_CornerRadiusBR:
- return (LPCWSTR)IDC_SIZENWSE;
- case RH_CornerRadiusTR:
- case RH_CornerRadiusBL:
- return (LPCWSTR)IDC_SIZENESW;
- default:
- return (LPCWSTR)IDC_ARROW;
- }
-}
-
-// 计算工具栏位置:优先选区下方,放不下则上方,再放不下则选区内底部
-static void CalcToolbarPosition(const RECT& selRel, int virtualW, int virtualH,
- const SCToolbarMetrics& m, RECT& out) {
- int tw = CalcToolbarWidth(m);
- int th = m.h;
- int margin = m.margin;
-
- // 默认水平居中于选区,下方
- int x = selRel.left + ((selRel.right - selRel.left) - tw) / 2;
- int y = selRel.bottom + margin;
-
- // 下方放不下 -> 上方
- if (y + th > virtualH) {
- y = selRel.top - margin - th;
- }
- // 上方也放不下(选区很高),放选区内底部
- if (y < 0) {
- y = selRel.bottom - margin - th;
- if (y < selRel.top) y = selRel.top + margin;
- }
- // 水平边界约束
- if (x + tw > virtualW) x = virtualW - tw - margin;
- if (x < 0) x = margin;
-
- out.left = x;
- out.top = y;
- out.right = x + tw;
- out.bottom = y + th;
-}
-
-// 命中测试工具栏按钮,返回 ToolButton 序号(相对虚拟屏幕坐标),-1 = none
-static int HitTestToolbar(int x, int y, const RECT& toolbarRect, const SCToolbarMetrics& m) {
- if (!PointInRect(x, y, toolbarRect)) return -1;
- int idx = (x - toolbarRect.left - m.border - m.pad) / (m.btn + m.gap);
- if (idx < 0 || idx >= TB_Count) return -1;
- return idx;
-}
-
-// 绘制单个工具图标:从 SCIconCache 取预渲染位图,AlphaBlend 居中绘制。
-// cx,cy 为按钮中心;iconSize 为缓存位图边长(物理像素)。
-static void DrawToolbarIcon(HDC hdc, int cx, int cy, int btn, bool active,
- const SCIconCache& icons) {
- HBITMAP bmp = icons.Get(btn, active);
- if (!bmp) return;
-
- int sz = icons.iconSize;
- int x = cx - sz / 2;
- int y = cy - sz / 2;
-
- HDC srcDC = CreateCompatibleDC(hdc);
- if (!srcDC) return;
- HGDIOBJ oldBmp = SelectObject(srcDC, bmp);
-
- BLENDFUNCTION blend;
- blend.BlendOp = AC_SRC_OVER;
- blend.BlendFlags = 0;
- blend.SourceConstantAlpha = 255;
- blend.AlphaFormat = AC_SRC_ALPHA; // 使用 per-pixel alpha(位图已预乘)
- AlphaBlend(hdc, x, y, sz, sz, srcDC, 0, 0, sz, sz, blend);
-
- SelectObject(srcDC, oldBmp);
- DeleteDC(srcDC);
-}
-
-// 绘制选区调整手柄(8 个),传入相对坐标矩形。handleSize 为 DPI 缩放后的边长。
-static void DrawResizeHandles(HDC hdc, const RECT& selRel, int handleSize) {
- int hs = handleSize;
- int half = hs / 2;
- int cx = (selRel.left + selRel.right) / 2;
- int cy = (selRel.top + selRel.bottom) / 2;
- int pts[][2] = {
- { selRel.left, cy },
- { selRel.right, cy },
- { cx, selRel.top },
- { cx, selRel.bottom },
- { selRel.left, selRel.top },
- { selRel.right, selRel.top },
- { selRel.left, selRel.bottom },
- { selRel.right, selRel.bottom },
- };
- // GDI+ 抗锯齿绘制:方块边缘像素半透明过渡,消除 GDI Rectangle 的硬锯齿。
- // 颜色沿用原 GDI 资源:蓝色填充 RGB(0,136,255) + 白色 1px 描边 RGB(255,255,255)。
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- Gdiplus::SolidBrush fillBrush(Gdiplus::Color(255, 0, 136, 255));
- Gdiplus::Pen borderPen(Gdiplus::Color(255, 255, 255, 255), 1.0f);
- Gdiplus::GraphicsPath path;
- for (auto& p : pts) {
- // hs×hs 匹配原 GDI Rectangle(l,t,r,b) 的 [l,r-1]×[t,b-1] 像素范围(2*half=hs)。
- path.AddRectangle(Gdiplus::Rect(p[0] - half, p[1] - half, hs, hs));
- }
- graphics.FillPath(&fillBrush, &path);
- graphics.DrawPath(&borderPen, &path);
-}
-
-// 绘制单个倒角拖拽手柄(选区四角内侧其一):白底圆 + 蓝环 + 朝向选区角的四分之一弧点缀。
-// 圆形与方形 resize 手柄区分;GDI+ 抗锯齿。selRel 为选区相对 backDC 坐标。
-// corner 指定画哪一个角(RH_CornerRadiusTL/TR/BL/BR),仅绘制该角手柄(默认隐藏,靠近/拖拽时调用方传入)。
-// 沿对角线内移 d = clamp(inset+radius, 0, maxR):radius 增大时沿对角线向中心滑动,轨迹恒在对角线上;
-// 松手后 radius 保持,手柄停在原地(不回弹到静止位 inset)。
-// handleSize/inset 为 DPI 缩放后值;radius = ctx->selectionCornerRadius。
-static void DrawCornerRadiusHandle(HDC hdc, const RECT& selRel, int handleSize, int inset, int radius, int corner) {
- if (!IsCornerRadiusHandle(corner)) return;
- int half = handleSize / 2;
- int gr = (std::max)(1, half - 2);
- int cx, cy;
- CornerRadiusHandleCenter(selRel, inset, radius, corner, cx, cy);
- // 弧起始角(GDI+ 顺时针,0°=右/3 点):落在朝向选区角的象限。
- // TL: 180°→270°(左上象限) TR: 270°→0°(右上象限)
- // BR: 0°→90°(右下象限) BL: 90°→180°(左下象限)
- float start = 0.0f;
- switch (corner) {
- case RH_CornerRadiusTL: start = 180.0f; break;
- case RH_CornerRadiusTR: start = 270.0f; break;
- case RH_CornerRadiusBR: start = 0.0f; break;
- case RH_CornerRadiusBL: start = 90.0f; break;
- }
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- Gdiplus::SolidBrush fillBrush(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::Pen ringPen(Gdiplus::Color(255, 0, 136, 255), 1.5f);
- Gdiplus::Pen glyphPen(Gdiplus::Color(255, 0, 136, 255), 1.0f);
- Gdiplus::GraphicsPath path;
- path.AddEllipse(cx - half, cy - half, half * 2, half * 2);
- graphics.FillPath(&fillBrush, &path);
- graphics.DrawPath(&ringPen, &path);
- graphics.DrawArc(&glyphPen, cx - gr, cy - gr, gr * 2, gr * 2, start, 90.0f);
-}
-
-// 绘制确认态选区边框(细蓝框)
-// 前置声明:DrawConfirmedBorder 在 AddRoundedRect 之前定义,圆角分支需调用它。
-static void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius);
-
-// 绘制确认态选区边框(细蓝框)。radius<1 为直角(沿用 GDI Rectangle);
-// radius≥1 改用 GDI+ 圆角路径描边,抗锯齿且与 selectionPen(蓝 1px)一致。
-static void DrawConfirmedBorder(HDC hdc, const RECT& selRel, const SCGdiResources& gdi, int radius) {
- if (radius < 1) {
- HGDIOBJ oldPen = SelectObject(hdc, gdi.selectionPen);
- HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
- Rectangle(hdc, selRel.left, selRel.top, selRel.right, selRel.bottom);
- SelectObject(hdc, oldPen);
- SelectObject(hdc, oldBrush);
- return;
- }
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- Gdiplus::Pen pen(Gdiplus::Color(255, 0, 136, 255), 1.0f);
- Gdiplus::GraphicsPath path;
- AddRoundedRect(path, selRel.left, selRel.top,
- selRel.right - selRel.left, selRel.bottom - selRel.top, radius);
- graphics.DrawPath(&pen, &path);
-}
-
-// 用 GDI+ 圆角矩形路径填充 outPath(抗锯齿绘制的基础)。x,y,w,h 为整数像素矩形,
-// radius 为圆角半径(自动钳制为不超过短边一半,避免重叠畸变)。
-// GDI+ 的 GraphicsPath 不可拷贝,故用 out 参数而非返回值。
-static void AddRoundedRect(Gdiplus::GraphicsPath& outPath, int x, int y, int w, int h, int radius) {
- int r = (std::min)(radius, (std::min)(w, h) / 2);
- if (r < 1) r = 1;
- Gdiplus::Rect rect(x, y, w, h);
- outPath.AddArc(rect.X, rect.Y, r * 2, r * 2, 180, 90);
- outPath.AddArc(rect.GetRight() - r * 2, rect.Y, r * 2, r * 2, 270, 90);
- outPath.AddArc(rect.GetRight() - r * 2, rect.GetBottom() - r * 2, r * 2, r * 2, 0, 90);
- outPath.AddArc(rect.X, rect.GetBottom() - r * 2, r * 2, r * 2, 90, 90);
- outPath.CloseFigure();
-}
-
-// 绘制悬浮工具栏(白底圆角 + 按钮图标 + 分组分隔线)
-// 几何与图标尺寸均按 metrics(DPI 缩放)计算;图标从 icons 缓存取。
-static void DrawToolbar(HDC hdc, const RECT& toolbarRect,
- int hoverBtn, int activeTool, const SCGdiResources& gdi,
- const SCToolbarMetrics& m, const SCIconCache& icons) {
- // 按钮在工具栏高度内的垂直留白
- int btnPad = (m.h - m.btn) / 2;
-
- // ---- 第一遍:GDI+ 抗锯齿绘制工具栏圆角背景 + 按钮圆角高亮 ----
- // GDI 的 RoundRect/FillRect 不支持抗锯齿,圆角边缘有锯齿,故改用 GDI+。
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
- {
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
-
- int tw = toolbarRect.right - toolbarRect.left;
- int th = toolbarRect.bottom - toolbarRect.top;
-
- // 白色圆角背景填充(圆角外保持透明,露出后方截图)
- Gdiplus::SolidBrush whiteBrush(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::GraphicsPath bgPath;
- AddRoundedRect(bgPath, toolbarRect.left, toolbarRect.top, tw, th, m.radius);
- graphics.FillPath(&whiteBrush, &bgPath);
-
- // 1px 浅灰边框
- Gdiplus::Pen borderPen(Gdiplus::Color(255, 210, 210, 210), (Gdiplus::REAL)m.border);
- graphics.DrawPath(&borderPen, &bgPath);
-
- // 各按钮圆角高亮(hover/active)
- // 按钮高亮圆角半径:约为按钮边长的 1/8,视觉柔和
- int hlRadius = m.btn / 8;
- int hlInset = 2;
- int hlSize = m.btn - hlInset * 2;
- for (int i = 0; i < TB_Count; i++) {
- if (i == TB_Separator1 || i == TB_Separator2) continue;
- bool hover = (i == hoverBtn);
- bool active = (i == activeTool);
- if (!hover && !active) continue;
-
- int bx = toolbarRect.left + m.border + m.pad + i * (m.btn + m.gap);
- int by = toolbarRect.top + btnPad;
- // 选中态:主题蓝 #3B8BF2 叠白底 ~15% 的预混合浅蓝(225,237,253)
- // hover 态:极浅蓝(235,243,255)
- Gdiplus::Color hlColor = active
- ? Gdiplus::Color(255, 225, 237, 253)
- : Gdiplus::Color(255, 235, 243, 255);
- Gdiplus::SolidBrush hlBrush(hlColor);
- Gdiplus::GraphicsPath hlPath;
- AddRoundedRect(hlPath, bx + hlInset, by + hlInset, hlSize, hlSize, hlRadius);
- graphics.FillPath(&hlBrush, &hlPath);
- }
- }
-
- // ---- 第二遍:GDI 绘制分隔线 + 图标(位图直接 AlphaBlend,无需 AA)----
- for (int i = 0; i < TB_Count; i++) {
- int bx = toolbarRect.left + m.border + m.pad + i * (m.btn + m.gap);
- int by = toolbarRect.top + btnPad;
- RECT btnRect = { bx, by, bx + m.btn, by + m.btn };
-
- bool isSep = (i == TB_Separator1 || i == TB_Separator2);
- if (isSep) {
- // 分隔线
- int sx = bx + m.btn / 2;
- int sepInset = m.btn / 8 + 2;
- HGDIOBJ op = SelectObject(hdc, gdi.toolbarSepPen);
- MoveToEx(hdc, sx, btnRect.top + sepInset, NULL);
- LineTo(hdc, sx, btnRect.bottom - sepInset);
- SelectObject(hdc, op);
- continue;
- }
-
- bool active = (i == activeTool);
- int cx = bx + m.btn / 2;
- int cy = by + m.btn / 2;
- DrawToolbarIcon(hdc, cx, cy, i, active, icons);
- }
-}
-
-// ==================== 标注绘制(GDI+ 抗锯齿) ====================
-
-// 箭头头:在 (ex,ey) 方向(从 sx,sy 指来)画一个填充三角,大小随 thickness。
-// ox/oy 为整体偏移(覆盖层渲染时加 curSelRect.left/top;合成时为 0)。
-static void DrawArrowHead(Gdiplus::Graphics& graphics, Gdiplus::Brush& brush,
- float sx, float sy, float ex, float ey,
- float thickness, float ox, float oy) {
- float dx = ex - sx;
- float dy = ey - sy;
- float len = std::sqrt(dx * dx + dy * dy);
- if (len < 1.0f) return;
- // 单位向量(箭头朝向)
- float ux = dx / len;
- float uy = dy / len;
- // 箭头长度/半宽 ∝ thickness
- float headLen = thickness * 3.0f + 6.0f;
- float headHalfW = thickness * 1.8f + 4.0f;
- if (headLen > len) headLen = len * 0.6f;
-
- // 箭尖
- float tipX = ex + ox, tipY = ey + oy;
- // 底部中心(沿箭头方向后退 headLen)
- float baseX = ex - ux * headLen + ox;
- float baseY = ey - uy * headLen + oy;
- // 法线方向
- float nx = -uy, ny = ux;
- // 两翼
- float w1X = baseX + nx * headHalfW, w1Y = baseY + ny * headHalfW;
- float w2X = baseX - nx * headHalfW, w2Y = baseY - ny * headHalfW;
-
- Gdiplus::PointF pts[3] = {
- Gdiplus::PointF(tipX, tipY),
- Gdiplus::PointF(w1X, w1Y),
- Gdiplus::PointF(w2X, w2Y),
- };
- graphics.FillPolygon(&brush, pts, 3);
-}
-
-// 绘制单条标注(不含 clip)。
-// 标注坐标为绝对虚拟屏幕坐标;ox/oy 为绘制偏移,把绝对坐标换算到目标 DC 的局部坐标:
-// - 覆盖层(backDC):ox/oy = -virtualX/-virtualY(backDC 原点 = 虚拟屏幕左上角)。
-// - 合成(finalDC):ox/oy = -rect.left/-rect.top(finalDC 原点 = 选区左上角)。
-static void DrawOneAnnotation(Gdiplus::Graphics& graphics, const Annotation& a,
- float ox, float oy) {
- Gdiplus::Color c(GetRValue(a.color), GetGValue(a.color), GetBValue(a.color));
- float thick = (float)a.thickness;
- if (thick < 1.0f) thick = 1.0f;
- Gdiplus::Pen pen(c, thick);
- pen.SetLineJoin(Gdiplus::LineJoinRound);
- pen.SetStartCap(Gdiplus::LineCapRound);
- pen.SetEndCap(Gdiplus::LineCapRound);
- Gdiplus::SolidBrush brush(c);
-
- switch (a.type) {
- case AT_Rect: {
- float x = a.x1 + ox;
- float y = a.y1 + oy;
- float w = (float)(a.x2 - a.x1);
- float h = (float)(a.y2 - a.y1);
- graphics.DrawRectangle(&pen, (std::min)(x, x + w), (std::min)(y, y + h),
- std::fabs(w), std::fabs(h));
- break;
- }
- case AT_Circle: {
- float x = a.x1 + ox;
- float y = a.y1 + oy;
- float w = (float)(a.x2 - a.x1);
- float h = (float)(a.y2 - a.y1);
- graphics.DrawEllipse(&pen, (std::min)(x, x + w), (std::min)(y, y + h),
- std::fabs(w), std::fabs(h));
- break;
- }
- case AT_Arrow: {
- float sx = a.x1 + ox;
- float sy = a.y1 + oy;
- float ex = a.x2 + ox;
- float ey = a.y2 + oy;
- float dx = ex - sx, dy = ey - sy;
- float len = std::sqrt(dx * dx + dy * dy);
- if (len < 1.0f) break;
- // 箭头几何:机翼状——底边内凹(向尖端方向凹入 notch),两翼后掠,
- // 从箭身末端张开,呈「细尾→锥杆→张开两翼→尖」的箭矢形态。
- float headLen = thick * 4.0f + 8.0f;
- float headHalfW = thick * 2.4f + 5.0f;
- float notch = headLen * 0.4f; // 内凹深度:底边中点向尖端凹入
- if (headLen > len) headLen = len * 0.6f;
- float ux = dx / len, uy = dy / len;
- float nx = -uy, ny = ux;
- // 箭头底部中心(沿箭头方向后退 headLen)与内凹点
- float baseX = ex - ux * headLen;
- float baseY = ey - uy * headLen;
- float notchX = baseX + ux * notch;
- float notchY = baseY + uy * notch;
- // 箭身:起点细、终点粗的锥形。终点延伸至内凹点并略超出(overlap),
- // 由箭头覆盖重叠区,避免抗锯齿在拼接处留细缝;终点宽 < 两翼宽,
- // 使两翼从箭身末端明显张开、内凹缺口清晰可见。
- float startHalfW = (std::max)(thick * 0.5f, 0.75f);
- float endHalfW = headHalfW * 0.55f;
- float overlap = 1.5f;
- float bodyEndX = notchX + ux * overlap;
- float bodyEndY = notchY + uy * overlap;
- Gdiplus::PointF body[4] = {
- Gdiplus::PointF(sx + nx * startHalfW, sy + ny * startHalfW),
- Gdiplus::PointF(sx - nx * startHalfW, sy - ny * startHalfW),
- Gdiplus::PointF(bodyEndX - nx * endHalfW, bodyEndY - ny * endHalfW),
- Gdiplus::PointF(bodyEndX + nx * endHalfW, bodyEndY + ny * endHalfW),
- };
- graphics.FillPolygon(&brush, body, 4);
- // 机翼状箭头:尖 → 右翼 → 内凹点 → 左翼(底边内凹而非平直三角)
- Gdiplus::PointF head[4] = {
- Gdiplus::PointF(ex, ey),
- Gdiplus::PointF(baseX + nx * headHalfW, baseY + ny * headHalfW),
- Gdiplus::PointF(notchX, notchY),
- Gdiplus::PointF(baseX - nx * headHalfW, baseY - ny * headHalfW),
- };
- graphics.FillPolygon(&brush, head, 4);
- break;
- }
- case AT_Brush: {
- if (a.pts.size() < 2) break;
- std::vector pts;
- pts.reserve(a.pts.size());
- for (const POINT& p : a.pts) {
- pts.push_back(Gdiplus::PointF((float)(p.x) + ox, (float)(p.y) + oy));
- }
- graphics.DrawLines(&pen, pts.data(), (INT)pts.size());
- break;
- }
- case AT_Text: {
- if (a.text.empty()) break;
- // 字号 = thickness;用 GDI+ 字体绘制,顶部对齐到锚点 y1
- int fontPx = a.thickness;
- if (fontPx < 8) fontPx = 8;
- Gdiplus::FontFamily fontFamily(SC_FONT_FACE);
- Gdiplus::Font font(&fontFamily, (Gdiplus::REAL)fontPx, Gdiplus::FontStyleRegular,
- Gdiplus::UnitPixel);
- Gdiplus::SolidBrush textBrush(c);
- Gdiplus::StringFormat sf;
- sf.SetAlignment(Gdiplus::StringAlignmentNear);
- sf.SetLineAlignment(Gdiplus::StringAlignmentNear);
- // 用 MeasureString 得到合适布局矩形(避免被裁)
- Gdiplus::RectF layoutRect((Gdiplus::REAL)(a.x1 + ox), (Gdiplus::REAL)(a.y1 + oy),
- 10000.0f, (Gdiplus::REAL)(fontPx * 2));
- graphics.DrawString(a.text.c_str(), -1, &font, layoutRect, &sf, &textBrush);
- break;
- }
- }
-}
-
-// ==================== 马赛克渲染 ====================
-// 马赛克原理:将原始屏幕位图(srcDC = memDC,物理像素)一次缩小到按 blockPx
-// 计算的低分辨率位图,再以最近邻一次放大到目标 DC,避免逐块执行大量 GDI 调用。
-// 目标 DC(targetDC)为逻辑像素(backDC / finalDC),源 DC(srcDC)为物理像素(memDC),
-// 二者通过 dpiScale 换算:srcX = (absX - virtualX) * dpiScale。
-
-// 对单个矩形区域批量生成马赛克并绘制到 targetDC。
-// dstX0/dstY0/dstW/dstH 是目标逻辑像素矩形;srcAbsX0/srcAbsY0 是其在虚拟屏幕中的
-// 绝对逻辑坐标。低分辨率尺寸按 blockPx 向上取整,右/下不足整块的边缘由最后一格覆盖。
-// 返回 true 表示缩小和最近邻放大均成功;失败时不修改缓存有效性,调用方可跳过揭示。
-static bool MosaicBlitRect(HDC targetDC, HDC srcDC,
- int dstX0, int dstY0, int dstW, int dstH,
- int srcAbsX0, int srcAbsY0,
- int blockPx, int virtualX, int virtualY, double dpiScale) {
- if (!targetDC || !srcDC || dstW <= 0 || dstH <= 0 || blockPx < 1 || dpiScale <= 0) {
- return false;
- }
-
- // 每个低分辨率像素对应约一个 blockPx 逻辑像素块;向上取整覆盖非整块边缘。
- int reducedW = (dstW + blockPx - 1) / blockPx;
- int reducedH = (dstH + blockPx - 1) / blockPx;
- if (reducedW <= 0 || reducedH <= 0) return false;
-
- // 源位图是物理像素,目标与马赛克块大小是逻辑像素,统一在这里完成坐标换算。
- int srcX = (int)((srcAbsX0 - virtualX) * dpiScale + 0.5);
- int srcY = (int)((srcAbsY0 - virtualY) * dpiScale + 0.5);
- int srcW = (int)(dstW * dpiScale + 0.5);
- int srcH = (int)(dstH * dpiScale + 0.5);
- if (srcW < 1) srcW = 1;
- if (srcH < 1) srcH = 1;
-
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return false;
- HDC reducedDC = CreateCompatibleDC(screenDC);
- HBITMAP reducedBmp = reducedDC
- ? CreateCompatibleBitmap(screenDC, reducedW, reducedH)
- : NULL;
- ReleaseDC(NULL, screenDC);
- if (!reducedDC || !reducedBmp) {
- if (reducedBmp) DeleteObject(reducedBmp);
- if (reducedDC) DeleteDC(reducedDC);
- return false;
- }
-
- HGDIOBJ oldReducedBmp = SelectObject(reducedDC, reducedBmp);
- if (!oldReducedBmp || oldReducedBmp == HGDI_ERROR) {
- DeleteObject(reducedBmp);
- DeleteDC(reducedDC);
- return false;
- }
-
- // 第一次 StretchBlt:以 HALFTONE 将整个源区域压缩到低分辨率马赛克采样图。
- int oldReducedMode = GetStretchBltMode(reducedDC);
- POINT oldBrushOrigin = {0, 0};
- bool reducedModeReady = oldReducedMode != 0
- && SetStretchBltMode(reducedDC, HALFTONE) != 0
- && SetBrushOrgEx(reducedDC, 0, 0, &oldBrushOrigin);
- bool reducedOk = reducedModeReady
- && StretchBlt(reducedDC, 0, 0, reducedW, reducedH,
- srcDC, srcX, srcY, srcW, srcH, SRCCOPY);
- if (reducedModeReady) {
- SetBrushOrgEx(reducedDC, oldBrushOrigin.x, oldBrushOrigin.y, NULL);
- }
- if (oldReducedMode != 0) SetStretchBltMode(reducedDC, oldReducedMode);
-
- // 第二次 StretchBlt:最近邻放大,保持每个低分辨率采样点为纯色块。
- int oldTargetMode = GetStretchBltMode(targetDC);
- bool targetModeReady = reducedOk && oldTargetMode != 0
- && SetStretchBltMode(targetDC, COLORONCOLOR) != 0;
- bool expandedOk = targetModeReady
- && StretchBlt(targetDC, dstX0, dstY0, dstW, dstH,
- reducedDC, 0, 0, reducedW, reducedH, SRCCOPY);
- if (oldTargetMode != 0) SetStretchBltMode(targetDC, oldTargetMode);
-
- SelectObject(reducedDC, oldReducedBmp);
- DeleteObject(reducedBmp);
- DeleteDC(reducedDC);
- return reducedOk && expandedOk;
-}
-
-
-// ==================== 马赛克渲染(reveal-mask 模型) ====================
-// 核心:仅在存在已提交马赛克或正在绘制马赛克时,才把整张虚拟屏幕按当前块大小
-// 生成 mosaicBase。马赛克标注只是「蒙版」——涂抹=路径圆形区域、框选=矩形区域——
-// 揭示其背后的 mosaicBase。任意区域、任意顺序叠加都连续无缝;切换块大小时延迟到
-// 下一次实际需要马赛克的绘制再重建,避免普通截图确认态承担无用的整屏预处理。
-
-// 释放马赛克 base 资源
-static void FreeMosaicBase(CaptureContext* ctx) {
- if (ctx->mosaicBaseDC) { DeleteDC(ctx->mosaicBaseDC); ctx->mosaicBaseDC = NULL; }
- if (ctx->mosaicBaseBitmap) { DeleteObject(ctx->mosaicBaseBitmap); ctx->mosaicBaseBitmap = NULL; }
- ctx->mosaicBaseW = 0;
- ctx->mosaicBaseH = 0;
- ctx->mosaicBaseBlockPx = 0;
-}
-
-// 用 GDI+ 把单色位图转为带透明通道的 32bpp HBITMAP(用于光标)。
-static HBITMAP ColorBitmapFromBitmap(Gdiplus::Bitmap& bmp) {
- HBITMAP hBmp = NULL;
- Gdiplus::Color bg(0, 0, 0, 0); // 透明背景
- bmp.GetHBITMAP(bg, &hBmp);
- return hBmp;
-}
-
-// 生成单个涂抹光标:半径圆(白底 + 深色描边 + 中心十字)。
-// size 为光标位图边长(逻辑像素);hotspot 在中心。
-static HCURSOR CreateMosaicBrushCursor(int radius) {
- int pad = 3;
- int size = (radius + pad) * 2;
- if (size < 16) size = 16;
-
- HCURSOR result = NULL;
- // GDI+ 已由会话级 InitGdipResources 启动(在 InitMosaicBrushCursors 调用前完成)。
- {
- Gdiplus::Bitmap bmp(size, size, PixelFormat32bppARGB);
- {
- Gdiplus::Graphics g(&bmp);
- g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- int cx = size / 2;
- int cy = size / 2;
- // 外圈:白色描边底(保证暗背景可见)
- Gdiplus::Pen whitePen(Gdiplus::Color(255, 255, 255, 255), 3.0f);
- g.DrawEllipse(&whitePen, cx - radius, cy - radius, radius * 2, radius * 2);
- // 内圈:深色虚线描边
- Gdiplus::Pen darkPen(Gdiplus::Color(255, 30, 30, 30), 1.5f);
- darkPen.SetDashStyle(Gdiplus::DashStyleDash);
- g.DrawEllipse(&darkPen, cx - radius, cy - radius, radius * 2, radius * 2);
- // 中心十字(准星)
- Gdiplus::Pen crossPen(Gdiplus::Color(255, 30, 30, 30), 1.0f);
- int cl = (std::min)(6, radius);
- g.DrawLine(&crossPen, cx - cl, cy, cx + cl, cy);
- g.DrawLine(&crossPen, cx, cy - cl, cx, cy + cl);
- }
- HBITMAP hColor = ColorBitmapFromBitmap(bmp);
-
- // 掩码位图(全黑,使用彩色光标时掩码可忽略,但 CreateIcon 要求非空)
- HDC screenDC = GetDC(NULL);
- HDC maskDC = CreateCompatibleDC(screenDC);
- HBITMAP hMask = CreateCompatibleBitmap(screenDC, size, size);
- HGDIOBJ oldMask = SelectObject(maskDC, hMask);
- PatBlt(maskDC, 0, 0, size, size, BLACKNESS);
- SelectObject(maskDC, oldMask);
- DeleteDC(maskDC);
- ReleaseDC(NULL, screenDC);
-
- ICONINFO ii = {0};
- ii.fIcon = FALSE;
- ii.xHotspot = size / 2;
- ii.yHotspot = size / 2;
- ii.hbmMask = hMask;
- ii.hbmColor = hColor;
- result = CreateIconIndirect(&ii);
- DeleteObject(hMask);
- DeleteObject(hColor);
- }
- return result;
-}
-
-// 初始化涂抹光标缓存(按 DPI 缩放半径)。
-static void InitMosaicBrushCursors(CaptureContext* ctx) {
- if (ctx->mosaicBrushCursorsInited) return;
- for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
- ctx->mosaicBrushCursors[i] = CreateMosaicBrushCursor(SC_MOSAIC_RADIUS[i]);
- }
- ctx->mosaicBrushCursorsInited = true;
-}
-
-static void FreeMosaicBrushCursors(CaptureContext* ctx) {
- for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) {
- if (ctx->mosaicBrushCursors[i]) { DestroyIcon(ctx->mosaicBrushCursors[i]); ctx->mosaicBrushCursors[i] = NULL; }
- }
- ctx->mosaicBrushCursorsInited = false;
-}
-
-// 生成会话级马赛克底图:将整个虚拟屏幕批量缩小后最近邻放大到离屏位图。
-// 缓存坐标与 backDC 一致(原点 = 虚拟屏幕左上角),尺寸 = virtualW×virtualH,
-// 与选区无关:选区移动/缩放不需要重建,仅在虚拟屏幕尺寸或块大小变化时重建。
-// 先在临时 GDI 对象中完整生成,成功后再替换旧缓存;失败会保留“无有效缓存”状态。
-static bool RebuildMosaicBase(CaptureContext* ctx) {
- int w = ctx->virtualW;
- int h = ctx->virtualH;
- if (w <= 0 || h <= 0) {
- FreeMosaicBase(ctx);
- return false;
- }
-
- int blockPx = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
- if (blockPx < 2) blockPx = 2;
-
- HDC screenDC = GetDC(NULL);
- if (!screenDC) {
- FreeMosaicBase(ctx);
- return false;
- }
- HDC newDC = CreateCompatibleDC(screenDC);
- HBITMAP newBitmap = newDC ? CreateCompatibleBitmap(screenDC, w, h) : NULL;
- ReleaseDC(NULL, screenDC);
- if (!newDC || !newBitmap) {
- if (newBitmap) DeleteObject(newBitmap);
- if (newDC) DeleteDC(newDC);
- FreeMosaicBase(ctx);
- return false;
- }
-
- HGDIOBJ oldNewBitmap = SelectObject(newDC, newBitmap);
- if (!oldNewBitmap || oldNewBitmap == HGDI_ERROR) {
- DeleteObject(newBitmap);
- DeleteDC(newDC);
- FreeMosaicBase(ctx);
- return false;
- }
-
- // 整虚拟屏幕按 blockPx 马赛克化:base 原点 = 虚拟屏幕左上角,源绝对坐标 = virtualX/virtualY。
- bool generated = MosaicBlitRect(newDC, ctx->memDC, 0, 0, w, h,
- ctx->virtualX, ctx->virtualY, blockPx,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale);
- if (!generated) {
- SelectObject(newDC, oldNewBitmap);
- DeleteObject(newBitmap);
- DeleteDC(newDC);
- FreeMosaicBase(ctx);
- return false;
- }
-
- FreeMosaicBase(ctx);
- ctx->mosaicBaseDC = newDC;
- ctx->mosaicBaseBitmap = newBitmap;
- ctx->mosaicBaseW = w;
- ctx->mosaicBaseH = h;
- ctx->mosaicBaseBlockPx = blockPx;
- return true;
-}
-
-// 判断现有马赛克缓存是否缺失,或尺寸/块大小已与当前会话不一致。
-// 此函数只检查缓存键;调用方必须先通过 HasMosaicToRender 确认当前帧确实需要马赛克。
-static bool MosaicBaseNeedsRebuild(const CaptureContext* ctx) {
- int blockPx = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
- if (blockPx < 2) blockPx = 2;
- bool blockChanged = (blockPx != ctx->mosaicBaseBlockPx);
- bool sizeChanged = (ctx->mosaicBaseW != ctx->virtualW || ctx->mosaicBaseH != ctx->virtualH);
- return blockChanged || sizeChanged || !ctx->mosaicBaseDC;
-}
-
-static bool HasMosaicToRender(const std::vector& annotations, const Annotation* curDrawing) {
- if (curDrawing && curDrawing->type == AT_Mosaic) return true;
- for (const Annotation& a : annotations) {
- if (a.type == AT_Mosaic) return true;
- }
- return false;
-}
-
-// 把单条马赛克标注对应的蒙版构建为目标 DC 局部坐标下的 HRGN。
-// ox/oy 将标注的绝对屏幕坐标转换到目标坐标系:覆盖层使用 -virtualX/-virtualY,
-// 导出位图使用 -selection.left/-selection.top。
-static HRGN BuildMosaicMaskRegion(const Annotation& a, float ox, float oy) {
- if (a.mosaicRect) {
- int absL = (std::min)(a.x1, a.x2);
- int absT = (std::min)(a.y1, a.y2);
- int absR = (std::max)(a.x1, a.x2);
- int absB = (std::max)(a.y1, a.y2);
- return CreateRectRgn((int)(absL + ox + 0.5f), (int)(absT + oy + 0.5f),
- (int)(absR + ox + 0.5f), (int)(absB + oy + 0.5f));
- } else {
- // 涂抹:把整条路径变为连续的「胶囊」区域(保证快速移动时不留空隙)。
- // 做法:沿相邻点之间的线段以不超过 radius/2 的步长插值取点,每个点画一个圆并并入区域,
- // 相邻圆重叠从而形成无缝的粗笔触轨迹。
- int radius = a.brushRadius;
- if (radius < 1) radius = 1;
- HRGN rgn = CreateRectRgn(0, 0, 0, 0);
- if (a.pts.empty()) return rgn;
- // 步长:半径的一半,保证相邻圆重叠 ≥50%,无视觉缝隙
- double step = (std::max)(1.0, radius * 0.5);
-
- auto addCircle = [&](double cx, double cy) {
- int ix = (int)(cx + 0.5);
- int iy = (int)(cy + 0.5);
- HRGN circle = CreateEllipticRgn(ix - radius, iy - radius,
- ix + radius, iy + radius);
- CombineRgn(rgn, rgn, circle, RGN_OR);
- DeleteObject(circle);
- };
-
- // 第一个点
- addCircle(a.pts[0].x + ox, a.pts[0].y + oy);
- for (size_t i = 1; i < a.pts.size(); i++) {
- double x0 = a.pts[i - 1].x + ox;
- double y0 = a.pts[i - 1].y + oy;
- double x1 = a.pts[i].x + ox;
- double y1 = a.pts[i].y + oy;
- double dx = x1 - x0, dy = y1 - y0;
- double segLen = std::sqrt(dx * dx + dy * dy);
- if (segLen < 0.5) {
- addCircle(x1, y1);
- continue;
- }
- int n = (int)(segLen / step + 0.5);
- if (n < 1) n = 1;
- for (int k = 1; k <= n; k++) {
- double t = (double)k / n;
- addCircle(x0 + dx * t, y0 + dy * t);
- }
- }
- return rgn;
- }
-}
-
-// 揭示马赛克:把 mosaicBase 中由 masks(已提交标注)+ curDrawing(正在绘制)覆盖的区域
-// BitBlt 到 targetDC。mosaicBase 与 targetDC 使用相同的目标坐标系:覆盖层以虚拟屏幕
-// 左上角为原点,导出位图以截图选区左上角为原点。
-// contentBounds 是目标 DC 中允许显示马赛克的内容矩形;最终蒙版会与其求交,确保笔刷半径
-// 或历史标注不会越过截图选区。ox/oy 用于把标注绝对坐标换算到目标局部坐标。
-static void RevealMosaicToTarget(HDC targetDC, HDC mosaicBase,
- const std::vector& annotations,
- const Annotation* curDrawing,
- const RECT& contentBounds,
- float ox, float oy) {
- if (!targetDC || !mosaicBase
- || contentBounds.right <= contentBounds.left
- || contentBounds.bottom <= contentBounds.top) {
- return;
- }
-
- // 合并所有马赛克标注的蒙版区域(目标局部坐标)
- HRGN mask = CreateRectRgn(0, 0, 0, 0);
- bool any = false;
- for (const Annotation& a : annotations) {
- if (a.type != AT_Mosaic) continue;
- HRGN r = BuildMosaicMaskRegion(a, ox, oy);
- CombineRgn(mask, mask, r, RGN_OR);
- DeleteObject(r);
- any = true;
- }
- if (curDrawing && curDrawing->type == AT_Mosaic) {
- HRGN r = BuildMosaicMaskRegion(*curDrawing, ox, oy);
- CombineRgn(mask, mask, r, RGN_OR);
- DeleteObject(r);
- any = true;
- }
- if (any) {
- // 先把马赛克蒙版裁到截图内容矩形,再与调用方已有的 dirtyRect 裁剪区求交。
- // contentBounds 采用半开区间;因此边框所在的矩形外沿不会被马赛克写入。
- HRGN contentRgn = CreateRectRgn(contentBounds.left, contentBounds.top,
- contentBounds.right, contentBounds.bottom);
- CombineRgn(mask, mask, contentRgn, RGN_AND);
- DeleteObject(contentRgn);
-
- // 用 SaveDC 保护调用方的裁剪区(P1 局部帧时为 dirtyRect),揭示结束后完整恢复。
- int saved = SaveDC(targetDC);
- // mask 与现有裁剪区(dirtyRect)求交,揭示只发生在 dirtyRect∩选区∩蒙版。
- ExtSelectClipRgn(targetDC, mask, RGN_AND);
- // base 与 targetDC 同坐标系,1:1 拷贝。
- BitBlt(targetDC, contentBounds.left, contentBounds.top,
- contentBounds.right - contentBounds.left,
- contentBounds.bottom - contentBounds.top,
- mosaicBase, contentBounds.left, contentBounds.top, SRCCOPY);
- if (saved) RestoreDC(targetDC, saved);
- }
- DeleteObject(mask);
-}
-
-// 覆盖层渲染矢量/文字标注(不含马赛克,马赛克由 reveal-mask 单独处理)。
-// selRel:选区在 backDC 局部坐标的矩形;标注为绝对虚拟屏幕坐标,偏移 = -virtualX/-virtualY。
-// 用 SetClip 限制到选区内部,避免画到遮罩区。
-static void DrawAnnotations(HDC hdc, const RECT& selRel, int virtualX, int virtualY,
- const std::vector& annotations,
- const Annotation* curDrawing) {
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用(Graphics 按 hdc 新建)。
- {
- Gdiplus::Graphics graphics(hdc);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- // 限制绘制范围在选区内
- Gdiplus::Rect clipRect(selRel.left, selRel.top,
- selRel.right - selRel.left,
- selRel.bottom - selRel.top);
- // 限制绘制范围在选区内(与选区矩形求交;局部帧时还会与 backDC 裁剪区 dirtyRect 求交,
- // 因 Graphics(backDC) 继承 GDI 裁剪区,CombineModeIntersect 保证标注不超出 dirtyRect∩选区)。
- graphics.SetClip(clipRect, Gdiplus::CombineModeIntersect);
-
- float ox = (float)-virtualX;
- float oy = (float)-virtualY;
-
- for (const Annotation& a : annotations) {
- if (a.type == AT_Mosaic) continue; // 马赛克单独渲染
- DrawOneAnnotation(graphics, a, ox, oy);
- }
- if (curDrawing && curDrawing->type != AT_Mosaic) {
- DrawOneAnnotation(graphics, *curDrawing, ox, oy);
- }
- }
-}
-
-
-// 合成标注进最终 PNG:finalDC 原点 = 选区左上角,故偏移 = -rect.left/-rect.top。
-// srcDC = memDC(原始屏幕位图,物理像素),用于马赛克像素化取源。
-// mosaicBlockPx:马赛克块大小(与编辑器当前全局块大小保持一致,保证导出与所见一致)。
-static void CompositeAnnotations(HDC finalDC, HDC srcDC,
- const std::vector& annotations,
- const RECT& rect, int virtualX, int virtualY,
- double dpiScale, int mosaicBlockPx) {
- if (annotations.empty()) return;
-
- // 马赛克先渲染到底图上,后续矢量/文字标注保持清晰覆盖在其上方。
- if (HasMosaicToRender(annotations, nullptr)) {
- int blockPx = mosaicBlockPx;
- if (blockPx < 2) blockPx = 2;
- int w = rect.right - rect.left;
- int h = rect.bottom - rect.top;
- HDC screenDC = GetDC(NULL);
- if (screenDC) {
- HDC baseDC = CreateCompatibleDC(screenDC);
- HBITMAP baseBmp = baseDC ? CreateCompatibleBitmap(screenDC, w, h) : NULL;
- if (baseDC && baseBmp) {
- HGDIOBJ oldBase = SelectObject(baseDC, baseBmp);
- bool generated = oldBase && oldBase != HGDI_ERROR
- && MosaicBlitRect(baseDC, srcDC, 0, 0, w, h,
- rect.left, rect.top, blockPx,
- virtualX, virtualY, dpiScale);
- if (generated) {
- // 揭示蒙版区域(finalDC 原点 = 选区左上角,base 同为选区相对,ox=-rect.left)
- float ox = (float)-rect.left;
- float oy = (float)-rect.top;
- RevealMosaicToTarget(finalDC, baseDC, annotations, nullptr,
- RECT{0, 0, w, h}, ox, oy);
- }
- if (oldBase && oldBase != HGDI_ERROR) SelectObject(baseDC, oldBase);
- }
- if (baseBmp) DeleteObject(baseBmp);
- if (baseDC) DeleteDC(baseDC);
- ReleaseDC(NULL, screenDC);
- }
- }
-
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
- {
- Gdiplus::Graphics graphics(finalDC);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- float ox = (float)-rect.left;
- float oy = (float)-rect.top;
- for (const Annotation& a : annotations) {
- if (a.type == AT_Mosaic) continue; // 马赛克单独渲染
- DrawOneAnnotation(graphics, a, ox, oy);
- }
- }
-}
-
-// 将 HBITMAP 转换为 PNG base64 字符串
-static std::string BitmapToBase64Png(HBITMAP hBitmap) {
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
- std::string result;
- {
- Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(hBitmap, NULL);
- if (bmp) {
- CLSID pngClsid;
- if (GetPngEncoderClsid(&pngClsid) >= 0) {
- IStream* stream = NULL;
- CreateStreamOnHGlobal(NULL, TRUE, &stream);
- if (stream && bmp->Save(stream, &pngClsid, NULL) == Gdiplus::Ok) {
- HGLOBAL hMem = NULL;
- GetHGlobalFromStream(stream, &hMem);
- size_t len = GlobalSize(hMem);
- BYTE* ptr = (BYTE*)GlobalLock(hMem);
- if (ptr && len > 0) {
- result = "data:image/png;base64," + Base64Encode(ptr, len);
- }
- GlobalUnlock(hMem);
- }
- if (stream) stream->Release();
- }
- delete bmp;
- }
- }
- return result;
-}
-
-// 保存位图到剪贴板
-static bool SaveBitmapToClipboard(HBITMAP hBitmap) {
- if (!OpenClipboard(NULL)) return false;
- EmptyClipboard();
- BITMAP bm;
- GetObject(hBitmap, sizeof(BITMAP), &bm);
- HBITMAP hCopy = (HBITMAP)CopyImage(hBitmap, IMAGE_BITMAP, bm.bmWidth, bm.bmHeight, LR_COPYRETURNORG);
- SetClipboardData(CF_BITMAP, hCopy);
- CloseClipboard();
- return true;
-}
-
-// 将预乘 32bpp ARGB 像素缓冲编码为 PNG,可选产出 data URL base64 与/或原始 PNG 字节。
-// 用 Gdiplus::Bitmap 直接包装外部预乘缓冲(PixelFormat32bppPARGB),
-// PNG 编码器会转为非预乘写入 PNG(同时与剪贴板 CF_DIB 的预乘约定一致),保留 alpha。
-static bool EncodePremulArgbPng(const void* bits, int w, int h, int stride,
- std::string* base64Out, std::string* rawOut) {
- if (!bits || w <= 0 || h <= 0) return false;
- CLSID pngClsid;
- if (GetPngEncoderClsid(&pngClsid) < 0) return false;
- IStream* stream = NULL;
- if (CreateStreamOnHGlobal(NULL, TRUE, &stream) != S_OK || !stream) return false;
- bool ok = false;
- {
- Gdiplus::Bitmap bmp(w, h, stride, PixelFormat32bppPARGB, (BYTE*)bits);
- if (bmp.Save(stream, &pngClsid, NULL) == Gdiplus::Ok) {
- HGLOBAL hMem = NULL;
- if (GetHGlobalFromStream(stream, &hMem) == S_OK && hMem) {
- size_t len = GlobalSize(hMem);
- BYTE* ptr = (BYTE*)GlobalLock(hMem);
- if (ptr && len > 0) {
- if (rawOut) rawOut->assign((const char*)ptr, len);
- if (base64Out) *base64Out = "data:image/png;base64," + Base64Encode(ptr, len);
- ok = true;
- }
- if (ptr) GlobalUnlock(hMem);
- }
- }
- }
- stream->Release();
- return ok;
-}
-
-// 圆角透明图复制到剪贴板:CF_DIB(BITMAPV4HEADER, BI_BITFIELDS, 预乘 ARGB, 自下而上)
-// + 注册 PNG 格式(透明度跨应用最可靠,浏览器/Slack/图像编辑器按此读取)。
-// srcDC 须已选入 hbmp(用于 GetDIBits)。仅 radius>0 调用;radius==0 仍走 SaveBitmapToClipboard。
-static bool SaveArgbBitmapToClipboard(HBITMAP hbmp, HDC srcDC, int w, int h,
- const std::string& pngBytes) {
- if (!OpenClipboard(NULL)) return false;
- EmptyClipboard();
- // CF_DIB:用 GetDIBits 转成自下而上、BI_BITFIELDS 的 32bpp 预乘 ARGB
- BITMAPV4HEADER b4 = {};
- b4.bV4Size = sizeof(BITMAPV4HEADER);
- b4.bV4Width = w;
- b4.bV4Height = h; // 正值 = 自下而上
- b4.bV4Planes = 1;
- b4.bV4BitCount = 32;
- b4.bV4V4Compression = BI_BITFIELDS;
- b4.bV4RedMask = 0x00FF0000;
- b4.bV4GreenMask = 0x0000FF00;
- b4.bV4BlueMask = 0x000000FF;
- b4.bV4AlphaMask = 0xFF000000;
- b4.bV4SizeImage = (DWORD)((SIZE_T)w * (SIZE_T)h * 4);
- SIZE_T dibSize = sizeof(BITMAPV4HEADER) + (SIZE_T)w * (SIZE_T)h * 4;
- HGLOBAL hDib = GlobalAlloc(GMEM_MOVEABLE, dibSize);
- if (hDib) {
- BYTE* p = (BYTE*)GlobalLock(hDib);
- if (p) {
- memcpy(p, &b4, sizeof(b4));
- GetDIBits(srcDC, hbmp, 0, h, p + sizeof(b4), (BITMAPINFO*)&b4, DIB_RGB_COLORS);
- GlobalUnlock(hDib);
- if (!SetClipboardData(CF_DIB, hDib)) GlobalFree(hDib);
- } else {
- GlobalFree(hDib);
- }
- }
- // PNG 格式:透明度最可靠的载体
- if (!pngBytes.empty()) {
- static UINT pngFmt = 0;
- if (pngFmt == 0) pngFmt = RegisterClipboardFormatW(L"PNG");
- if (pngFmt) {
- HGLOBAL hPng = GlobalAlloc(GMEM_MOVEABLE, pngBytes.size());
- if (hPng) {
- BYTE* pp = (BYTE*)GlobalLock(hPng);
- if (pp) {
- memcpy(pp, pngBytes.data(), pngBytes.size());
- GlobalUnlock(hPng);
- if (!SetClipboardData(pngFmt, hPng)) GlobalFree(hPng);
- } else GlobalFree(hPng);
- }
- }
- }
- CloseClipboard();
- return true;
-}
-
-// 生成圆角透明的 32bpp 预乘 ARGB DIB(radius>0 用)。
-// 复用现有像素提取+标注合成流程,但用 32bpp ARGB DIB 替代不透明位图;
-// 合成前把 alpha 统一置 255(保证标注以不透明背景混合、AA 边正确),合成后按圆角
-// 蒙版逐像素写入 alpha(内部不透明、弧边抗锯齿预乘、外部透明)。
-// 返回 HBITMAP(调用方 DeleteObject);outDC 调用方 DeleteDC;outBits 为 DIB 像素指针。
-static HBITMAP BuildRoundedArgbFinal(HDC memDC, const RECT& rect, int vx, int vy,
- double dpiScale, const std::vector& anns, int radius,
- HDC& outDC, void*& outBits, int& outW, int& outH) {
- outDC = NULL; outBits = NULL; outW = 0; outH = 0;
- int width = rect.right - rect.left;
- int height = rect.bottom - rect.top;
- if (width <= 0 || height <= 0) return NULL;
-
- int lx = rect.left - vx;
- int ly = rect.top - vy;
- int px = (int)(lx * dpiScale + 0.5);
- int py = (int)(ly * dpiScale + 0.5);
- int pw = (int)(width * dpiScale + 0.5);
- int ph = (int)(height * dpiScale + 0.5);
-
- HDC screenDC = GetDC(NULL);
- if (!screenDC) return NULL;
-
- // 物理尺寸 32bpp ARGB DIB(top-down);BitBlt 后 RGB 就位,alpha 字节不可靠(下方统一置 255)
- BITMAPINFO bi = {};
- bi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
- bi.bmiHeader.biWidth = pw;
- bi.bmiHeader.biHeight = -ph;
- bi.bmiHeader.biPlanes = 1;
- bi.bmiHeader.biBitCount = 32;
- bi.bmiHeader.biCompression = BI_RGB;
- void* bits = NULL;
- HBITMAP regionBmp = CreateDIBSection(screenDC, &bi, DIB_RGB_COLORS, &bits, NULL, 0);
- HDC regionDC = CreateCompatibleDC(screenDC);
- SelectObject(regionDC, regionBmp);
- BitBlt(regionDC, 0, 0, pw, ph, memDC, px, py, SRCCOPY);
-
- HBITMAP finalBmp = regionBmp;
- HDC finalDC = regionDC;
- void* finalBits = bits;
- int finalW = pw, finalH = ph;
-
- if (dpiScale > 1.01 || dpiScale < 0.99) {
- BITMAPINFO bi2 = {};
- bi2.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
- bi2.bmiHeader.biWidth = width;
- bi2.bmiHeader.biHeight = -height;
- bi2.bmiHeader.biPlanes = 1;
- bi2.bmiHeader.biBitCount = 32;
- bi2.bmiHeader.biCompression = BI_RGB;
- void* bits2 = NULL;
- HBITMAP scaledBmp = CreateDIBSection(screenDC, &bi2, DIB_RGB_COLORS, &bits2, NULL, 0);
- HDC scaledDC = CreateCompatibleDC(screenDC);
- SelectObject(scaledDC, scaledBmp);
- SetStretchBltMode(scaledDC, HALFTONE);
- SetBrushOrgEx(scaledDC, 0, 0, NULL);
- StretchBlt(scaledDC, 0, 0, width, height, regionDC, 0, 0, pw, ph, SRCCOPY);
- DeleteDC(regionDC);
- DeleteObject(regionBmp);
- finalBmp = scaledBmp; finalDC = scaledDC; finalBits = bits2;
- finalW = width; finalH = height;
- }
-
- // 合成前置 alpha=255:标注按不透明背景混合(结果不透明),后续圆角蒙版只负责透明
- if (finalBits) {
- BYTE* p = (BYTE*)finalBits;
- int cnt = finalW * finalH;
- for (int i = 0; i < cnt; i++) p[i * 4 + 3] = 255;
- }
- CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale,
- SC_MOSAIC_SIZES[g_captureCtx ? g_captureCtx->mosaicSizeIdx : SC_DEFAULT_MOSAIC_IDX]);
-
- // 圆角蒙版:同尺寸 32bpp DIB,不透明黑底 + GDI+ 填白圆角路径,取 RGB 通道作 coverage
- int r = (std::min)(radius, (std::min)(finalW, finalH) / 2);
- if (r >= 1 && finalBits) {
- BITMAPINFO mbi = {};
- mbi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
- mbi.bmiHeader.biWidth = finalW;
- mbi.bmiHeader.biHeight = -finalH;
- mbi.bmiHeader.biPlanes = 1;
- mbi.bmiHeader.biBitCount = 32;
- mbi.bmiHeader.biCompression = BI_RGB;
- void* maskBits = NULL;
- HBITMAP maskBmp = CreateDIBSection(screenDC, &mbi, DIB_RGB_COLORS, &maskBits, NULL, 0);
- if (maskBmp && maskBits) {
- BYTE* mp = (BYTE*)maskBits;
- int mcnt = finalW * finalH;
- for (int i = 0; i < mcnt; i++) { mp[i*4]=0; mp[i*4+1]=0; mp[i*4+2]=0; mp[i*4+3]=255; }
- HDC maskDC = CreateCompatibleDC(screenDC);
- HGDIOBJ oldMask = SelectObject(maskDC, maskBmp);
- {
- Gdiplus::Graphics g(maskDC);
- g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality);
- Gdiplus::SolidBrush white(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::GraphicsPath path;
- AddRoundedRect(path, 0, 0, finalW, finalH, r);
- g.FillPath(&white, &path);
- }
- SelectObject(maskDC, oldMask);
- DeleteDC(maskDC);
- // 逐像素:final alpha = coverage(mask B 通道,==G==R),RGB 按其预乘
- BYTE* dst = (BYTE*)finalBits;
- BYTE* msk = (BYTE*)maskBits;
- int dcnt = finalW * finalH;
- for (int i = 0; i < dcnt; i++) {
- int a = msk[i * 4];
- if (a <= 0) {
- dst[i*4]=0; dst[i*4+1]=0; dst[i*4+2]=0; dst[i*4+3]=0;
- } else if (a >= 255) {
- dst[i*4+3]=255; // RGB 不变(不透明)
- } else {
- dst[i*4] = (BYTE)((int)dst[i*4] * a / 255);
- dst[i*4+1] = (BYTE)((int)dst[i*4+1] * a / 255);
- dst[i*4+2] = (BYTE)((int)dst[i*4+2] * a / 255);
- dst[i*4+3] = (BYTE)a;
- }
- }
- DeleteObject(maskBmp);
- }
- }
-
- ReleaseDC(NULL, screenDC);
- outDC = finalDC; outBits = finalBits; outW = finalW; outH = finalH;
- return finalBmp;
-}
-
-// 从预截屏位图提取区域,生成 base64 并复制到剪贴板。
-// anns:可选的标注列表,会合成进最终 PNG(选区相对坐标,finalDC 原点 = 选区原点)。
-// radius>0 走圆角透明导出(32bpp ARGB + 圆角蒙版 + PNG/PNG剪贴板);radius==0 维持不透明位图路径不变。
-static ScreenshotResult* ExtractRegionResult(HDC memDC, const RECT& rect,
- int vx, int vy, double dpiScale, const std::vector& anns, int radius) {
- ScreenshotResult* result = new ScreenshotResult();
- result->success = false;
- int width = rect.right - rect.left;
- int height = rect.bottom - rect.top;
- result->x = rect.left;
- result->y = rect.top;
- result->x2 = rect.right;
- result->y2 = rect.bottom;
- result->width = width;
- result->height = height;
-
- if (width <= 0 || height <= 0) return result;
-
- // radius>0:圆角透明导出
- if (radius > 0) {
- HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0;
- HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius,
- fDC, fBits, fw, fh);
- if (fbmp && fBits) {
- std::string rawPng, b64;
- if (EncodePremulArgbPng(fBits, fw, fh, fw * 4, &b64, &rawPng)) {
- result->base64 = b64;
- result->success = SaveArgbBitmapToClipboard(fbmp, fDC, fw, fh, rawPng);
- }
- }
- if (fDC) DeleteDC(fDC);
- if (fbmp) DeleteObject(fbmp);
- return result;
- }
-
- // radius==0:原有不透明位图路径
- // 从物理尺寸位图提取区域
- int lx = rect.left - vx;
- int ly = rect.top - vy;
- int px = (int)(lx * dpiScale + 0.5);
- int py = (int)(ly * dpiScale + 0.5);
- int pw = (int)(width * dpiScale + 0.5);
- int ph = (int)(height * dpiScale + 0.5);
-
- HDC screenDC = GetDC(NULL);
- HDC regionDC = CreateCompatibleDC(screenDC);
- HBITMAP regionBmp = CreateCompatibleBitmap(screenDC, pw, ph);
- SelectObject(regionDC, regionBmp);
- BitBlt(regionDC, 0, 0, pw, ph, memDC, px, py, SRCCOPY);
-
- // 如果有 DPI 缩放,缩放回逻辑尺寸
- HBITMAP finalBmp = regionBmp;
- HDC finalDC = regionDC;
- if (dpiScale > 1.01 || dpiScale < 0.99) {
- HDC scaledDC = CreateCompatibleDC(screenDC);
- HBITMAP scaledBmp = CreateCompatibleBitmap(screenDC, width, height);
- SelectObject(scaledDC, scaledBmp);
- SetStretchBltMode(scaledDC, HALFTONE);
- SetBrushOrgEx(scaledDC, 0, 0, NULL);
- StretchBlt(scaledDC, 0, 0, width, height, regionDC, 0, 0, pw, ph, SRCCOPY);
- DeleteDC(regionDC);
- DeleteObject(regionBmp);
- finalBmp = scaledBmp;
- finalDC = scaledDC;
- }
-
- // 合成标注进最终图像(finalDC 原点 = 选区原点,标注为绝对坐标,偏移 = -rect.left/top)
- CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale,
- SC_MOSAIC_SIZES[g_captureCtx ? g_captureCtx->mosaicSizeIdx : SC_DEFAULT_MOSAIC_IDX]);
-
- // 生成 base64
- result->base64 = BitmapToBase64Png(finalBmp);
- // 复制到剪贴板
- result->success = SaveBitmapToClipboard(finalBmp);
-
- DeleteDC(finalDC);
- DeleteObject(finalBmp);
- ReleaseDC(NULL, screenDC);
-
- return result;
-}
-
-// ---- 窗口过程和线程 ----
-
-// 生成默认保存文件名:Screenshot_YYYYMMDD_HHMMSS.png
-static std::wstring MakeDefaultScreenshotName() {
- time_t now = time(NULL);
- struct tm lt;
- localtime_s(<, &now);
- wchar_t buf[64];
- wsprintfW(buf, L"Screenshot_%04d%02d%02d_%02d%02d%02d.png",
- lt.tm_year + 1900, lt.tm_mon + 1, lt.tm_mday,
- lt.tm_hour, lt.tm_min, lt.tm_sec);
- return std::wstring(buf);
-}
-
-// 弹出系统保存对话框,返回用户选择的文件完整路径(含 .png 后缀);
-// 用户取消或失败时返回空字符串。
-// hwndOwner:父窗口句柄(截图覆盖层),用于模态居中。
-// 注意:覆盖层是 WS_EX_TOPMOST 全屏窗口,通用对话框可能被遮挡。
-// 弹出前临时移除其 TOPMOST(让对话框自然置顶),关闭后恢复,保证对话框可见可交互。
-static std::wstring PromptSaveFilePath(HWND hwndOwner) {
- // 默认目录:图片库(FOLDERID_Pictures),获取失败则退化为桌面
- wchar_t* defaultDir = nullptr;
- HRESULT hr = SHGetKnownFolderPath(FOLDERID_Pictures, 0, NULL, &defaultDir);
- std::wstring initDir;
- if (SUCCEEDED(hr) && defaultDir) {
- initDir = defaultDir;
- CoTaskMemFree(defaultDir);
- } else {
- wchar_t* desktop = nullptr;
- if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Desktop, 0, NULL, &desktop)) && desktop) {
- initDir = desktop;
- CoTaskMemFree(desktop);
- }
- }
-
- std::wstring defaultName = MakeDefaultScreenshotName();
-
- wchar_t fileBuf[MAX_PATH] = {0};
- wcsncpy_s(fileBuf, MAX_PATH, defaultName.c_str(), _TRUNCATE);
-
- // 临时取消覆盖层 TOPMOST,确保保存对话框显示在最上层
- if (hwndOwner) {
- SetWindowPos(hwndOwner, HWND_NOTOPMOST, 0, 0, 0, 0,
- SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
- }
-
- OPENFILENAMEW ofn = {0};
- ofn.lStructSize = sizeof(OPENFILENAMEW);
- ofn.hwndOwner = hwndOwner;
- ofn.lpstrFile = fileBuf;
- ofn.nMaxFile = MAX_PATH;
- ofn.lpstrFilter = L"PNG 图像 (*.png)\0*.png\0";
- ofn.nFilterIndex = 1;
- ofn.lpstrDefExt = L"png"; // 用户未输扩展名时自动补 .png
- if (!initDir.empty()) {
- ofn.lpstrInitialDir = initDir.c_str();
- }
- ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY
- | OFN_NOCHANGEDIR;
-
- BOOL ok = GetSaveFileNameW(&ofn);
-
- // 恢复覆盖层 TOPMOST(用户取消保存时需要回到置顶全屏状态)
- if (hwndOwner) {
- SetWindowPos(hwndOwner, HWND_TOPMOST, 0, 0, 0, 0,
- SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE);
- }
-
- if (ok) {
- return std::wstring(fileBuf);
- }
- return std::wstring();
-}
-
-// 将已合成标注的选区 HBITMAP 保存为 PNG 文件。
-// 返回 true 表示保存成功。
-static bool SaveRegionToPngFile(HDC memDC, const RECT& rect, int vx, int vy,
- double dpiScale, const std::vector& anns,
- const std::wstring& filePath, int radius) {
- int width = rect.right - rect.left;
- int height = rect.bottom - rect.top;
- if (width <= 0 || height <= 0 || filePath.empty()) return false;
-
- // radius>0:圆角透明导出(32bpp ARGB + 圆角蒙版),GDI+ 直接保存为 PNG 文件
- if (radius > 0) {
- HDC fDC = NULL; void* fBits = NULL; int fw = 0, fh = 0;
- HBITMAP fbmp = BuildRoundedArgbFinal(memDC, rect, vx, vy, dpiScale, anns, radius,
- fDC, fBits, fw, fh);
- bool ok = false;
- if (fbmp && fBits) {
- CLSID pngClsid;
- if (GetPngEncoderClsid(&pngClsid) >= 0) {
- Gdiplus::Bitmap bmp(fw, fh, fw * 4, PixelFormat32bppPARGB, (BYTE*)fBits);
- ok = (bmp.Save(filePath.c_str(), &pngClsid, NULL) == Gdiplus::Ok);
- }
- }
- if (fDC) DeleteDC(fDC);
- if (fbmp) DeleteObject(fbmp);
- return ok;
- }
-
- // radius==0:原有不透明位图路径
- // 复用 ExtractRegionResult 的合成逻辑生成 finalBmp(含标注、按逻辑尺寸)
- int lx = rect.left - vx;
- int ly = rect.top - vy;
- int px = (int)(lx * dpiScale + 0.5);
- int py = (int)(ly * dpiScale + 0.5);
- int pw = (int)(width * dpiScale + 0.5);
- int ph = (int)(height * dpiScale + 0.5);
-
- HDC screenDC = GetDC(NULL);
- HDC regionDC = CreateCompatibleDC(screenDC);
- HBITMAP regionBmp = CreateCompatibleBitmap(screenDC, pw, ph);
- SelectObject(regionDC, regionBmp);
- BitBlt(regionDC, 0, 0, pw, ph, memDC, px, py, SRCCOPY);
-
- HBITMAP finalBmp = regionBmp;
- HDC finalDC = regionDC;
- if (dpiScale > 1.01 || dpiScale < 0.99) {
- HDC scaledDC = CreateCompatibleDC(screenDC);
- HBITMAP scaledBmp = CreateCompatibleBitmap(screenDC, width, height);
- SelectObject(scaledDC, scaledBmp);
- SetStretchBltMode(scaledDC, HALFTONE);
- SetBrushOrgEx(scaledDC, 0, 0, NULL);
- StretchBlt(scaledDC, 0, 0, width, height, regionDC, 0, 0, pw, ph, SRCCOPY);
- DeleteDC(regionDC);
- DeleteObject(regionBmp);
- finalBmp = scaledBmp;
- finalDC = scaledDC;
- }
- CompositeAnnotations(finalDC, memDC, anns, rect, vx, vy, dpiScale,
- SC_MOSAIC_SIZES[g_captureCtx ? g_captureCtx->mosaicSizeIdx : SC_DEFAULT_MOSAIC_IDX]);
-
- // 用 GDI+ 保存为 PNG 文件(GDI+ 已由会话级 InitGdipResources 启动)
- bool ok = false;
- {
- Gdiplus::Bitmap* bmp = Gdiplus::Bitmap::FromHBITMAP(finalBmp, NULL);
- if (bmp) {
- CLSID pngClsid;
- if (GetPngEncoderClsid(&pngClsid) >= 0) {
- ok = (bmp->Save(filePath.c_str(), &pngClsid, NULL) == Gdiplus::Ok);
- }
- delete bmp;
- }
- }
-
- DeleteDC(finalDC);
- DeleteObject(finalBmp);
- ReleaseDC(NULL, screenDC);
- return ok;
-}
-
-// 在主线程调用 JS 回调(截图完成)
-static void CallScreenshotJs(napi_env env, napi_value js_callback, void* context, void* data) {
- if (env != nullptr && js_callback != nullptr && data != nullptr) {
- ScreenshotResult* result = static_cast(data);
-
- napi_value resultObj;
- napi_create_object(env, &resultObj);
-
- napi_value success;
- napi_get_boolean(env, result->success, &success);
- napi_set_named_property(env, resultObj, "success", success);
-
- if (result->success) {
- napi_value x, y, x2, y2, width, height, base64;
- napi_create_int32(env, result->x, &x);
- napi_set_named_property(env, resultObj, "x", x);
- napi_create_int32(env, result->y, &y);
- napi_set_named_property(env, resultObj, "y", y);
- napi_create_int32(env, result->x2, &x2);
- napi_set_named_property(env, resultObj, "x2", x2);
- napi_create_int32(env, result->y2, &y2);
- napi_set_named_property(env, resultObj, "y2", y2);
- napi_create_int32(env, result->width, &width);
- napi_set_named_property(env, resultObj, "width", width);
- napi_create_int32(env, result->height, &height);
- napi_set_named_property(env, resultObj, "height", height);
- napi_create_string_utf8(env, result->base64.c_str(), result->base64.size(), &base64);
- napi_set_named_property(env, resultObj, "base64", base64);
- }
-
- napi_value global;
- napi_get_global(env, &global);
- napi_call_function(env, global, js_callback, 1, &resultObj, nullptr);
- delete result;
- }
-}
-
-// 钳制选区圆角半径到合理范围:[0, min(w,h)/2]。
-// 选区尺寸变化(确认/调整/移动)后调用,避免半径越界导致手柄命中与渲染不一致。
-static void ClampCornerRadius(CaptureContext* ctx) {
- int w = ctx->selection.right - ctx->selection.left;
- int h = ctx->selection.bottom - ctx->selection.top;
- int maxR = (std::min)(w, h) / 2;
- if (maxR < 0) maxR = 0;
- if (ctx->selectionCornerRadius > maxR) ctx->selectionCornerRadius = maxR;
- if (ctx->selectionCornerRadius < 0) ctx->selectionCornerRadius = 0;
-}
-
-// 进入确认态:规范化选区并切换状态
-static void EnterConfirmed(CaptureContext* ctx, const RECT& sel) {
- RECT n = NormalizeRect(sel);
- // 约束到虚拟屏幕内
- if (n.left < ctx->virtualX) { n.right += ctx->virtualX - n.left; n.left = ctx->virtualX; }
- if (n.top < ctx->virtualY) { n.bottom += ctx->virtualY - n.top; n.top = ctx->virtualY; }
- if (n.right > ctx->virtualX + ctx->virtualW) n.right = ctx->virtualX + ctx->virtualW;
- if (n.bottom > ctx->virtualY + ctx->virtualH) n.bottom = ctx->virtualY + ctx->virtualH;
- // 最小尺寸保护
- if (n.right - n.left < SC_MIN_SELECTION) n.right = n.left + SC_MIN_SELECTION;
- if (n.bottom - n.top < SC_MIN_SELECTION) n.bottom = n.top + SC_MIN_SELECTION;
- ctx->selection = n;
- ctx->resizeHandle = RH_None;
- ctx->hoveredCornerHandle = RH_None; // 进入确认态先置无,由后续 MOUSEMOVE 重新靠近探测
- ClampCornerRadius(ctx);
- if (ctx->activeTool < 0) ctx->activeTool = TB_Drag;
- if (ctx->popupTool < 0) ctx->popupTool = -1;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
-}
-
-// 计算所有标注内容的包围盒(选区相对逻辑坐标)。
-// 用于限制选区缩放:选区不可缩小到裁掉已添加内容。
-// 返回 false 表示无标注(无约束)。
-// 计算所有标注内容的包围盒(绝对虚拟屏幕坐标)。
-// 用于限制选区缩放:选区不可缩小到裁掉已添加内容。
-// 返回 false 表示无标注(无约束)。hdc 用于测量文字宽高。
-// 前置声明:文字包围盒复用 MeasureTextAnnotation(定义在下方)。
-// 参数为非常量引用:命中失败时会回填文字测量缓存(textCache* 字段)。
-static RECT MeasureTextAnnotation(HDC hdc, Annotation& a);
-
-static RECT MeasureArrowAnnotationBounds(const Annotation& a) {
- float sx = (float)a.x1;
- float sy = (float)a.y1;
- float ex = (float)a.x2;
- float ey = (float)a.y2;
- float minX = (std::min)(sx, ex);
- float minY = (std::min)(sy, ey);
- float maxX = (std::max)(sx, ex);
- float maxY = (std::max)(sy, ey);
-
- float thick = (float)a.thickness;
- if (thick < 1.0f) thick = 1.0f;
- float dx = ex - sx;
- float dy = ey - sy;
- float len = std::sqrt(dx * dx + dy * dy);
- if (len >= 1.0f) {
- float headLen = thick * 4.0f + 8.0f;
- float headHalfW = thick * 2.4f + 5.0f;
- if (headLen > len) headLen = len * 0.6f;
- float notch = headLen * 0.4f;
- float ux = dx / len;
- float uy = dy / len;
- float nx = -uy;
- float ny = ux;
- float baseX = ex - ux * headLen;
- float baseY = ey - uy * headLen;
- float notchX = baseX + ux * notch;
- float notchY = baseY + uy * notch;
- float startHalfW = (std::max)(thick * 0.5f, 0.75f);
- float endHalfW = headHalfW * 0.55f;
- float overlap = 1.5f;
- float bodyEndX = notchX + ux * overlap;
- float bodyEndY = notchY + uy * overlap;
-
- auto expand = [&](float x, float y) {
- if (x < minX) minX = x;
- if (y < minY) minY = y;
- if (x > maxX) maxX = x;
- if (y > maxY) maxY = y;
- };
- expand(sx + nx * startHalfW, sy + ny * startHalfW);
- expand(sx - nx * startHalfW, sy - ny * startHalfW);
- expand(bodyEndX - nx * endHalfW, bodyEndY - ny * endHalfW);
- expand(bodyEndX + nx * endHalfW, bodyEndY + ny * endHalfW);
- expand(baseX + nx * headHalfW, baseY + ny * headHalfW);
- expand(notchX, notchY);
- expand(baseX - nx * headHalfW, baseY - ny * headHalfW);
- }
-
- const float margin = 2.0f;
- return { (int)floorf(minX - margin), (int)floorf(minY - margin),
- (int)ceilf(maxX + margin), (int)ceilf(maxY + margin) };
-}
-// 非常量引用:AT_Text 分支会经 MeasureTextAnnotation 回填文字测量缓存。
-static bool CalcAnnotationsBounds(std::vector& anns, RECT& out, HDC hdc) {
- if (anns.empty()) return false;
- int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
- auto expand = [&](int x, int y) {
- if (x < minL) minL = x;
- if (y < minT) minT = y;
- if (x > maxR) maxR = x;
- if (y > maxB) maxB = y;
- };
- for (Annotation& a : anns) {
- if (a.type == AT_Brush) {
- for (const POINT& p : a.pts) expand(p.x, p.y);
- } else if (a.type == AT_Mosaic) {
- if (a.mosaicRect) {
- // 框选模式:矩形角点
- expand(a.x1, a.y1);
- expand(a.x2, a.y2);
- } else {
- // 涂抹模式:路径包围盒 + 半径
- int r = a.brushRadius;
- for (const POINT& p : a.pts) {
- expand(p.x - r, p.y - r);
- expand(p.x + r, p.y + r);
- }
- }
- } else if (a.type == AT_Text) {
- // 复用 MeasureTextAnnotation:与选中时的外边框(含 padding)完全一致,
- // 保证 resize 选区时文字包围盒约束 = 视觉选中边框,不会被裁掉。
- RECT r = MeasureTextAnnotation(hdc, a);
- expand(r.left, r.top);
- expand(r.right, r.bottom);
- } else if (a.type == AT_Arrow) {
- RECT r = MeasureArrowAnnotationBounds(a);
- expand(r.left, r.top);
- expand(r.right, r.bottom);
- } else {
- expand(a.x1, a.y1);
- expand(a.x2, a.y2);
- }
- }
- if (minL == INT_MAX) return false;
- out.left = minL;
- out.top = minT;
- out.right = maxR;
- out.bottom = maxB;
- return true;
-}
-
-// 约束单轴 resize 的活动端坐标,固定端始终保持按下时的位置。
-// rawActive/originalActive 分别是当前候选值和按下时的活动端;screenMin/screenMax 是
-// 虚拟屏幕在该轴上的绝对边界。已有标注时优先保证内容不被裁掉;松开时可按活动端
-// 当前所在侧补足最小尺寸,整个过程只移动活动端,因此穿越后不会带动固定端漂移。
-static int ConstrainResizeActiveCoordinate(int rawActive, int originalActive, int fixed,
- int screenMin, int screenMax,
- bool hasContent, int contentMin, int contentMax,
- bool enforceMinSize) {
- int active = (std::max)(screenMin, (std::min)(rawActive, screenMax));
-
- if (hasContent) {
- if (fixed <= contentMin) {
- // 内容位于固定端高侧:活动端必须覆盖内容高边,不能穿越后把内容留在选区外。
- active = (std::max)(active, contentMax);
- } else if (fixed >= contentMax) {
- // 内容位于固定端低侧:活动端必须覆盖内容低边。
- active = (std::min)(active, contentMin);
- } else {
- // 固定端落在内容内部时不存在可完整覆盖内容的单侧区间,保留按下时活动端。
- active = originalActive;
- }
- active = (std::max)(screenMin, (std::min)(active, screenMax));
- }
-
- if (enforceMinSize) {
- // 活动端恰好落在固定端时沿按下时的方向补足,避免释放后方向不确定。
- bool onLowSide = active < fixed || (active == fixed && originalActive < fixed);
- if (onLowSide) {
- active = (std::min)(active, fixed - SC_MIN_SELECTION);
- } else {
- active = (std::max)(active, fixed + SC_MIN_SELECTION);
- }
- // 固定端靠近虚拟屏幕边缘时,目标侧可能不足最小尺寸;此时边界优先且固定端不动。
- active = (std::max)(screenMin, (std::min)(active, screenMax));
- }
-
- return active;
-}
-
-// 从鼠标按下时的选区快照计算本帧 resize 结果(绝对虚拟屏幕坐标)。
-// 每个活动轴都只更新对应手柄端,固定边/固定对角点始终取 startSelection;活动端可穿过
-// 固定端,最后仅为绘制和导出调用一次 NormalizeRect。contentBounds 在 hasContent=true 时
-// 限制活动端以保留已有标注;enforceMinSize 仅用于松开时沿最终方向补足最小尺寸。
-static RECT ResizeSelectionFromHandle(const RECT& startSelection, int handle, int dx, int dy,
- const RECT& virtualBounds,
- bool hasContent, const RECT& contentBounds,
- bool enforceMinSize) {
- RECT resized = startSelection;
-
- bool movesLeft = handle == RH_Left || handle == RH_TopLeft || handle == RH_BottomLeft;
- bool movesRight = handle == RH_Right || handle == RH_TopRight || handle == RH_BottomRight;
- bool movesTop = handle == RH_Top || handle == RH_TopLeft || handle == RH_TopRight;
- bool movesBottom = handle == RH_Bottom || handle == RH_BottomLeft || handle == RH_BottomRight;
-
- if (movesLeft) {
- resized.left = ConstrainResizeActiveCoordinate(
- startSelection.left + dx, startSelection.left, startSelection.right,
- virtualBounds.left, virtualBounds.right,
- hasContent, contentBounds.left, contentBounds.right, enforceMinSize);
- } else if (movesRight) {
- resized.right = ConstrainResizeActiveCoordinate(
- startSelection.right + dx, startSelection.right, startSelection.left,
- virtualBounds.left, virtualBounds.right,
- hasContent, contentBounds.left, contentBounds.right, enforceMinSize);
- }
-
- if (movesTop) {
- resized.top = ConstrainResizeActiveCoordinate(
- startSelection.top + dy, startSelection.top, startSelection.bottom,
- virtualBounds.top, virtualBounds.bottom,
- hasContent, contentBounds.top, contentBounds.bottom, enforceMinSize);
- } else if (movesBottom) {
- resized.bottom = ConstrainResizeActiveCoordinate(
- startSelection.bottom + dy, startSelection.bottom, startSelection.top,
- virtualBounds.top, virtualBounds.bottom,
- hasContent, contentBounds.top, contentBounds.bottom, enforceMinSize);
- }
-
- return NormalizeRect(resized);
-}
-
-// 取调整手柄在选区上的锚点(绝对虚拟屏幕坐标),作为放大镜焦点。
-// 锚点 = 手柄所在边/角的端点:左右手柄取边中点,顶/底取中点,四角取角点。
-static void GetResizeHandleAnchor(int handle, const RECT& sel, int& ax, int& ay) {
- int cx = (sel.left + sel.right) / 2, cy = (sel.top + sel.bottom) / 2;
- switch (handle) {
- case RH_Left: ax = sel.left; ay = cy; break;
- case RH_Right: ax = sel.right; ay = cy; break;
- case RH_Top: ax = cx; ay = sel.top; break;
- case RH_Bottom: ax = cx; ay = sel.bottom; break;
- case RH_TopLeft: ax = sel.left; ay = sel.top; break;
- case RH_TopRight: ax = sel.right; ay = sel.top; break;
- case RH_BottomLeft: ax = sel.left; ay = sel.bottom; break;
- case RH_BottomRight: ax = sel.right; ay = sel.bottom; break;
- default: ax = cx; ay = cy; break;
- }
-}
-
-// 从按下快照 + (鼠标位移 + 键盘微调) 实时重算选区,并刷新放大镜焦点像素色。
-// MOUSEMOVE 与 KEYDOWN 共用,保证两者一致。拖拽过程不强制最小尺寸(释放时再补)。
-static void ApplyResizeSelection(HWND hwnd, CaptureContext* ctx) {
- const RECT& start = ctx->dragStartSelection;
- int dx = (ctx->mouseX - ctx->dragStartX) + ctx->kbDX;
- int dy = (ctx->mouseY - ctx->dragStartY) + ctx->kbDY;
- RECT vb = { ctx->virtualX, ctx->virtualY,
- ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
- RECT cb = {0, 0, 0, 0};
- bool hasContent = CalcAnnotationsBounds(ctx->annotations, cb, ctx->backDC);
- ctx->selection = ResizeSelectionFromHandle(start, ctx->resizeHandle, dx, dy,
- vb, hasContent, cb, false);
- // 放大镜焦点取活动手柄锚点(随活动边移动,键盘微调时鼠标不动也能跟随)
- int ax, ay;
- GetResizeHandleAnchor(ctx->resizeHandle, ctx->selection, ax, ay);
- ctx->currentColor = GetPixelColorFromBitmap(ctx->memDC, ax, ay,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale);
- InvalidateRect(hwnd, NULL, FALSE);
-}
-
-// 方向键微调选区:CS_Resizing 微调活动边(累加 kbDX/kbDY);CS_Confirmed 整体平移 1px。
-// Shift 加速到 10px。返回是否已处理(已处理则调用方 return 0)。
-static bool HandleSelectionNudgeKey(HWND hwnd, CaptureContext* ctx, WPARAM vk) {
- int step = (GetKeyState(VK_SHIFT) & 0x8000) ? 10 : 1;
- int ddx = 0, ddy = 0;
- switch (vk) {
- case VK_LEFT: ddx = -step; break;
- case VK_RIGHT: ddx = step; break;
- case VK_UP: ddy = -step; break;
- case VK_DOWN: ddy = step; break;
- default: return false;
- }
- if (ctx->state == CS_Resizing && !IsCornerRadiusHandle(ctx->resizeHandle)) {
- ctx->kbDX += ddx;
- ctx->kbDY += ddy;
- ApplyResizeSelection(hwnd, ctx);
- return true;
- }
- if (ctx->state == CS_Confirmed && !ctx->popupOpen) {
- RECT& s = ctx->selection;
- int w = s.right - s.left, h = s.bottom - s.top;
- int nl = s.left + ddx, nt = s.top + ddy;
- if (nl < ctx->virtualX) nl = ctx->virtualX;
- if (nt < ctx->virtualY) nt = ctx->virtualY;
- if (nl + w > ctx->virtualX + ctx->virtualW) nl = ctx->virtualX + ctx->virtualW - w;
- if (nt + h > ctx->virtualY + ctx->virtualH) nt = ctx->virtualY + ctx->virtualH - h;
- s.left = nl; s.right = nl + w; s.top = nt; s.bottom = nt + h;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return true;
- }
- return false;
-}
-
-// 测量文字标注的包围盒(绝对虚拟屏幕坐标)
-// 用 GDI+ MeasureString 测量,与提交态 DrawString 渲染同源:
-// - 字形左上角相对锚点 (x1,y1) 有内部偏移 (offX, offY)
-// - 宽高为紧凑字形范围(不含 GDI GetTextExtentPoint32W 的尾部 overhang)
-// - 边框左右 padding 对称,完整包住文字
-// 性能:测量结果只依赖 (text, fontPx),与锚点无关,故缓存"相对锚点的偏移/尺寸"。
-// 命中缓存(textCacheValid && textCacheFontPx == fontPx)时直接复用,跳过 GDI+ 测量;
-// 锚点变化(TransformAnnotationByBox 平移)不影响缓存有效性。
-// 缓存对象(FontFamily/StringFormat/Font)取自会话级 InitGdipResources(经 g_captureCtx)。
-static RECT MeasureTextAnnotation(HDC hdc, Annotation& a) {
- RECT rect = { a.x1, a.y1, a.x1, a.y1 };
- if (a.type != AT_Text || a.text.empty()) return rect;
-
- int fontPx = a.thickness;
- if (fontPx < 8) fontPx = 8;
-
- float offX = 0, offY = 0, w = 0, h = 0;
- // 缓存命中:直接复用相对锚点的偏移/尺寸(与 fontPx 校验)
- if (a.textCacheValid && a.textCacheFontPx == fontPx) {
- offX = a.textCacheOffX;
- offY = a.textCacheOffY;
- w = a.textCacheW;
- h = a.textCacheH;
- } else if (g_captureCtx && g_captureCtx->gdipInited
- && g_captureCtx->gdipFontFamily && g_captureCtx->gdipStrFmt) {
- // 缓存未命中:做一次 GDI+ 测量并回填缓存
- Gdiplus::Graphics graphics(hdc);
- graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
- Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
- Gdiplus::StringFormat* sf = g_captureCtx->gdipStrFmt;
- Gdiplus::RectF origin(0, 0, 0, 0);
- Gdiplus::RectF bounds;
- graphics.MeasureString(a.text.c_str(), (INT)a.text.size(), font, origin, sf, &bounds);
- offX = bounds.X;
- offY = bounds.Y;
- w = bounds.Width;
- h = bounds.Height;
- a.textCacheValid = true;
- a.textCacheFontPx = fontPx;
- a.textCacheOffX = offX;
- a.textCacheOffY = offY;
- a.textCacheW = w;
- a.textCacheH = h;
- }
-
- const int padding = 4;
- // 字形左上角(绝对坐标)= 锚点 + GDI+ 内部偏移
- int glyphLeft = a.x1 + (int)floorf(offX);
- int glyphTop = a.y1 + (int)floorf(offY);
- int glyphRight = a.x1 + (int)ceilf(offX + w);
- int glyphBottom = a.y1 + (int)ceilf(offY + h);
- rect.left = glyphLeft - padding;
- rect.top = glyphTop - padding;
- rect.right = glyphRight + padding;
- rect.bottom = glyphBottom + padding;
- return rect;
-}
-
-// 命中测试文字标注,返回标注索引(-1 表示未命中)
-// 非常量引用:MeasureTextAnnotation 会回填文字测量缓存。
-static int HitTestTextAnnotations(std::vector& anns, int x, int y, HDC hdc) {
- for (int i = (int)anns.size() - 1; i >= 0; i--) {
- if (anns[i].type == AT_Text) {
- RECT rect = MeasureTextAnnotation(hdc, anns[i]);
- if (PointInRect(x, y, rect)) {
- return i;
- }
- }
- }
- return -1;
-}
-
-// ==================== 通用标注几何(选中/拖拽/缩放) ====================
-// 以下函数把「仅文字标注」具备的 hover/选中/拖拽/缩放能力推广到所有标注类型。
-// 坐标系与 Annotation 一致:绝对虚拟屏幕坐标(与 ctx->mouseX/selection 同帧)。
-
-// 点 (px,py) 到线段 (ax,ay)-(bx,by) 的最短距离(像素)。
-// 用于画笔/箭头/马赛克涂抹等线条型标注的命中检测。
-static double PointToSegmentDist(double px, double py, double ax, double ay, double bx, double by) {
- double dx = bx - ax, dy = by - ay;
- double lenSq = dx * dx + dy * dy;
- double t = 0;
- if (lenSq > 1e-9) {
- t = ((px - ax) * dx + (py - ay) * dy) / lenSq;
- if (t < 0) t = 0;
- else if (t > 1) t = 1;
- }
- double cx = ax + t * dx;
- double cy = ay + t * dy;
- double ex = px - cx, ey = py - cy;
- return std::sqrt(ex * ex + ey * ey);
-}
-
-// 点 (px,py) 到折线 pts 的最短距离(像素)。
-static double PointToPolylineDist(double px, double py, const std::vector& pts) {
- if (pts.empty()) return 1e18;
- if (pts.size() == 1) {
- double ex = px - pts[0].x, ey = py - pts[0].y;
- return std::sqrt(ex * ex + ey * ey);
- }
- double best = 1e18;
- for (size_t i = 0; i + 1 < pts.size(); i++) {
- double d = PointToSegmentDist(px, py, pts[i].x, pts[i].y, pts[i + 1].x, pts[i + 1].y);
- if (d < best) best = d;
- }
- return best;
-}
-
-// 计算单个标注的包围盒(绝对虚拟屏幕坐标)。
-// 返回的矩形完整包住标注可见区域(含线宽/半径/文字 padding),用于选中框与四角 resize 手柄定位。
-// hdc 仅在 AT_Text 时用于 GDI+ 测量文字宽高。
-// 非常量引用:AT_Text 分支会经 MeasureTextAnnotation 回填文字测量缓存。
-static RECT MeasureAnnotationBounds(Annotation& a, HDC hdc) {
- RECT r = { 0, 0, 0, 0 };
- auto setBox = [&](int x1, int y1, int x2, int y2) {
- r.left = (std::min)(x1, x2);
- r.top = (std::min)(y1, y2);
- r.right = (std::max)(x1, x2);
- r.bottom = (std::max)(y1, y2);
- };
- switch (a.type) {
- case AT_Rect:
- case AT_Circle:
- case AT_Mosaic:
- if (a.type == AT_Mosaic && !a.mosaicRect) {
- // 涂抹模式:路径包围盒 + 半径
- if (a.pts.empty()) { r = { 0,0,0,0 }; return r; }
- int rad = a.brushRadius;
- int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
- for (const POINT& p : a.pts) {
- if (p.x < minL) minL = p.x;
- if (p.x > maxR) maxR = p.x;
- if (p.y < minT) minT = p.y;
- if (p.y > maxB) maxB = p.y;
- }
- r.left = minL - rad; r.top = minT - rad;
- r.right = maxR + rad; r.bottom = maxB + rad;
- return r;
- }
- // Rect/Circle/MosaicRect:以两个对角点为包围盒
- setBox(a.x1, a.y1, a.x2, a.y2);
- return r;
- case AT_Arrow:
- return MeasureArrowAnnotationBounds(a);
- case AT_Brush: {
- if (a.pts.empty()) { r = { 0,0,0,0 }; return r; }
- int minL = INT_MAX, minT = INT_MAX, maxR = INT_MIN, maxB = INT_MIN;
- for (const POINT& p : a.pts) {
- if (p.x < minL) minL = p.x;
- if (p.x > maxR) maxR = p.x;
- if (p.y < minT) minT = p.y;
- if (p.y > maxB) maxB = p.y;
- }
- r.left = minL; r.top = minT; r.right = maxR; r.bottom = maxB;
- return r;
- }
- case AT_Text:
- return MeasureTextAnnotation(hdc, a);
- }
- return r;
-}
-
-// 命中测试任意标注,返回索引(-1 表示未命中)。
-// 从顶层(数组末尾,绘制最上层)向底层遍历,命中第一个即返回(与视觉 z-order 一致)。
-// 容差按线宽自适应:细线给 6px 余量,粗线给半个线宽,避免细线难以点中。
-// 非常量引用:AT_Text/AT_Mosaic 分支会回填文字测量缓存。
-static int HitTestAnnotation(std::vector& anns, int x, int y, HDC hdc) {
- for (int i = (int)anns.size() - 1; i >= 0; i--) {
- Annotation& a = anns[i];
- // 马赛克区域(框选/涂抹)不可选中、不可拖拽,直接跳过命中测试。
- if (a.type == AT_Mosaic) continue;
- double tol = (std::max)(6.0, a.thickness / 2.0 + 2.0);
- switch (a.type) {
- case AT_Rect: {
- // 仅命中矩形四条边轮廓(空心框),内部空白不选中。
- // 到任一边线段的最短距离 ≤ tol 即命中(容差向外扩散几像素辅助探测)。
- double d1 = PointToSegmentDist((double)x, (double)y, a.x1, a.y1, a.x2, a.y1);
- double d2 = PointToSegmentDist((double)x, (double)y, a.x2, a.y2, a.x1, a.y2);
- double d3 = PointToSegmentDist((double)x, (double)y, a.x1, a.y2, a.x1, a.y1);
- double d4 = PointToSegmentDist((double)x, (double)y, a.x2, a.y1, a.x2, a.y2);
- double dm = (std::min)((std::min)(d1, d2), (std::min)(d3, d4));
- if (dm <= tol) return i;
- break;
- }
- case AT_Circle: {
- // 椭圆(由包围盒定义)轮廓命中:用归一化径向距离近似。
- // r = hypot((x-cx)/a, (y-cy)/b),r≈1 即落在椭圆上。
- double cx = (a.x1 + a.x2) * 0.5;
- double cy = (a.y1 + a.y2) * 0.5;
- double aax = std::fabs((double)a.x2 - a.x1) * 0.5;
- double aay = std::fabs((double)a.y2 - a.y1) * 0.5;
- if (aax < 0.5 && aay < 0.5) {
- // 退化为点:直接点距
- double ex = x - cx, ey = y - cy;
- if (std::sqrt(ex * ex + ey * ey) <= tol) return i;
- } else if (aax < 0.5) {
- // 退化为竖直线段
- if (PointToSegmentDist((double)x, (double)y, cx, a.y1, cx, a.y2) <= tol) return i;
- } else if (aay < 0.5) {
- // 退化为水平线段
- if (PointToSegmentDist((double)x, (double)y, a.x1, cy, a.x2, cy) <= tol) return i;
- } else {
- double r = std::sqrt(((x - cx) / aax) * ((x - cx) / aax)
- + ((y - cy) / aay) * ((y - cy) / aay));
- // (r-1)*min(a,b) 把归一化距离换算回像素(用短半轴近似像素半径,保守且足够命中探测)
- double minAxis = (std::min)(aax, aay);
- if (std::fabs(r - 1.0) * minAxis <= tol) return i;
- }
- break;
- }
- case AT_Arrow: {
- if (PointToSegmentDist((double)x, (double)y, a.x1, a.y1, a.x2, a.y2) <= tol)
- return i;
- break;
- }
- case AT_Brush: {
- if (PointToPolylineDist((double)x, (double)y, a.pts) <= tol) return i;
- break;
- }
- case AT_Mosaic: {
- if (a.mosaicRect) {
- RECT box = MeasureAnnotationBounds(a, hdc);
- if (PointInRect(x, y, box)) return i;
- } else {
- // 涂抹:路径 + 半径
- if (PointToPolylineDist((double)x, (double)y, a.pts) <= a.brushRadius) return i;
- }
- break;
- }
- case AT_Text: {
- RECT box = MeasureTextAnnotation(hdc, a);
- if (PointInRect(x, y, box)) return i;
- break;
- }
- }
- }
- return -1;
-}
-
-// 命中测试标注包围盒的 8 个手柄(4 角 + 4 边中点),返回 ResizeHandle 或 RH_None。
-// 容差沿用选区手柄的 handleSize,保证与选区手柄一致的可点击范围。
-static int HitTestAnnotationHandle(int x, int y, const RECT& box, int handleSize) {
- int hs = handleSize;
- int cx = (box.left + box.right) / 2;
- int cy = (box.top + box.bottom) / 2;
- struct { int hx, hy; int handle; } tests[] = {
- { box.left, box.top, RH_TopLeft },
- { box.right, box.top, RH_TopRight },
- { box.left, box.bottom, RH_BottomLeft },
- { box.right, box.bottom, RH_BottomRight },
- { cx, box.top, RH_Top },
- { cx, box.bottom, RH_Bottom },
- { box.left, cy, RH_Left },
- { box.right, cy, RH_Right },
- };
- for (auto& t : tests) {
- RECT hitBox = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
- if (PointInRect(x, y, hitBox)) return t.handle;
- }
- return RH_None;
-}
-
-// 命中测试箭头的起点/终点端点手柄,返回 RH_ArrowStart / RH_ArrowEnd / RH_None。
-// 箭头只允许拖拽两个端点(而非四角包围盒缩放),故单独命中 a.x1,y1 / a.x2,y2。
-// 容差沿用 handleSize,与其它标注手柄可点击范围一致。
-static int HitTestArrowEndpoints(int x, int y, const Annotation& a, int handleSize) {
- int hs = handleSize;
- struct { int hx, hy; int handle; } tests[] = {
- { a.x1, a.y1, RH_ArrowStart },
- { a.x2, a.y2, RH_ArrowEnd },
- };
- for (auto& t : tests) {
- RECT hitBox = { t.hx - hs, t.hy - hs, t.hx + hs, t.hy + hs };
- if (PointInRect(x, y, hitBox)) return t.handle;
- }
- return RH_None;
-}
-
-// 按标注类型返回其缩放手柄命中(统一入口,供 LButtonDown/MouseMove/SETCURSOR 复用):
-// 箭头 = 起点/终点 2 端点;矩形/圆 = 包围盒 8 手柄(4 角 + 4 边中点);
-// 画笔/马赛克 = 无手柄(RH_None,不可缩放,画笔仅可整体拖动)。
-// hdc 仅用于测量文字包围盒(此处文字不会进入,但签名与 HitTestAnnotation 对齐便于扩展)。
-static int HitTestAnnotationResizeHandle(const Annotation& a, int x, int y, HDC hdc, int handleSize) {
- switch (a.type) {
- case AT_Arrow:
- return HitTestArrowEndpoints(x, y, a, handleSize);
- case AT_Rect:
- case AT_Circle: {
- RECT box = MeasureAnnotationBounds(const_cast(a), hdc);
- return HitTestAnnotationHandle(x, y, box, handleSize);
- }
- default:
- // AT_Brush / AT_Mosaic / AT_Text 无缩放手柄
- return RH_None;
- }
-}
-
-// 按包围盒变换(平移 + 等比/非等比缩放)映射标注所有坐标。
-// oldBox -> newBox:标注内每个点 p 映射为 newBox.left + (p-oldBox.left)*sx 等。
-// 用于四角 resize(画笔/涂抹路径按包围盒整体缩放,保持形状比例)。
-// sx/sy 防 0:旧宽/高为 0 时退化为平移。
-static void TransformAnnotationByBox(Annotation& a, const RECT& oldBox, const RECT& newBox) {
- double oldW = oldBox.right - oldBox.left;
- double oldH = oldBox.bottom - oldBox.top;
- double newW = newBox.right - newBox.left;
- double newH = newBox.bottom - newBox.top;
- double sx = (oldW > 0.5) ? newW / oldW : 1.0;
- double sy = (oldH > 0.5) ? newH / oldH : 1.0;
- auto mapX = [&](int v) { return (int)(newBox.left + (v - oldBox.left) * sx + 0.5); };
- auto mapY = [&](int v) { return (int)(newBox.top + (v - oldBox.top) * sy + 0.5); };
- switch (a.type) {
- case AT_Rect:
- case AT_Circle:
- case AT_Arrow:
- case AT_Mosaic:
- if (a.type == AT_Mosaic && !a.mosaicRect) {
- for (POINT& p : a.pts) { p.x = mapX(p.x); p.y = mapY(p.y); }
- } else {
- a.x1 = mapX(a.x1); a.y1 = mapY(a.y1);
- a.x2 = mapX(a.x2); a.y2 = mapY(a.y2);
- }
- break;
- case AT_Brush:
- for (POINT& p : a.pts) { p.x = mapX(p.x); p.y = mapY(p.y); }
- break;
- case AT_Text:
- // 文字不经过此路径(走原 draggingTextAnnotation 平移),此处兜底平移锚点
- a.x1 = mapX(a.x1); a.y1 = mapY(a.y1);
- break;
- }
-}
-
-// ==================== GDI+ 文字测量(与提交态渲染同源) ====================
-// 提交态文字用 GDI+ DrawString 渲染(StringAlignmentNear 顶部左对齐)。
-// 为保证输入态边框/光标/命中与提交态视觉一致,输入态也必须用 GDI+ 测量。
-// 旧实现用 GDI GetTextExtentPoint32W,其宽度含尾部 overhang(右侧多出留白),
-// 且垂直基线与 GDI+ 不同(导致文字偏靠下),故统一改用 GDI+。
-
-// 测量文字的紧凑包围盒:返回 DrawString(StringAlignmentNear/Near) 下字形相对锚点的偏移与尺寸。
-// outOffsetX/outOffsetY:字形左上角相对锚点 (0,0) 的偏移(通常 X≈0,Y 为字体内部顶部 leading)。
-// outW/outH:字形紧凑宽高。
-// 这样边框 = 锚点 + offset ± padding,可左右对称、紧贴字形。
-static void MeasureTextGdip(HDC hdc, const std::wstring& text, int fontPx,
- float& outOffsetX, float& outOffsetY, float& outW, float& outH) {
- outOffsetX = 0; outOffsetY = 0; outW = 0; outH = 0;
- if (text.empty()) {
- outH = (float)fontPx;
- return;
- }
- // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
- if (!g_captureCtx || !g_captureCtx->gdipInited
- || !g_captureCtx->gdipFontFamily || !g_captureCtx->gdipStrFmt) return;
- Gdiplus::Graphics graphics(hdc);
- graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
- Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
- Gdiplus::StringFormat* sf = g_captureCtx->gdipStrFmt;
- // MeasureString 返回与 DrawString 一致的布局包围盒(含 GDI+ 的字体内部间距)。
- Gdiplus::RectF origin(0, 0, 0, 0);
- Gdiplus::RectF bounds;
- graphics.MeasureString(text.c_str(), (INT)text.size(), font, origin, sf, &bounds);
- outOffsetX = bounds.X;
- outOffsetY = bounds.Y;
- outW = bounds.Width;
- outH = bounds.Height;
-}
-
-// 测量逐字符累计宽度(用于光标定位/选中高亮)。
-// widths[i] 表示前 i 个字符(text[0..i-1])的累计紧凑宽度,widths[0]=0。
-// 与 DrawString 渲染进度一致,光标 x = textX + widths[i]。
-// P2 优化:原实现逐前缀循环调 MeasureString(O(n²)),改用 MeasureCharacterRanges
-// 一次测出各前缀边界(O(n))。MeasureCharacterRanges 会通过 SetMeasurableCharacterRanges
-// 修改 StringFormat,故不能就地改会话共享的 ctx->gdipStrFmt,这里栈上复制一份使用。
-static void MeasureCharWidthsGdip(HDC hdc, const std::wstring& text, int fontPx,
- std::vector& widths) {
- widths.assign(text.size() + 1, 0.0f);
- if (text.empty()) return;
- // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
- if (!g_captureCtx || !g_captureCtx->gdipInited
- || !g_captureCtx->gdipFontFamily || !g_captureCtx->gdipStrFmt) return;
- Gdiplus::Graphics graphics(hdc);
- graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
- Gdiplus::Font* font = GetGdipFont(g_captureCtx, fontPx);
- // 复制会话级 StringFormat(Near/Near),在其上设置可测范围,避免污染共享对象。
- Gdiplus::StringFormat localSf(g_captureCtx->gdipStrFmt);
-
- const int n = (int)text.size();
- // 布局矩形足够大以容纳整串,避免 Near/Near 文本换行或裁剪。
- Gdiplus::RectF layoutRect(0, 0, 1000000.0f, 1000000.0f);
-
- // SetMeasurableCharacterRanges 单次可设范围数受 GDI+ 内部缓冲限制,分批处理。
- const int kBatch = 16;
- Gdiplus::CharacterRange ranges[kBatch];
- Gdiplus::Region regions[kBatch];
- for (int start = 1; start <= n; start += kBatch) {
- int cnt = (n - start + 1 < kBatch) ? (n - start + 1) : kBatch;
- for (int j = 0; j < cnt; j++) {
- int i = start + j; // 前缀长度 i (1..n)
- ranges[j].First = 0;
- ranges[j].Length = i;
- }
- if (localSf.SetMeasurableCharacterRanges(cnt, ranges) != Gdiplus::Ok) continue;
- if (graphics.MeasureCharacterRanges(text.c_str(), n, font, layoutRect,
- &localSf, cnt, regions) != Gdiplus::Ok) continue;
- for (int j = 0; j < cnt; j++) {
- int i = start + j;
- Gdiplus::RectF bounds;
- // 前 i 个字符区域的右边缘 = 原 MeasureString(prefix) 的 bounds.X + bounds.Width。
- if (regions[j].GetBounds(&bounds, &graphics) == Gdiplus::Ok) {
- widths[i] = bounds.GetRight();
- }
- }
- }
-}
-
-// 根据鼠标位置计算光标在文字中的位置(字符索引)
-// 用 GDI+ 逐字符测量,与 DrawString 渲染进度一致。
-static int CalcCaretPosFromMouse(HDC hdc, const std::wstring& text, int fontPx, int textX, int mouseX) {
- if (text.empty()) return 0;
-
- std::vector widths;
- MeasureCharWidthsGdip(hdc, text, fontPx, widths);
-
- int bestPos = 0;
- float bestDist = FLT_MAX;
- for (size_t i = 0; i <= text.size(); i++) {
- float x = (float)textX + widths[i];
- float dist = fabsf(x - (float)mouseX);
- if (dist < bestDist) {
- bestDist = dist;
- bestPos = (int)i;
- }
- }
- return bestPos;
-}
-
-// P1 脏区域辅助:使"被拖拽/缩放标注的旧位置 ∪ 新位置"区域无效。
-// 用于 resizingAnnotation/draggingAnnotation/draggingTextAnnotation 的 MOUSEMOVE。
-// curBox 为当前帧标注包围盒(绝对虚拟屏幕坐标,由调用方用 MeasureAnnotationBounds 算);
-// 与上帧缓存的 lastAnnotationBox 求并集后转 backDC 坐标并扩大。lastAnnotationBox 由 WM_PAINT 更新。
-// 扩大量需覆盖选中态的 resize 手柄:手柄圆心贴在包围盒边缘,半径 = handleSize/2,
-// 故 inflate 量取 handleSize/2 + 余量(= handleMetrics.handleMargin),确保旧/新手柄范围都在脏区内,
-// 避免拖拽时残留手柄痕迹。
-static void InvalidateAnnotationOp(HWND hwnd, CaptureContext* ctx, const RECT& curBox) {
- RECT b;
- if (ctx->hasLastAnnotationBox) {
- b = UnionRectSafe(ctx->lastAnnotationBox, curBox);
- } else {
- b = curBox;
- }
- b.left -= ctx->virtualX; b.top -= ctx->virtualY;
- b.right -= ctx->virtualX; b.bottom -= ctx->virtualY;
- const int handleMargin = ctx->handleMetrics.handleMargin; // 手柄半径 + 描边/抗锯齿余量
- InvalidateRect(hwnd, &InflateRectBy(b, handleMargin), FALSE);
-}
-
-// 选中/取消选中覆盖物时的精确脏区计算。
-// 根据当前 selectedAnnotation / selectedTextAnnotation 的包围盒算出需重绘的脏区
-// (含 resize 手柄半径余量),可选合并工具栏 + popup 旧位置(切换不同类型工具时)。
-// 调用时机:必须在调用方清空 selected*/hovered* 之前调用,否则读不到旧选中项的包围盒。
-// 坐标基准:返回 backDC/客户区坐标(已减 virtualX/Y),可直接用于 InvalidateRect(hwnd, &r, FALSE)。
-// 参数 includeToolbar:true 时并集工具栏矩形和 popup 矩形(覆盖 activeTool 切换导致的高亮按钮
-// 位移 + popup 打开/关闭/切换),用于「切换到不同类型工具」场景。
-// 返回值可能为无效矩形({0,0,0,0}):表示当前无任何选中项,调用方据此决定是否全屏重绘兜底。
-static RECT CalcSelectionDirty(CaptureContext* ctx, bool includeToolbar) {
- RECT dirty = {0, 0, 0, 0};
- const int handleMargin = ctx->handleMetrics.handleMargin; // 与 InvalidateAnnotationOp 一致的手柄半径余量
-
- // 非文字标注选中项的包围盒
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- RECT r = MeasureAnnotationBounds(ctx->annotations[ctx->selectedAnnotation], ctx->backDC);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
- }
- // 文字标注选中项的包围盒(MeasureTextAnnotation 含文字 padding)
- if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ctx->selectedTextAnnotation]);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
- }
- // 切换不同类型工具:activeTool 变化后高亮按钮位移,popup 可能打开/关闭/切换类型,
- // 将整条工具栏和当前 popup 都纳入脏区(宁大勿小,避免按钮高亮残影)。
- if (includeToolbar) {
- if (IsValidRect(ctx->toolbarRect)) {
- dirty = UnionRectSafe(dirty, InflateRectBy(ctx->toolbarRect, 4));
- }
- if (IsValidRect(ctx->popupRect)) {
- dirty = UnionRectSafe(dirty, InflateRectBy(ctx->popupRect, 4));
- }
- }
- return dirty;
-}
-
-// P1 脏区域辅助:使文字行区域无效(用于文字编辑态的键盘输入/删除/光标移动)。
-// 文字行 y 范围取自上帧光标高度(lastCaretRect),x 范围覆盖整个选区宽度
-// (文字必然在选区内,选区宽度是文字行可能宽度的安全上界)。lastCaret 无效时全屏。
-// 坐标基准:backDC 坐标 = 客户区坐标(已减 virtualX/Y),与 InvalidateRect 一致。
-static void InvalidateTextLine(HWND hwnd, CaptureContext* ctx) {
- if (!ctx->hasLastCaret) {
- InvalidateRect(hwnd, NULL, FALSE);
- return;
- }
- // 文字行矩形:选区宽度 × 光标行高(backDC 坐标)
- RECT line = {
- ctx->selection.left - ctx->virtualX,
- ctx->lastCaretRect.top,
- ctx->selection.right - ctx->virtualX,
- ctx->lastCaretRect.bottom
- };
- InvalidateRect(hwnd, &InflateRectBy(line, 4), FALSE);
-}
-
-// 截图覆盖层窗口过程(双缓冲渲染)
-static LRESULT CALLBACK ScreenshotOverlayWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) {
- CaptureContext* ctx = g_captureCtx;
- if (!ctx) return DefWindowProc(hwnd, msg, wParam, lParam);
-
- switch (msg) {
- case WM_PAINT: {
- PAINTSTRUCT ps;
- HDC hdc = BeginPaint(hwnd, &ps);
- HDC backDC = ctx->backDC;
-
- // 计算浮窗位置
- int panelX, panelY;
- CalcPanelPosition(ctx->mouseX, ctx->mouseY,
- ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->panelMetrics, panelX, panelY);
- // 转为相对坐标
- int panelXRel = panelX - ctx->virtualX;
- int panelYRel = panelY - ctx->virtualY;
-
- RECT curPanelRect = { panelXRel, panelYRel,
- panelXRel + ctx->panelMetrics.w, panelYRel + ctx->panelMetrics.h };
-
- // 当前选区矩形
- RECT curSelRect = {0,0,0,0};
- if (ctx->state == CS_Selecting) {
- curSelRect.left = (std::min)(ctx->startX, ctx->endX) - ctx->virtualX;
- curSelRect.top = (std::min)(ctx->startY, ctx->endY) - ctx->virtualY;
- curSelRect.right = (std::max)(ctx->startX, ctx->endX) - ctx->virtualX;
- curSelRect.bottom = (std::max)(ctx->startY, ctx->endY) - ctx->virtualY;
- } else if (ctx->state == CS_Confirmed || ctx->state == CS_Resizing
- || ctx->state == CS_Moving || ctx->state == CS_Drawing
- || ctx->state == CS_TextEditing) {
- curSelRect.left = ctx->selection.left - ctx->virtualX;
- curSelRect.top = ctx->selection.top - ctx->virtualY;
- curSelRect.right = ctx->selection.right - ctx->virtualX;
- curSelRect.bottom = ctx->selection.bottom - ctx->virtualY;
- }
-
- // 当前高亮窗口矩形
- RECT curHlRect = {0,0,0,0};
- if (ctx->state == CS_Idle && ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
- const RECT& wr = ctx->windows[ctx->hoveredWindow].rect;
- curHlRect = { wr.left - ctx->virtualX, wr.top - ctx->virtualY,
- wr.right - ctx->virtualX, wr.bottom - ctx->virtualY };
- }
-
- double ds = ctx->dpiScale;
- int physW = (int)(ctx->virtualW * ds + 0.5);
- int physH = (int)(ctx->virtualH * ds + 0.5);
-
- // P1 脏区域优化:仅"选区正在移动/缩放"的状态(Selecting/Resizing/Moving)
- // 因遮罩边界大范围变化,必须整屏恢复背景。其余 confirmedMode 状态(Confirmed/Drawing/
- // TextEditing)选区静止,可用 ps.rcPaint 局部刷新(由各调用点 InvalidateRect 传精确矩形)。
- if (ctx->state == CS_Selecting || ctx->state == CS_Resizing || ctx->state == CS_Moving) {
- ctx->needFullRedraw = true;
- }
-
- // 本帧脏区域:fullFrame 时为全屏,否则取 ps.rcPaint(由 InvalidateRect 矩形决定)。
- // 客户区坐标 = backDC 坐标(窗口原点 = 虚拟屏幕左上角),可直接用于背景恢复与裁剪。
- bool fullFrame = ctx->needFullRedraw;
- RECT dirtyRect;
- HRGN dirtyRgn = NULL;
- if (fullFrame) {
- dirtyRect = { 0, 0, ctx->virtualW, ctx->virtualH };
- } else {
- // ps.rcPaint 在 InvalidateRect(NULL) 时为整个客户区(等价全屏),传矩形时为该矩形。
- dirtyRect = ps.rcPaint;
- // 限定到虚拟屏幕范围内(防御性:ps.rcPaint 理论上不会超出客户区=虚拟屏幕)
- if (dirtyRect.left < 0) dirtyRect.left = 0;
- if (dirtyRect.top < 0) dirtyRect.top = 0;
- if (dirtyRect.right > ctx->virtualW) dirtyRect.right = ctx->virtualW;
- if (dirtyRect.bottom > ctx->virtualH) dirtyRect.bottom = ctx->virtualH;
- }
-
- // 恢复背景:fullFrame 全屏恢复;局部帧只恢复 dirtyRect(其余区域保留上帧最终画面)。
- if (fullFrame) {
- if (ds > 1.01 || ds < 0.99) {
- StretchBlt(backDC, 0, 0, ctx->virtualW, ctx->virtualH,
- ctx->memDC, 0, 0, physW, physH, SRCCOPY);
- } else {
- BitBlt(backDC, 0, 0, ctx->virtualW, ctx->virtualH,
- ctx->memDC, 0, 0, SRCCOPY);
- }
- ctx->needFullRedraw = false;
- } else {
- // 局部恢复脏区域为原始背景(后续渲染管线会对该区域重画遮罩/标注等,
- // 因 AlphaBlend 作用于已恢复的清晰背景,结果与全屏渲染一致)。
- RestoreDirtyRegion(backDC, ctx->memDC, dirtyRect, ds);
- // 设置裁剪区:后续所有 GDI/GDI+ 绘制自动限制在 dirtyRect 内,
- // 避免重绘未变化区域(GDI+ Graphics(backDC) 会继承此裁剪区)。
- dirtyRgn = CreateRectRgn(dirtyRect.left, dirtyRect.top,
- dirtyRect.right, dirtyRect.bottom);
- SelectClipRgn(backDC, dirtyRgn);
- }
-
- // 绘制窗口高亮(Idle 状态)
- if (ctx->state == CS_Idle) {
- if (ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
- // 高亮悬停的窗口
- DrawWindowHighlight(backDC, ctx->windows[ctx->hoveredWindow].rect,
- ctx->virtualX, ctx->virtualY, ctx->gdi);
- } else {
- // 没有匹配到窗口时,高亮鼠标所在的屏幕
- POINT pt = { ctx->mouseX, ctx->mouseY };
- HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
- if (hMonitor) {
- MONITORINFO monitorInfo;
- monitorInfo.cbSize = sizeof(MONITORINFO);
- if (GetMonitorInfo(hMonitor, &monitorInfo)) {
- DrawWindowHighlight(backDC, monitorInfo.rcMonitor,
- ctx->virtualX, ctx->virtualY, ctx->gdi);
- }
- }
- }
- }
-
- // ---- 确认/调整/移动/绘制态:遮罩 + 边框 + 手柄 + 标注 + 工具栏 ----
- RECT curLabelRect = {0,0,0,0};
- RECT curToolbarRect = {0,0,0,0};
- RECT curPopupRect = {0,0,0,0};
- bool confirmedMode = (ctx->state == CS_Confirmed || ctx->state == CS_Resizing
- || ctx->state == CS_Moving || ctx->state == CS_Drawing
- || ctx->state == CS_TextEditing);
- if (confirmedMode) {
- // 选区外遮罩(选区内部保持清晰)
- DrawDimMask(backDC, ctx->gdi,
- curSelRect.left, curSelRect.top, curSelRect.right, curSelRect.bottom,
- ctx->virtualW, ctx->virtualH, ctx->selectionCornerRadius);
- // 已提交标注 + 正在绘制的标注(绘制范围 clip 在选区内)
- // 调整选区时也保持显示,便于看清内容是否会被裁掉。
- if (ctx->state == CS_Confirmed || ctx->state == CS_Drawing || ctx->state == CS_Resizing) {
- const Annotation* cur = ctx->hasCurDrawing ? &ctx->curDrawing : nullptr;
- // 马赛克(reveal-mask 模型):只有当前帧确实包含马赛克内容时才延迟生成 base,
- // 普通截图确认态不再承担整张虚拟屏幕的马赛克预处理。
- if (HasMosaicToRender(ctx->annotations, cur)) {
- if (MosaicBaseNeedsRebuild(ctx)) RebuildMosaicBase(ctx);
- if (ctx->mosaicBaseDC) {
- // 全屏缓存与 backDC 均以虚拟屏幕左上角为原点;把标注绝对坐标转换到
- // backDC 局部坐标,并用选区矩形硬裁剪马赛克笔刷的可见范围。
- RevealMosaicToTarget(backDC, ctx->mosaicBaseDC,
- ctx->annotations, cur, curSelRect,
- (float)-ctx->virtualX, (float)-ctx->virtualY);
- }
- }
- DrawAnnotations(backDC, curSelRect, ctx->virtualX, ctx->virtualY, ctx->annotations, cur);
- // 缓存正在绘制标注的包围盒(绝对虚拟屏幕坐标),供 CS_Drawing 局部刷新计算旧位置
- if (ctx->state == CS_Drawing && ctx->hasCurDrawing) {
- ctx->lastDrawingBox = MeasureAnnotationBounds(ctx->curDrawing, backDC);
- ctx->hasLastDrawingBox = true;
- } else if (ctx->state != CS_Drawing) {
- ctx->hasLastDrawingBox = false;
- }
- // 正在拖拽的矩形马赛克:叠加虚线边框提示当前框选范围(backDC 绝对坐标)。
- // 涂抹模式不画矩形边框(其范围由预览圆体现)。
- if (ctx->state == CS_Drawing && ctx->hasCurDrawing
- && ctx->curDrawing.type == AT_Mosaic && ctx->curDrawing.mosaicRect) {
- int rx1 = (std::min)(ctx->curDrawing.x1, ctx->curDrawing.x2);
- int ry1 = (std::min)(ctx->curDrawing.y1, ctx->curDrawing.y2);
- int rx2 = (std::max)(ctx->curDrawing.x1, ctx->curDrawing.x2);
- int ry2 = (std::max)(ctx->curDrawing.y1, ctx->curDrawing.y2);
- // GDI+ 已由会话级 InitGdipResources 启动,此处直接使用。
- {
- Gdiplus::Graphics graphics(backDC);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- // 虚线笔:白色底 + 深色虚线,保证在任意背景上可见
- Gdiplus::Pen whitePen(Gdiplus::Color(255, 255, 255, 255), 3.0f);
- graphics.DrawRectangle(&whitePen, (float)rx1, (float)ry1,
- (float)(rx2 - rx1), (float)(ry2 - ry1));
- Gdiplus::Pen dashPen(Gdiplus::Color(255, 0x1E, 0x88, 0xE5), 1.5f);
- dashPen.SetDashStyle(Gdiplus::DashStyleDash);
- graphics.DrawRectangle(&dashPen, (float)rx1, (float)ry1,
- (float)(rx2 - rx1), (float)(ry2 - ry1));
- }
- }
- }
- // 文字编辑态:绘制输入光标和选中文字标注的边框
- if (ctx->state == CS_TextEditing) {
- if (HasMosaicToRender(ctx->annotations, nullptr)) {
- if (MosaicBaseNeedsRebuild(ctx)) RebuildMosaicBase(ctx);
- if (ctx->mosaicBaseDC) {
- RevealMosaicToTarget(backDC, ctx->mosaicBaseDC,
- ctx->annotations, nullptr, curSelRect,
- (float)-ctx->virtualX, (float)-ctx->virtualY);
- }
- }
- // 绘制已提交的标注
- DrawAnnotations(backDC, curSelRect, ctx->virtualX, ctx->virtualY, ctx->annotations, nullptr);
-
- // 绘制当前输入的文字和光标(统一用 GDI+,与提交态 DrawString 完全一致,
- // 避免旧 GDI TextOutW 导致的文字偏靠下、右侧间距偏大的问题)
- // 注意:测量与绘制共用同一个 GDI+ Graphics 作用域,避免在 backDC 上反复
- // Startup/Shutdown 及创建多个 Graphics 对象引发的状态混乱/崩溃。
- int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
- COLORREF textColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
-
- // 文字锚点转换为相对坐标
- int textX = ctx->textAnchorX - ctx->virtualX;
- int textY = ctx->textAnchorY - ctx->virtualY;
-
- // 单次 GDI+ 作用域:完成测量(整体包围盒 + 逐字符宽度)与文字绘制。
- // GDI+ 已由会话级 InitGdipResources 启动;FontFamily/StringFormat/Font 复用会话缓存。
- // 关键:测量与绘制必须在同一 Graphics 作用域内(注释 3245-3247),故仍用内层 {} 包住。
- float offX = 0, offY = 0, textW = 0, textH = 0;
- std::vector charWidths;
- {
- Gdiplus::Graphics graphics(backDC);
- graphics.SetTextRenderingHint(Gdiplus::TextRenderingHintAntiAlias);
- // 复用会话缓存的 FontFamily/StringFormat/Font(指针非空,InitGdipResources 保证)
- Gdiplus::Font* font = GetGdipFont(ctx, fontPx);
- Gdiplus::StringFormat* sf = ctx->gdipStrFmt;
-
- // 整体紧凑包围盒
- Gdiplus::RectF origin(0, 0, 0, 0);
- Gdiplus::RectF bounds;
- if (!ctx->textBuf.empty()) {
- graphics.MeasureString(ctx->textBuf.c_str(), (INT)ctx->textBuf.size(),
- font, origin, sf, &bounds);
- offX = bounds.X;
- offY = bounds.Y;
- textW = bounds.Width;
- textH = bounds.Height;
- } else {
- textH = (float)fontPx;
- }
- // 逐字符累计宽度(与 DrawString 渲染进度一致)
- charWidths.assign(ctx->textBuf.size() + 1, 0.0f);
- if (!ctx->textBuf.empty()) {
- std::wstring sub;
- sub.reserve(ctx->textBuf.size());
- for (size_t i = 1; i <= ctx->textBuf.size(); i++) {
- sub.assign(ctx->textBuf, 0, i);
- graphics.MeasureString(sub.c_str(), (INT)sub.size(),
- font, origin, sf, &bounds);
- charWidths[i] = bounds.X + bounds.Width;
- }
- }
-
- // 绘制文字(与提交态一致:顶部左对齐到锚点)
- if (!ctx->textBuf.empty()) {
- Gdiplus::SolidBrush textBrush(Gdiplus::Color(GetRValue(textColor),
- GetGValue(textColor),
- GetBValue(textColor)));
- Gdiplus::RectF layoutRect((float)textX, (float)textY, 10000.0f,
- (float)(fontPx * 2));
- graphics.DrawString(ctx->textBuf.c_str(), -1, font, layoutRect,
- sf, &textBrush);
- }
- }
-
- // 字形可见区域的左上角(含 GDI+ 内部偏移)
- float glyphLeft = (float)textX + offX;
- float glyphTop = (float)textY + offY;
- // 文字可见宽度(最小给一个占位宽度,空输入框也有合理大小)
- float glyphW = (textW > 20.0f || !ctx->textBuf.empty()) ? textW : 20.0f;
- float glyphH = (textH > 0 ? textH : (float)fontPx);
-
- // 绘制边框:左右对称 padding,上下基于字形紧凑高度。
- // 用字形可见区域 + padding,保证左侧与右侧间距一致,并完整包住文字。
- const float padding = 4.0f;
- int boxLeft = (int)floorf(glyphLeft - padding);
- int boxTop = (int)floorf(glyphTop - padding);
- int boxRight = (int)ceilf(glyphLeft + glyphW + padding);
- int boxBottom = (int)ceilf(glyphTop + glyphH + padding);
- HPEN boxPen = CreatePen(PS_SOLID, 1, textColor);
- HGDIOBJ oldPen2 = SelectObject(backDC, boxPen);
- HGDIOBJ oldBrush2 = SelectObject(backDC, GetStockObject(NULL_BRUSH));
- Rectangle(backDC, boxLeft, boxTop, boxRight, boxBottom);
- SelectObject(backDC, oldBrush2);
- SelectObject(backDC, oldPen2);
- DeleteObject(boxPen);
-
- // 绘制文字选择高亮(基于 GDI+ 字符宽度)
- if (ctx->textSelStart >= 0 && ctx->textSelEnd >= 0 && ctx->textSelStart != ctx->textSelEnd
- && ctx->textSelStart < (int)charWidths.size()
- && ctx->textSelEnd < (int)charWidths.size()) {
- int selStart = (std::min)(ctx->textSelStart, ctx->textSelEnd);
- int selEnd = (std::max)(ctx->textSelStart, ctx->textSelEnd);
-
- float selLeftF = (float)textX + charWidths[selStart];
- float selRightF = (float)textX + charWidths[selEnd];
- int selLeft = (int)floorf(selLeftF);
- int selRight = (int)ceilf(selRightF);
- int selW = selRight - selLeft;
- int selY = (int)floorf(glyphTop);
- int selH = (int)ceilf(glyphH);
- if (selW > 0 && selH > 0) {
- BLENDFUNCTION blend = { AC_SRC_OVER, 0, 100, 0 };
- HDC tempDC = CreateCompatibleDC(backDC);
- HBITMAP tempBmp = CreateCompatibleBitmap(backDC, selW, selH);
- SelectObject(tempDC, tempBmp);
- RECT tempRect = {0, 0, selW, selH};
- FillRect(tempDC, &tempRect, ctx->gdi.textSelBrush);
- AlphaBlend(backDC, selLeft, selY, selW, selH, tempDC, 0, 0, selW, selH, blend);
- DeleteDC(tempDC);
- DeleteObject(tempBmp);
- }
- }
-
- // 绘制光标(闪烁效果,基于 GDI+ 字符宽度,高度对齐字形)
- // 始终缓存光标几何(即使本帧不可见),供光标闪烁/方向键的 InvalidateRect 局部刷新。
- if (ctx->textCaretPos >= 0 && ctx->textCaretPos < (int)charWidths.size()) {
- float caretXF = (float)textX + charWidths[ctx->textCaretPos];
- int caretX = (int)floorf(caretXF);
- int caretY2 = (int)floorf(glyphTop);
- int caretH = (int)ceilf(glyphH);
- // 缓存光标矩形(backDC 坐标,含 2px 宽度),供局部刷新
- ctx->lastCaretRect = { caretX - 1, caretY2, caretX + 3, caretY2 + caretH };
- ctx->hasLastCaret = true;
- if (ctx->textCaretVisible) {
- HPEN caretPen = CreatePen(PS_SOLID, 2, textColor);
- HGDIOBJ oldPenC = SelectObject(backDC, caretPen);
- MoveToEx(backDC, caretX, caretY2, NULL);
- LineTo(backDC, caretX, caretY2 + caretH);
- SelectObject(backDC, oldPenC);
- DeleteObject(caretPen);
- }
- }
- }
- // 确认态和文字编辑态:文字标注的选中边框
- // - selectedTextAnnotation:已选中的标注(点击后持久保持),实线高亮边框
- // 注:文字悬浮辅助边框已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
- if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
- // 已选中:实线蓝色边框(醒目)
- if (ctx->selectedTextAnnotation >= 0
- && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- RECT annRect = MeasureTextAnnotation(backDC, ctx->annotations[ctx->selectedTextAnnotation]);
- // 缓存选中文字标注包围盒(绝对坐标),供拖动时局部刷新算旧位置
- ctx->lastAnnotationBox = annRect;
- ctx->hasLastAnnotationBox = true;
- annRect.left -= ctx->virtualX;
- annRect.top -= ctx->virtualY;
- annRect.right -= ctx->virtualX;
- annRect.bottom -= ctx->virtualY;
- HGDIOBJ oldPenS = SelectObject(backDC, ctx->gdi.annTextSelPen);
- HGDIOBJ oldBrushS = SelectObject(backDC, GetStockObject(NULL_BRUSH));
- Rectangle(backDC, annRect.left, annRect.top, annRect.right, annRect.bottom);
- SelectObject(backDC, oldBrushS);
- SelectObject(backDC, oldPenS);
- }
- // 注:文字悬浮辅助边框已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
- }
- // 非文字标注的选中/悬浮边框(确认态/文字编辑态)
- // - selectedAnnotation:已选中,按工具类型差异化渲染(见下方分支)
- // 注:鼠标悬浮辅助包围盒已移除(确认态下悬浮高亮与选中边框语义重叠且干扰视觉)。
- if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
- // 选中态(按工具类型差异化):
- // 矩形:无包围盒 + 8 手柄(4 角 + 4 边中点)
- // 圆形:蓝色虚线包围盒 + 8 手柄(4 角 + 4 边中点)
- // 箭头:无包围盒 + 2 端点手柄
- // 画笔:蓝色虚线包围盒 + 无手柄(仅可整体拖动)
- // 手柄统一为白色圆形 + 红色描边(GDI+ 抗锯齿绘制,见下方)。
- if (ctx->selectedAnnotation >= 0
- && ctx->selectedAnnotation < (int)ctx->annotations.size()
- && ctx->annotations[ctx->selectedAnnotation].type != AT_Text) {
- RECT box = MeasureAnnotationBounds(ctx->annotations[ctx->selectedAnnotation], backDC);
- // 缓存选中标注包围盒(绝对坐标),供拖拽/缩放时局部刷新算旧位置
- ctx->lastAnnotationBox = box;
- ctx->hasLastAnnotationBox = true;
- box.left -= ctx->virtualX; box.top -= ctx->virtualY;
- box.right -= ctx->virtualX; box.bottom -= ctx->virtualY;
- const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
-
- // 包围盒:仅圆形/画笔画蓝色虚线框;矩形/箭头不画。
- if (selA.type == AT_Circle || selA.type == AT_Brush) {
- HGDIOBJ oldPenA = SelectObject(backDC, ctx->gdi.annHoverPen);
- HGDIOBJ oldBrushA = SelectObject(backDC, GetStockObject(NULL_BRUSH));
- Rectangle(backDC, box.left, box.top, box.right, box.bottom);
- SelectObject(backDC, oldBrushA);
- SelectObject(backDC, oldPenA);
- }
-
- // 收集手柄坐标(backDC 局部坐标)。画笔无手柄;箭头取 2 端点;
- // 矩形/圆形取 8 个(4 角 + 4 边中点)。
- int handles[8][2];
- int handleCount = 0;
- if (selA.type == AT_Arrow) {
- handles[0][0] = selA.x1 - ctx->virtualX;
- handles[0][1] = selA.y1 - ctx->virtualY;
- handles[1][0] = selA.x2 - ctx->virtualX;
- handles[1][1] = selA.y2 - ctx->virtualY;
- handleCount = 2;
- } else if (selA.type == AT_Rect || selA.type == AT_Circle) {
- int cx = (box.left + box.right) / 2;
- int cy = (box.top + box.bottom) / 2;
- // 4 角
- handles[0][0] = box.left; handles[0][1] = box.top;
- handles[1][0] = box.right; handles[1][1] = box.top;
- handles[2][0] = box.left; handles[2][1] = box.bottom;
- handles[3][0] = box.right; handles[3][1] = box.bottom;
- // 4 边中点
- handles[4][0] = cx; handles[4][1] = box.top;
- handles[5][0] = cx; handles[5][1] = box.bottom;
- handles[6][0] = box.left; handles[6][1] = cy;
- handles[7][0] = box.right; handles[7][1] = cy;
- handleCount = 8;
- }
- // 白色圆形手柄(红色描边),半径 = handleSize/2(DPI 缩放后),与方块视觉大小一致。
- // GDI+ 抗锯齿绘制(GDI Ellipse 边缘有硬锯齿):白色填充 + 红色 1px 描边。
- if (handleCount > 0) {
- int half = ctx->handleMetrics.handleSize / 2;
- Gdiplus::Graphics graphics(backDC);
- graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias);
- Gdiplus::SolidBrush cBrush(Gdiplus::Color(255, 255, 255, 255));
- Gdiplus::Pen cPen(Gdiplus::Color(255, 229, 57, 53), 1.0f);
- Gdiplus::GraphicsPath handlePath;
- for (int i = 0; i < handleCount; i++) {
- // 直径 2*half 与原 GDI Ellipse(x-half,y-half,x+half,y+half)
- // 的像素覆盖一致(GDI 右下边 exclusive,直径 = 2*half)。
- handlePath.AddEllipse(handles[i][0] - half, handles[i][1] - half,
- 2 * half, 2 * half);
- }
- graphics.FillPath(&cBrush, &handlePath);
- graphics.DrawPath(&cPen, &handlePath);
- }
- }
- }
- // 确认态边框和调整手柄最后绘制,避免马赛克及其他标注覆盖交互轮廓。
- DrawConfirmedBorder(backDC, curSelRect, ctx->gdi, ctx->selectionCornerRadius);
- // 正在拖拽倒角手柄(CS_Resizing + 角手柄):此时隐藏选区 resize 手柄,仅留被拖的倒角手柄。
- bool draggingCorner = (ctx->state == CS_Resizing && IsCornerRadiusHandle(ctx->resizeHandle));
- // 选区 resize 手柄:确认态/绘制标注/调整中/整体拖动中显示,并随 curSelRect 实时跟随。
- // 倒角手柄拖拽时隐藏,避免两类手柄挤在同一角;文字编辑时不绘制,避免遮挡光标。
- if (!draggingCorner && (ctx->state == CS_Confirmed || ctx->state == CS_Drawing
- || ctx->state == CS_Resizing || ctx->state == CS_Moving)) {
- DrawResizeHandles(backDC, curSelRect, ctx->handleMetrics.handleSize);
- }
- // 倒角拖拽手柄:默认不显示,仅"鼠标靠近某角"或"正拖拽某角"时显示该角一个。
- // 拖拽 -> 取正在拖的角(resizeHandle);确认态靠近 -> 取 hoveredCornerHandle;
- // 沿对角线内移 d = clamp(inset+radius, 0, maxR):radius 增大时沿对角线向中心滑动;
- // 松手后 radius 保持 -> 手柄停在原地,不回弹到静止位 inset。
- int visibleCorner = RH_None;
- if (draggingCorner) {
- visibleCorner = ctx->resizeHandle;
- } else if (ctx->state == CS_Confirmed) {
- visibleCorner = ctx->hoveredCornerHandle;
- }
- if (visibleCorner != RH_None) {
- DrawCornerRadiusHandle(backDC, curSelRect,
- ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
- ctx->selectionCornerRadius, visibleCorner);
- }
- // 调整选区(标准手柄)时显示放大镜:焦点取活动手柄锚点(随活动边移动),
- // 面板置于选区外侧避免遮挡目标。读 memDC 干净像素,所见即真实屏幕。
- if (ctx->state == CS_Resizing && !IsCornerRadiusHandle(ctx->resizeHandle)) {
- int ax, ay;
- GetResizeHandleAnchor(ctx->resizeHandle, ctx->selection, ax, ay);
- int pX, pY;
- CalcResizePanelPosition(ctx->resizeHandle, ctx->selection,
- ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH,
- ctx->panelMetrics, pX, pY);
- int pXRel = pX - ctx->virtualX, pYRel = pY - ctx->virtualY;
- curPanelRect = { pXRel, pYRel,
- pXRel + ctx->panelMetrics.w, pYRel + ctx->panelMetrics.h };
- DrawInfoPanel(backDC, pXRel, pYRel, ctx->currentColor,
- ctx->memDC, ctx->virtualX, ctx->virtualY, ax, ay, ctx->dpiScale,
- ctx->gdi, ctx->panelMetrics);
- }
- // 悬浮工具栏 + 粗细/颜色子菜单
- // 整体拖动选区(CS_Moving)时保持显示并实时跟随;调整选区(CS_Resizing)时仍隐藏,避免手柄附近抖动。
- // 绘制标注(CS_Drawing)/文字编辑(CS_TextEditing)时保持显示,便于随时查看/切换工具与样式。
- if (ctx->state == CS_Confirmed || ctx->state == CS_Moving || ctx->state == CS_Drawing || ctx->state == CS_TextEditing) {
- CalcToolbarPosition(curSelRect, ctx->virtualW, ctx->virtualH,
- ctx->toolbarMetrics, curToolbarRect);
- ctx->toolbarRect = curToolbarRect;
- // hover 命中(相对坐标),确认态和文字编辑态均可更新 hover
- if (ctx->state == CS_Confirmed || ctx->state == CS_TextEditing) {
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
- ctx->hoverToolbarBtn = HitTestToolbar(mxRel, myRel, curToolbarRect, ctx->toolbarMetrics);
- } else {
- ctx->hoverToolbarBtn = -1;
- }
- DrawToolbar(backDC, curToolbarRect, ctx->hoverToolbarBtn, ctx->activeTool,
- ctx->gdi, ctx->toolbarMetrics, ctx->iconCache);
- int popupTool = ctx->popupTool;
- // 马赛克子菜单:模式切换 + 块大小
- if (ctx->popupOpen && popupTool == TB_Mosaic) {
- int mpw, mph;
- CalcMosaicPopupSize(ctx->popupMetrics, mpw, mph);
- CalcPopupPlacement(curToolbarRect, ctx->virtualW, ctx->virtualH,
- ctx->popupMetrics, mpw, mph, curPopupRect);
- ctx->popupRect = curPopupRect;
- int modeIdx = ctx->mosaicRectMode ? 1 : 0;
- DrawMosaicPopup(backDC, curPopupRect, modeIdx, ctx->mosaicSizeIdx,
- ctx->mosaicRadiusIdx, ctx->popupMetrics);
- }
- // 粗细/颜色子菜单:文字工具激活时始终显示(含文字编辑态)
- else if (ctx->popupOpen && CanShowStylePopupTool(popupTool)) {
- CalcPopupPosition(curToolbarRect, ctx->virtualW, ctx->virtualH,
- ctx->popupMetrics, curPopupRect);
- ctx->popupRect = curPopupRect;
- bool isText = (popupTool == TB_Text);
- int firstIdx = isText ? ctx->fontSizeIdx : ctx->drawThickIdx;
- DrawPopup(backDC, curPopupRect, ctx->drawColorIdx, firstIdx, isText,
- ctx->popupMetrics);
- }
- } else {
- ctx->hoverToolbarBtn = -1;
- }
- // 确认态不绘制尺寸标签;放大镜仅在调整手柄(CS_Resizing)时绘制(见上方)
- } else {
- // ---- Idle/Selecting 态:原有逻辑 ----
- // 绘制选区外遮罩(微信风格,仅 Selecting 状态),选区内部保持清晰
- if (ctx->state == CS_Selecting) {
- DrawDimMask(backDC, ctx->gdi,
- curSelRect.left, curSelRect.top, curSelRect.right, curSelRect.bottom,
- ctx->virtualW, ctx->virtualH, 0);
- }
-
- // 绘制选区或窗口尺寸标签
- if (ctx->state == CS_Selecting) {
- curLabelRect = DrawSelection(backDC, ctx->startX, ctx->startY, ctx->endX, ctx->endY,
- ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->gdi, ctx->panelMetrics);
- } else if (ctx->state == CS_Idle) {
- RECT screenRect;
- int ww, wh;
-
- if (ctx->hoveredWindow >= 0 && ctx->hoveredWindow < (int)ctx->windows.size()) {
- // 显示悬停窗口的尺寸
- const RECT& wr = ctx->windows[ctx->hoveredWindow].rect;
- ww = wr.right - wr.left;
- wh = wr.bottom - wr.top;
- screenRect = wr;
- } else {
- // 没有匹配到窗口时,显示当前屏幕的尺寸
- POINT pt = { ctx->mouseX, ctx->mouseY };
- HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
- if (hMonitor) {
- MONITORINFO monitorInfo;
- monitorInfo.cbSize = sizeof(MONITORINFO);
- if (GetMonitorInfo(hMonitor, &monitorInfo)) {
- screenRect = monitorInfo.rcMonitor;
- ww = screenRect.right - screenRect.left;
- wh = screenRect.bottom - screenRect.top;
- }
- }
- }
-
- curLabelRect = DrawSizeLabel(backDC, ww, wh,
- screenRect.left - ctx->virtualX, screenRect.top - ctx->virtualY,
- screenRect.right - ctx->virtualX, screenRect.bottom - ctx->virtualY,
- ctx->virtualW, ctx->virtualH, ctx->gdi, ctx->panelMetrics);
- }
-
- // 绘制放大镜信息面板
- DrawInfoPanel(backDC, panelXRel, panelYRel, ctx->currentColor,
- ctx->memDC, ctx->virtualX, ctx->virtualY,
- ctx->mouseX, ctx->mouseY, ctx->dpiScale, ctx->gdi, ctx->panelMetrics);
- }
-
- // 更新脏区域追踪
- ctx->lastPanelRect = curPanelRect;
- ctx->lastSelectionRect = curSelRect;
- ctx->lastLabelRect = curLabelRect;
- ctx->lastHighlightRect = curHlRect;
- ctx->lastToolbarRect = curToolbarRect;
- ctx->lastPopupRect = curPopupRect;
-
- // 取消裁剪区(局部帧设置的),避免影响后续 GDI 操作
- if (dirtyRgn) {
- SelectClipRgn(backDC, NULL);
- DeleteObject(dirtyRgn);
- }
-
- // 后台缓冲 -> 窗口(局部帧只拷脏区域,fullFrame 拷全屏)
- BitBlt(hdc, dirtyRect.left, dirtyRect.top,
- dirtyRect.right - dirtyRect.left, dirtyRect.bottom - dirtyRect.top,
- backDC, dirtyRect.left, dirtyRect.top, SRCCOPY);
- EndPaint(hwnd, &ps);
- return 0;
- }
-
- case WM_LBUTTONDOWN: {
- if (ctx->state == CS_Idle) {
- // 开始新的框选
- ctx->startX = ctx->mouseX;
- ctx->startY = ctx->mouseY;
- ctx->endX = ctx->mouseX;
- ctx->endY = ctx->mouseY;
- ctx->selectionCornerRadius = 0; // 新框选从直角开始
- ctx->state = CS_Selecting;
- ctx->needFullRedraw = true;
- } else if (ctx->state == CS_TextEditing) {
- // 文字编辑态:允许点击工具栏/子菜单操作,或点击当前输入框内选择文字
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
-
- // 命中粗细/颜色子菜单:切换属性,提交文字,回到确认态
- if (ctx->popupOpen) {
- int hit = HitTestPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
- if (hit > 0) {
- // 字号切换
- ctx->fontSizeIdx = hit - 1;
- // 提交当前文字(如果有),应用新属性给选中的文字标注
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- if (hit < 0) {
- // 颜色切换
- ctx->drawColorIdx = -hit - 1;
- // 提交当前文字
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- }
-
- // 点击工具栏按钮:先提交当前文字,再回到确认态
- int toolbarBtn = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
- if (toolbarBtn >= 0 && toolbarBtn != TB_Separator1 && toolbarBtn != TB_Separator2) {
- // 先提交当前文字(如果有)
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
-
- // 点击当前输入框内:开始文字选择或移动光标
- // 命中区与绘制边框完全一致(用 GDI+ 紧凑度量),否则点边框附近算"框外"会提交文字。
- int textX = ctx->textAnchorX - ctx->virtualX;
- int textY = ctx->textAnchorY - ctx->virtualY;
- int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
-
- float offX = 0, offY = 0, textW = 0, textH = 0;
- MeasureTextGdip(ctx->backDC, ctx->textBuf, fontPx, offX, offY, textW, textH);
- float glyphLeft = (float)textX + offX;
- float glyphTop = (float)textY + offY;
- float glyphW = (textW > 20.0f || !ctx->textBuf.empty()) ? textW : 20.0f;
- float glyphH = (textH > 0 ? textH : (float)fontPx);
-
- const float padding = 4.0f;
- RECT inputBox = {
- (int)floorf(glyphLeft - padding),
- (int)floorf(glyphTop - padding),
- (int)ceilf(glyphLeft + glyphW + padding),
- (int)ceilf(glyphTop + glyphH + padding)
- };
-
- if (PointInRect(mxRel, myRel, inputBox)) {
- // 点击输入框内:计算光标位置并开始选择
- int caretPos = CalcCaretPosFromMouse(ctx->backDC, ctx->textBuf, fontPx, textX, mxRel);
-
- ctx->textCaretPos = caretPos;
- ctx->textSelStart = caretPos;
- ctx->textSelEnd = caretPos;
- ctx->textDraggingSelection = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
-
- // 点击选区内其他位置:提交当前文字,开始新的输入
- if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textAnchorX = ctx->mouseX;
- ctx->textAnchorY = ctx->mouseY;
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
-
- // 点击选区外:提交文字并退出文字编辑态
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- } else if (ctx->state == CS_Confirmed) {
- // 实时命中测试(不依赖 hover 缓存值)。
- // 远程桌面(RDP)下 WM_MOUSEMOVE 常被节流/合并,hoverToolbarBtn/hoveredTextAnnotation
- // 可能滞后于实际鼠标位置。若继续用缓存判断,点击文字标注时可能误命中残留的工具栏
- // 索引并提前 return,导致文字选中/拖拽分支永远执行不到。
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
- int b = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
-
- // 点击工具栏按钮(实时命中)
- if (b >= 0 && b != TB_Separator1 && b != TB_Separator2) {
- // 选中态保留规则(与 Figma/PowerPoint 一致):
- // - 点击"与已选中标注同类型"的绘制工具按钮(含文字)-> 保留选中,仅切换子菜单开合
- // (例:选中矩形后点矩形按钮 -> 关闭/重开粗细颜色子菜单,矩形仍选中)。
- // - 点击"不同类型"的绘制工具按钮 -> 切换到该绘制工具,取消当前选中
- // (子菜单参数将用于后续绘制,不再作用于旧选中元素,避免参数错配)。
- // - 点击确认/取消/保存/撤销/重做等"与选中元素无关"的按钮 -> 取消所有选中。
- bool isDrawToolBtn = IsVectorTool(b) || b == TB_Mosaic || b == TB_Text || b == TB_Drag;
- bool matchesSelection = false;
- if (isDrawToolBtn) {
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
- && AnnotationTypeToTool(ctx->annotations[ctx->selectedAnnotation].type) == b) {
- matchesSelection = true;
- }
- if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()
- && b == TB_Text) {
- matchesSelection = true;
- }
- }
- bool hadAnnSel = (ctx->selectedAnnotation >= 0);
- bool hadTextSel = (ctx->selectedTextAnnotation >= 0);
- if (!matchesSelection) {
- // 不匹配选中项:取消所有选中(切换到其它工具或执行无关操作)。
- // 切换到不同类型工具时 activeTool 必变,高亮按钮位移 + popup 可能切换,
- // 故脏区须含工具栏+popup(includeToolbar=true)。需在清状态前算脏区。
- RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->selectedTextAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- if (hadAnnSel || hadTextSel) {
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- }
- }
- // 确定:提取选区并完成截图
- if (b == TB_Confirm) {
- ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius);
- if (g_screenshotTsfn != nullptr) {
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- ctx->state = CS_Done;
- DestroyWindow(hwnd);
- return 0;
- }
- // 取消:回调失败并关闭
- if (b == TB_Cancel) {
- if (g_screenshotTsfn != nullptr) {
- ScreenshotResult* result = new ScreenshotResult();
- result->success = false;
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- ctx->state = CS_Cancelled;
- DestroyWindow(hwnd);
- return 0;
- }
- // 矢量工具:切换激活态 + 打开/关闭粗细颜色子菜单
- if (b == TB_Rect || b == TB_Circle || b == TB_Arrow || b == TB_Brush) {
- if (ctx->activeTool == b) {
- // 再次点同一工具:关闭工具与子菜单
- ctx->activeTool = -1;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- } else {
- ctx->activeTool = b;
- ctx->popupTool = b;
- ctx->popupOpen = true;
- // 匹配选中项时回显该标注的粗细/颜色到子菜单
- if (matchesSelection && ctx->selectedAnnotation >= 0
- && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
- for (int i = 0; i < SC_THICK_COUNT; i++) {
- if (SC_THICK_PRESETS[i] == selA.thickness) { ctx->drawThickIdx = i; break; }
- }
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == selA.color) { ctx->drawColorIdx = i; break; }
- }
- }
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- // 文字工具:切换激活态 + 打开/关闭子菜单(字号+颜色)
- if (b == TB_Text) {
- if (ctx->activeTool == b) {
- // 再次点同一工具:关闭工具与子菜单
- ctx->activeTool = -1;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- } else {
- ctx->activeTool = b;
- ctx->popupTool = b;
- ctx->popupOpen = true;
- // 匹配选中文字时回显该标注的字号/颜色到子菜单
- if (matchesSelection && ctx->selectedTextAnnotation >= 0
- && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- const Annotation& selT = ctx->annotations[ctx->selectedTextAnnotation];
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- if (SC_FONT_SIZES[i] == selT.thickness) { ctx->fontSizeIdx = i; break; }
- }
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == selT.color) { ctx->drawColorIdx = i; break; }
- }
- }
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- // 马赛克工具:切换激活态 + 打开/关闭子菜单(模式+块大小)
- if (b == TB_Mosaic) {
- if (ctx->activeTool == b) {
- // 再次点同一工具:关闭工具与子菜单
- ctx->activeTool = -1;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- } else {
- ctx->activeTool = b;
- ctx->popupTool = b;
- ctx->popupOpen = true;
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- if (b == TB_Drag) {
- if (ctx->activeTool == b) {
- ctx->activeTool = -1;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- } else {
- ctx->activeTool = b;
- if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- ctx->popupTool = TB_Text;
- const Annotation& selT = ctx->annotations[ctx->selectedTextAnnotation];
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- if (SC_FONT_SIZES[i] == selT.thickness) { ctx->fontSizeIdx = i; break; }
- }
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == selT.color) { ctx->drawColorIdx = i; break; }
- }
- ctx->popupOpen = true;
- } else if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- const Annotation& selA = ctx->annotations[ctx->selectedAnnotation];
- ctx->popupTool = AnnotationTypeToTool(selA.type);
- for (int i = 0; i < SC_THICK_COUNT; i++) {
- if (SC_THICK_PRESETS[i] == selA.thickness) { ctx->drawThickIdx = i; break; }
- }
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == selA.color) { ctx->drawColorIdx = i; break; }
- }
- ctx->popupOpen = CanShowStylePopupTool(ctx->popupTool);
- } else {
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- }
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- // 撤销:恢复上一份标注快照
- if (b == TB_Undo) {
- if (UndoAnnotations(ctx)) {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- // 重做:恢复下一份标注快照
- if (b == TB_Redo) {
- if (RedoAnnotations(ctx)) {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- // 保存到本地:弹出系统保存对话框,保存为 PNG 后关闭截图
- if (b == TB_Save) {
- std::wstring filePath = PromptSaveFilePath(hwnd);
- if (!filePath.empty()) {
- bool saved = SaveRegionToPngFile(ctx->memDC, ctx->selection,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale,
- ctx->annotations, filePath, ctx->selectionCornerRadius);
- // 无论保存成功与否,均关闭截图窗口(用户已选择保存路径)
- // 通过回调告知 JS 结果(成功/失败),不回传路径
- ScreenshotResult* result = new ScreenshotResult();
- result->success = saved;
- if (saved) {
- result->x = ctx->selection.left;
- result->y = ctx->selection.top;
- result->x2 = ctx->selection.right;
- result->y2 = ctx->selection.bottom;
- result->width = ctx->selection.right - ctx->selection.left;
- result->height = ctx->selection.bottom - ctx->selection.top;
- }
- if (g_screenshotTsfn != nullptr) {
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- ctx->state = CS_Done;
- DestroyWindow(hwnd);
- }
- // 用户取消保存对话框:不关闭,留在编辑态
- return 0;
- }
- return 0;
- }
-
- // 命中马赛克子菜单(模式切换 + 块大小 + 涂抹半径)
- // 马赛克标注本身不可选中,且这些选项只影响后续绘制、不作用于已选中的其它元素,
- // 故均属于"非拖拽/resize 动作"-> 取消当前选中态。
- if (ctx->popupOpen && ctx->popupTool == TB_Mosaic) {
- int hit = HitTestMosaicPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
- // 清除选中态并在清空前计算脏区(马赛克 popup 不改 activeTool,仅清覆盖物选中)。
- // 返回 dirty(backDC 坐标);调用方据 IsValidRect 决定局部或全屏 invalidate。
- auto clearSel = [&]() {
- RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
- ctx->selectedAnnotation = -1;
- ctx->selectedTextAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- return dirty;
- };
- if (hit == 1) {
- ctx->mosaicRectMode = false; // 涂抹模式
- { RECT d = clearSel();
- if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
- return 0;
- }
- if (hit == 2) {
- ctx->mosaicRectMode = true; // 框选模式
- { RECT d = clearSel();
- if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
- return 0;
- }
- if (hit >= 101 && hit < 200) {
- ctx->mosaicSizeIdx = hit - 101;
- { RECT d = clearSel();
- if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
- return 0;
- }
- if (hit >= 201) {
- ctx->mosaicRadiusIdx = hit - 201;
- { RECT d = clearSel();
- if (IsValidRect(d)) InvalidateRect(hwnd, &d, FALSE); else InvalidateRect(hwnd, NULL, FALSE); }
- return 0;
- }
- }
-
- // 命中粗细/颜色子菜单
- if (ctx->popupOpen) {
- int hit = HitTestPopup(mxRel, myRel, ctx->popupRect, ctx->popupMetrics);
- if (hit > 0) {
- // 第一组:文字工具时为字号索引,矢量工具时为粗细索引
- if (ctx->popupTool == TB_Text) {
- ctx->fontSizeIdx = hit - 1;
- // 如果选中了文字标注,修改其字号(选中文字时子菜单改动作用于该标注)
- if (ctx->selectedTextAnnotation >= 0 && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- int newSize = SC_FONT_SIZES[ctx->fontSizeIdx];
- if (ctx->annotations[ctx->selectedTextAnnotation].thickness != newSize) {
- PushAnnotationHistory(ctx);
- ctx->annotations[ctx->selectedTextAnnotation].thickness = newSize;
- ctx->annotations[ctx->selectedTextAnnotation].textCacheValid = false;
- }
- }
- } else {
- ctx->drawThickIdx = hit - 1;
- // 矢量工具改粗细:若已选中矢量标注,则作用于该标注(保持选中);
- // 否则作用于后续绘制。
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- int newThickness = SC_THICK_PRESETS[ctx->drawThickIdx];
- if (ctx->annotations[ctx->selectedAnnotation].thickness != newThickness) {
- PushAnnotationHistory(ctx);
- ctx->annotations[ctx->selectedAnnotation].thickness = newThickness;
- }
- }
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- if (hit < 0) {
- ctx->drawColorIdx = -hit - 1;
- // 如果选中了文字标注,修改其颜色(选中文字时颜色改动作用于该标注)
- if (ctx->popupTool == TB_Text && ctx->selectedTextAnnotation >= 0
- && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- COLORREF newColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
- if (ctx->annotations[ctx->selectedTextAnnotation].color != newColor) {
- PushAnnotationHistory(ctx);
- ctx->annotations[ctx->selectedTextAnnotation].color = newColor;
- }
- }
- // 矢量工具改颜色:若已选中矢量标注,则作用于该标注(保持选中);
- // 否则作用于后续绘制。
- if (ctx->popupTool != TB_Text && ctx->selectedAnnotation >= 0
- && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- COLORREF newColor = SC_COLOR_PRESETS[ctx->drawColorIdx];
- if (ctx->annotations[ctx->selectedAnnotation].color != newColor) {
- PushAnnotationHistory(ctx);
- ctx->annotations[ctx->selectedAnnotation].color = newColor;
- }
- }
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- }
-
- // ===== 通用标注交互(选中/拖拽/缩放,优先于绘制工具)=====
- // 1) 已选中标注的四角 resize 手柄命中 -> 进入缩放
- // (须在普通命中之前:手柄贴在选中框角上,可能与标注本体重叠)
- // 不复用 CS_Resizing(该状态会改 ctx->selection 选区),用 resizingAnnotation 标志
- // 在 CS_Confirmed 下独立处理,与文字拖拽(draggingTextAnnotation)机制对称。
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
- && !IsDragTool(ctx->activeTool)) {
- Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
- RECT box = MeasureAnnotationBounds(sel, ctx->backDC);
- // 按类型命中缩放手柄:箭头=2 端点;矩形/圆=8 手柄;画笔=无(仅可拖动)
- int handle = HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY, ctx->backDC, ctx->handleMetrics.handleSize);
- if (handle != RH_None) {
- ctx->resizingAnnotation = ctx->selectedAnnotation;
- ctx->annotationResizeHandle = handle;
- ctx->annotationDragStartX = ctx->mouseX;
- ctx->annotationDragStartY = ctx->mouseY;
- ctx->annotationResizeStartBox = box;
- ctx->dragStartAnnotation = ctx->annotations[ctx->selectedAnnotation];
- ctx->annotationOpHistoryPushed = false;
- ctx->needFullRedraw = true;
- return 0;
- }
- }
- // 2) 文字标注命中 -> 优先选中并可拖动
- // 文字与画笔/矩形等覆盖物重叠时,优先进入文字选中逻辑,避免被非文字命中分支吞掉。
- int hitText = HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
- if (hitText >= 0) {
- if (ctx->activeTool == TB_Text) {
- // 选中文字标注,保持确认态。
- // selectedTextAnnotation 持久保持选中态(与 hover 解耦),鼠标移开仍高亮,
- // 直到点击空白或进入其他操作才清除。
- // 精确脏区:清掉上一个选中项(可能为非文字/另一文字)的选中边框。
- // 须在赋新选中前算脏区,否则读不到旧选中项的包围盒。
- RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->selectedTextAnnotation = hitText;
- ctx->hoveredTextAnnotation = hitText;
- // 工具栏回显:文字工具保持高亮,并继续回显文字参数。
- ctx->activeTool = TB_Text;
- ctx->popupTool = TB_Text;
- int curSize = ctx->annotations[hitText].thickness;
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- if (SC_FONT_SIZES[i] == curSize) { ctx->fontSizeIdx = i; break; }
- }
- COLORREF curColor = ctx->annotations[hitText].color;
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == curColor) { ctx->drawColorIdx = i; break; }
- }
- ctx->popupOpen = true;
- ctx->draggingTextAnnotation = hitText;
- ctx->textDragStartX = ctx->mouseX;
- ctx->textDragStartY = ctx->mouseY;
- ctx->dragStartX = ctx->annotations[hitText].x1;
- ctx->dragStartY = ctx->annotations[hitText].y1;
- ctx->annotationOpHistoryPushed = false;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- } else {
- // 非文字工具或空态下:切换选中目标到文字。
- const int handleMargin = ctx->handleMetrics.handleMargin;
- RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
- RECT newBox = MeasureTextAnnotation(ctx->backDC, ctx->annotations[hitText]);
- newBox.left -= ctx->virtualX; newBox.top -= ctx->virtualY;
- newBox.right -= ctx->virtualX; newBox.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(newBox, handleMargin));
- ctx->draggingTextAnnotation = hitText;
- ctx->hoveredAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredTextAnnotation = hitText;
- ctx->selectedTextAnnotation = hitText;
- // 工具栏回显:切到文字工具按钮,打开字号/颜色子菜单,同步该标注参数。
- ctx->activeTool = TB_Text;
- ctx->popupTool = TB_Text;
- ctx->popupOpen = true;
- int curSize = ctx->annotations[hitText].thickness;
- for (int i = 0; i < SC_FONT_COUNT; i++) {
- if (SC_FONT_SIZES[i] == curSize) { ctx->fontSizeIdx = i; break; }
- }
- COLORREF curColor = ctx->annotations[hitText].color;
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == curColor) { ctx->drawColorIdx = i; break; }
- }
- ctx->textDragStartX = ctx->mouseX;
- ctx->textDragStartY = ctx->mouseY;
- ctx->dragStartX = ctx->annotations[hitText].x1;
- ctx->dragStartY = ctx->annotations[hitText].y1;
- ctx->annotationOpHistoryPushed = false;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- }
-
- // 3) 任意非文字标注命中 -> 选中并可拖拽
- // 工具激活时也优先选中已有对象(与 Figma/PowerPoint 一致),点空白才绘制。
- int hitAnn = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
- if (hitAnn >= 0 && ctx->annotations[hitAnn].type != AT_Text) {
- // 切换选中目标:脏区 = 旧选中项(清掉其边框/手柄)∪ 新目标(显示新边框/手柄)
- // ∪ 工具栏+popup(activeTool 可能变化导致高亮按钮位移)。
- // 须在改 selectedAnnotation 之前算旧选中脏区。
- const int handleMargin = ctx->handleMetrics.handleMargin;
- RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
- RECT newBox = MeasureAnnotationBounds(ctx->annotations[hitAnn], ctx->backDC);
- newBox.left -= ctx->virtualX; newBox.top -= ctx->virtualY;
- newBox.right -= ctx->virtualX; newBox.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(newBox, handleMargin));
- ctx->selectedAnnotation = hitAnn;
- ctx->hoveredAnnotation = hitAnn;
- // 与文字选中互斥:选中非文字时清文字选中
- ctx->selectedTextAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- // 工具栏回显:保持原有回显逻辑。
- // 同步当前粗细/颜色索引为该标注的值,子菜单高亮与选中元素一致(回显参数)。
- const Annotation& hitA = ctx->annotations[hitAnn];
- ctx->activeTool = AnnotationTypeToTool(hitA.type);
- ctx->popupTool = ctx->activeTool;
- ctx->popupOpen = true;
- for (int i = 0; i < SC_THICK_COUNT; i++) {
- if (SC_THICK_PRESETS[i] == hitA.thickness) { ctx->drawThickIdx = i; break; }
- }
- for (int i = 0; i < SC_COLOR_COUNT; i++) {
- if (SC_COLOR_PRESETS[i] == hitA.color) { ctx->drawColorIdx = i; break; }
- }
- // 进入拖拽模式:按下即可移动(按下未移动时,松开仅保持选中态)
- ctx->draggingAnnotation = hitAnn;
- ctx->annotationDragStartX = ctx->mouseX;
- ctx->annotationDragStartY = ctx->mouseY;
- ctx->dragStartAnnotation = ctx->annotations[hitAnn];
- ctx->annotationOpHistoryPushed = false;
- // 触发重绘:needFullRedraw 仅是 WM_PAINT 内的提示,必须有 InvalidateRect 才会触发 WM_PAINT。
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
-
- // 矢量工具激活时,点击选区内部/工具栏外 -> 开始绘制
- // 子菜单保持打开,绘制中可继续看到当前粗细/颜色。
- // 注意:上面通用标注命中分支已拦截「点中已有标注」的情况,此处仅点空白才进入。
- if (IsVectorTool(ctx->activeTool) && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- // 点空白绘制新标注:清除已有选中态
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->hasCurDrawing = true;
- ctx->curDrawing = {};
- ctx->curDrawing.type = ToolToAnnotationType(ctx->activeTool);
- ctx->curDrawing.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- ctx->curDrawing.thickness = SC_THICK_PRESETS[ctx->drawThickIdx];
- // 起点用绝对虚拟屏幕坐标
- ctx->curDrawing.x1 = ctx->mouseX;
- ctx->curDrawing.y1 = ctx->mouseY;
- ctx->curDrawing.x2 = ctx->curDrawing.x1;
- ctx->curDrawing.y2 = ctx->curDrawing.y1;
- if (ctx->curDrawing.type == AT_Brush) {
- POINT p = { ctx->curDrawing.x1, ctx->curDrawing.y1 };
- ctx->curDrawing.pts.push_back(p);
- }
- ctx->state = CS_Drawing;
- ctx->needFullRedraw = true;
- return 0;
- }
-
- // 马赛克工具激活时,点击选区内部 -> 开始马赛克绘制
- // 子菜单保持打开,绘制中可继续看到当前模式/块大小。
- if (ctx->activeTool == TB_Mosaic && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- // 点空白绘制新标注:清除已有选中态
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->hasCurDrawing = true;
- ctx->curDrawing = {};
- ctx->curDrawing.type = AT_Mosaic;
- ctx->curDrawing.color = 0; // 马赛克无颜色
- ctx->curDrawing.mosaicRect = ctx->mosaicRectMode;
- ctx->curDrawing.mosaicSize = SC_MOSAIC_SIZES[ctx->mosaicSizeIdx];
- ctx->curDrawing.brushRadius = SC_MOSAIC_RADIUS[ctx->mosaicRadiusIdx];
- ctx->curDrawing.x1 = ctx->mouseX;
- ctx->curDrawing.y1 = ctx->mouseY;
- ctx->curDrawing.x2 = ctx->curDrawing.x1;
- ctx->curDrawing.y2 = ctx->curDrawing.y1;
- if (!ctx->mosaicRectMode) {
- // 涂抹模式:记录路径起点(揭示由 WM_PAINT 统一处理)
- POINT p = { ctx->curDrawing.x1, ctx->curDrawing.y1 };
- ctx->curDrawing.pts.push_back(p);
- }
- ctx->state = CS_Drawing;
- ctx->needFullRedraw = true;
- return 0;
- }
-
- // 文字工具激活时:点击选区内空白 -> 进入输入态
- if (ctx->activeTool == TB_Text) {
- if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- ctx->textBuf.clear();
- ctx->textAnchorX = ctx->mouseX;
- ctx->textAnchorY = ctx->mouseY;
- ctx->textCaretPos = 0;
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- // 精确脏区:清掉上一个选中项的选中边框/手柄。须在清状态前算。
- RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
- ctx->state = CS_TextEditing;
- // 进入输入态时清除文字/非文字标注选中,避免残留选中边框
- ctx->hoveredTextAnnotation = -1;
- ctx->selectedTextAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- // 保持子菜单打开,清空绘制标志
- // ctx->popupOpen 保持不变
- ctx->hasCurDrawing = false;
- // 文字工具保持激活,避免工具栏视觉状态丢失
- // ctx->activeTool 保持为 TB_Text
- // 不置 needFullRedraw:进入编辑态仅改变选中边框,用局部脏区即可,避免全屏重绘。
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- }
-
- // 命中调整手柄 -> 进入 Resizing
- int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
- if (h != RH_None) {
- ctx->selectedTextAnnotation = -1; // 进入手柄调整,清除文字选中
- ctx->selectedAnnotation = -1; // 清除非文字标注选中
- ctx->hoveredAnnotation = -1;
- ctx->resizeHandle = h;
- ctx->dragStartX = ctx->mouseX;
- ctx->dragStartY = ctx->mouseY;
- ctx->dragStartSelection = ctx->selection;
- ctx->kbDX = 0;
- ctx->kbDY = 0;
- ctx->state = CS_Resizing;
- ctx->needFullRedraw = true;
- return 0;
- }
- // 命中圆角手柄 -> 进入圆角调整(复用 CS_Resizing + 对应角手柄)
- int corner = HitTestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
- ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset, ctx->selectionCornerRadius);
- if (corner != RH_None) {
- ctx->selectedTextAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->resizeHandle = corner; // 记录具体角,决定拖拽方向与光标
- ctx->dragStartX = ctx->mouseX;
- ctx->dragStartY = ctx->mouseY;
- ctx->dragStartSelection = ctx->selection;
- ctx->dragStartRadius = ctx->selectionCornerRadius;
- ctx->kbDX = 0;
- ctx->kbDY = 0;
- ctx->state = CS_Resizing;
- ctx->needFullRedraw = true;
- return 0;
- }
- // 点击选区内部 -> 整体拖动(已有标注内容时禁止,避免标注与背景错位)
- if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- if (!ctx->annotations.empty()) {
- // 已有预处理内容:只能通过手柄改范围,不允许整体拖动。
- // 点空白(选中态的拖动被禁止)-> 取消当前选中。
- if (ctx->selectedAnnotation >= 0 || ctx->selectedTextAnnotation >= 0) {
- // 精确脏区:清掉选中项的选中边框/手柄。须在清状态前算。
- RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
- ctx->selectedTextAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- }
- return 0;
- }
- ctx->selectedTextAnnotation = -1; // 进入选区拖动,清除文字选中
- ctx->selectedAnnotation = -1; // 清除非文字标注选中
- ctx->hoveredAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- ctx->dragStartX = ctx->mouseX;
- ctx->dragStartY = ctx->mouseY;
- ctx->dragStartSelection = ctx->selection;
- ctx->state = CS_Moving;
- ctx->needFullRedraw = true;
- return 0;
- }
- // 点击选区外空白:清除文字/非文字标注选中,进入确认态后不可重新框选,忽略点击
- {
- RECT dirty = CalcSelectionDirty(ctx, false /*includeToolbar*/);
- ctx->selectedTextAnnotation = -1;
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- }
- return 0;
- }
- return 0;
- }
-
- case WM_MOUSEMOVE: {
- POINT pt;
- GetCursorPos(&pt);
- bool moved = (pt.x != ctx->mouseX || pt.y != ctx->mouseY);
- ctx->mouseX = pt.x;
- ctx->mouseY = pt.y;
- // currentColor 仅用于放大镜信息面板(DrawInfoPanel)。该面板在 CS_Idle/CS_Selecting
- // 以及 CS_Resizing(标准手柄)态绘制:前两者在此取色(鼠标处),CS_Resizing 的取色
- // 由 ApplyResizeSelection 在活动手柄锚点处完成,故此处不再重复。启动时已取一次初值。
- if (ctx->state == CS_Idle || ctx->state == CS_Selecting) {
- ctx->currentColor = GetPixelColorFromBitmap(ctx->memDC,
- ctx->mouseX, ctx->mouseY, ctx->virtualX, ctx->virtualY, ctx->dpiScale);
- }
-
- if (ctx->state == CS_Selecting) {
- ctx->endX = ctx->mouseX;
- ctx->endY = ctx->mouseY;
- InvalidateRect(hwnd, NULL, FALSE);
- } else if (ctx->state == CS_Idle) {
- int newHovered = FindWindowAtPoint(ctx->windows, ctx->mouseX, ctx->mouseY);
- ctx->hoveredWindow = newHovered;
- // 像素信息浮窗跟随鼠标:刷新旧面板位置 ∪ 新面板位置(放大镜跟随,两块都需重绘)。
- // 新面板位置在此预算(与 WM_PAINT 的 CalcPanelPosition 同源)。
- int npx, npy;
- CalcPanelPosition(ctx->mouseX, ctx->mouseY,
- ctx->virtualX, ctx->virtualY, ctx->virtualW, ctx->virtualH, ctx->panelMetrics, npx, npy);
- RECT newPanel = { npx - ctx->virtualX, npy - ctx->virtualY,
- npx - ctx->virtualX + ctx->panelMetrics.w, npy - ctx->virtualY + ctx->panelMetrics.h };
- RECT dirty = InflateRectBy(UnionRectSafe(ctx->lastPanelRect, newPanel), 2);
- // 窗口高亮变化也纳入(hoveredWindow 切换时旧/新高亮框)
- if (newHovered >= 0 && newHovered < (int)ctx->windows.size()) {
- RECT hr = ctx->windows[newHovered].rect;
- hr.left -= ctx->virtualX; hr.top -= ctx->virtualY;
- hr.right -= ctx->virtualX; hr.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(hr, 5));
- }
- dirty = UnionRectSafe(dirty, InflateRectBy(ctx->lastHighlightRect, 5));
- InvalidateRect(hwnd, &dirty, FALSE);
- } else if (ctx->state == CS_Resizing) {
- if (IsCornerRadiusHandle(ctx->resizeHandle)) {
- // 圆角调整:手柄沿所在角对角线滑动,四角同步(共用同一 radius)。
- // 取鼠标位移在该角对角线方向的投影(两向内分量之和/2)作为半径增量:
- // 垂直于对角线的位移被忽略,故手柄轨迹恒为对角线。
- int dx = pt.x - ctx->dragStartX;
- int dy = pt.y - ctx->dragStartY;
- int inX, inY;
- switch (ctx->resizeHandle) {
- case RH_CornerRadiusTL: inX = dx; inY = dy; break; // 右下为内
- case RH_CornerRadiusTR: inX = -dx; inY = dy; break; // 左下为内
- case RH_CornerRadiusBL: inX = dx; inY = -dy; break; // 右上为内
- case RH_CornerRadiusBR: inX = -dx; inY = -dy; break; // 左上为内
- default: inX = dx; inY = dy; break;
- }
- int diagDelta = (inX + inY) / 2; // 对角线方向位移(轴向)
- int w = ctx->selection.right - ctx->selection.left;
- int h = ctx->selection.bottom - ctx->selection.top;
- int maxR = (std::min)(w, h) / 2;
- if (maxR < 0) maxR = 0;
- int r = ctx->dragStartRadius + diagDelta;
- if (r < 0) r = 0;
- if (r > maxR) r = maxR;
- ctx->selectionCornerRadius = r;
- InvalidateRect(hwnd, NULL, FALSE);
- } else {
- // 每帧都从按下时的完整快照重算:活动端可以越过固定端并翻转,固定端不会
- // 因上一帧 NormalizeRect 后的 left/right、top/bottom 角色交换而漂移。
- // 鼠标位移 + 键盘微调(kbDX/kbDY) 共同决定活动端;放大镜焦点取活动手柄锚点。
- ApplyResizeSelection(hwnd, ctx);
- }
- } else if (ctx->state == CS_Moving) {
- // 整体平移
- int dx = pt.x - ctx->dragStartX;
- int dy = pt.y - ctx->dragStartY;
- int sw = ctx->dragStartSelection.right - ctx->dragStartSelection.left;
- int sh = ctx->dragStartSelection.bottom - ctx->dragStartSelection.top;
- int nl = ctx->dragStartSelection.left + dx;
- int nt = ctx->dragStartSelection.top + dy;
- // 约束到虚拟屏幕
- if (nl < ctx->virtualX) nl = ctx->virtualX;
- if (nt < ctx->virtualY) nt = ctx->virtualY;
- if (nl + sw > ctx->virtualX + ctx->virtualW) nl = ctx->virtualX + ctx->virtualW - sw;
- if (nt + sh > ctx->virtualY + ctx->virtualH) nt = ctx->virtualY + ctx->virtualH - sh;
- ctx->selection.left = nl;
- ctx->selection.top = nt;
- ctx->selection.right = nl + sw;
- ctx->selection.bottom = nt + sh;
- InvalidateRect(hwnd, NULL, FALSE);
- } else if (ctx->state == CS_Drawing) {
- // 更新正在绘制的标注终点/路径(选区相对坐标)
- if (ctx->hasCurDrawing) {
- // 终点用绝对坐标,钳制到选区内
- int selL = ctx->selection.left, selR = ctx->selection.right;
- int selT = ctx->selection.top, selB = ctx->selection.bottom;
- int ax = (std::max)(selL, (std::min)(ctx->mouseX, selR));
- int ay = (std::max)(selT, (std::min)(ctx->mouseY, selB));
- if (ctx->curDrawing.type == AT_Brush) {
- POINT p = { ax, ay };
- ctx->curDrawing.pts.push_back(p);
- } else if (ctx->curDrawing.type == AT_Mosaic && !ctx->curDrawing.mosaicRect) {
- // 马赛克涂抹模式:记录路径点。揭示由 WM_PAINT 统一处理(reveal-mask 模型,
- // 每帧从预计算的 base 揭示蒙版区域,无需增量绘制)。
- POINT p = { ax, ay };
- ctx->curDrawing.pts.push_back(p);
- } else {
- ctx->curDrawing.x2 = ax;
- ctx->curDrawing.y2 = ay;
- }
- // 局部刷新:上帧 curDrawing 包围盒 ∪ 本帧鼠标点附近(标注都在选区内)。
- // 坐标转 backDC(减 virtualX/Y)。lastDrawingBox 由 WM_PAINT 每帧更新。
- RECT mouseBox = { ax - ctx->virtualX - 8, ay - ctx->virtualY - 8,
- ax - ctx->virtualX + 8, ay - ctx->virtualY + 8 };
- RECT drawDirty;
- if (ctx->hasLastDrawingBox) {
- RECT ldb = { ctx->lastDrawingBox.left - ctx->virtualX,
- ctx->lastDrawingBox.top - ctx->virtualY,
- ctx->lastDrawingBox.right - ctx->virtualX,
- ctx->lastDrawingBox.bottom - ctx->virtualY };
- drawDirty = InflateRectBy(UnionRectSafe(ldb, mouseBox), 4);
- } else {
- drawDirty = InflateRectBy(mouseBox, 4);
- }
- InvalidateRect(hwnd, &drawDirty, FALSE);
- }
- } else if (ctx->state == CS_TextEditing) {
- // 文字编辑态:拖动选择文字
- if (ctx->textDraggingSelection) {
- int mxRel = ctx->mouseX - ctx->virtualX;
- int textX = ctx->textAnchorX - ctx->virtualX;
- int fontPx = SC_FONT_SIZES[ctx->fontSizeIdx];
- int caretPos = CalcCaretPosFromMouse(ctx->backDC, ctx->textBuf, fontPx, textX, mxRel);
-
- ctx->textSelEnd = caretPos;
- ctx->textCaretPos = caretPos;
- InvalidateTextLine(hwnd, ctx);
- }
- } else if (ctx->resizingAnnotation >= 0) {
- // 非文字标注缩放:四角手柄走包围盒变换;箭头端点手柄仅平移单个端点
- int idx = ctx->resizingAnnotation;
- int dx = ctx->mouseX - ctx->annotationDragStartX;
- int dy = ctx->mouseY - ctx->annotationDragStartY;
- if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
- PushAnnotationHistory(ctx);
- ctx->annotationOpHistoryPushed = true;
- }
- // 从按下时快照还原再变换,避免累积浮点误差
- ctx->annotations[idx] = ctx->dragStartAnnotation;
- Annotation& a = ctx->annotations[idx];
- if (ctx->annotationResizeHandle == RH_ArrowStart
- || ctx->annotationResizeHandle == RH_ArrowEnd) {
- // 箭头端点拖拽:仅移动对应端点,另一端点保持快照值不变
- if (ctx->annotationResizeHandle == RH_ArrowStart) {
- a.x1 = ctx->dragStartAnnotation.x1 + dx;
- a.y1 = ctx->dragStartAnnotation.y1 + dy;
- } else {
- a.x2 = ctx->dragStartAnnotation.x2 + dx;
- a.y2 = ctx->dragStartAnnotation.y2 + dy;
- }
- } else {
- // 包围盒缩放:4 角 + 4 边中点手柄,根据拖拽手柄更新包围盒,
- // 再按包围盒变换映射标注坐标(矩形/圆均支持 8 手柄)。
- const RECT& o = ctx->annotationResizeStartBox;
- RECT n = o;
- switch (ctx->annotationResizeHandle) {
- case RH_TopLeft: n.left = o.left + dx; n.top = o.top + dy; break;
- case RH_TopRight: n.right = o.right + dx; n.top = o.top + dy; break;
- case RH_BottomLeft: n.left = o.left + dx; n.bottom = o.bottom + dy; break;
- case RH_BottomRight: n.right = o.right + dx; n.bottom = o.bottom + dy; break;
- case RH_Left: n.left = o.left + dx; break;
- case RH_Right: n.right = o.right + dx; break;
- case RH_Top: n.top = o.top + dy; break;
- case RH_Bottom: n.bottom = o.bottom + dy; break;
- }
- // 防翻转:保证 right>left、bottom>top(至少 1px)
- n = NormalizeRect(n);
- if (n.right - n.left < 2) n.right = n.left + 2;
- if (n.bottom - n.top < 2) n.bottom = n.top + 2;
- TransformAnnotationByBox(a, o, n);
- }
- InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC));
- } else if (ctx->draggingAnnotation >= 0) {
- // 非文字标注整体拖拽:对按下时快照做 dx/dy 平移后写回(避免累积误差)
- int idx = ctx->draggingAnnotation;
- int dx = ctx->mouseX - ctx->annotationDragStartX;
- int dy = ctx->mouseY - ctx->annotationDragStartY;
- if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
- PushAnnotationHistory(ctx);
- ctx->annotationOpHistoryPushed = true;
- }
- ctx->annotations[idx] = ctx->dragStartAnnotation;
- Annotation& a = ctx->annotations[idx];
- switch (a.type) {
- case AT_Rect:
- case AT_Circle:
- case AT_Arrow:
- case AT_Mosaic:
- if (a.type == AT_Mosaic && !a.mosaicRect) {
- for (POINT& p : a.pts) { p.x += dx; p.y += dy; }
- } else {
- a.x1 += dx; a.y1 += dy; a.x2 += dx; a.y2 += dy;
- }
- break;
- case AT_Brush:
- for (POINT& p : a.pts) { p.x += dx; p.y += dy; }
- break;
- case AT_Text:
- a.x1 += dx; a.y1 += dy;
- break;
- }
- InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC));
- } else if (ctx->draggingTextAnnotation >= 0) {
- // 拖动文字标注位置
- int dx = ctx->mouseX - ctx->textDragStartX;
- int dy = ctx->mouseY - ctx->textDragStartY;
- if (!ctx->annotationOpHistoryPushed && (dx != 0 || dy != 0)) {
- PushAnnotationHistory(ctx);
- ctx->annotationOpHistoryPushed = true;
- }
- ctx->annotations[ctx->draggingTextAnnotation].x1 = ctx->dragStartX + dx;
- ctx->annotations[ctx->draggingTextAnnotation].y1 = ctx->dragStartY + dy;
- InvalidateAnnotationOp(hwnd, ctx, MeasureAnnotationBounds(ctx->annotations[ctx->draggingTextAnnotation], ctx->backDC));
- } else if (ctx->state == CS_Confirmed) {
- // hover 手柄/工具栏/文字/非文字标注变化需重绘以更新光标提示与高亮
- int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
- int tb = HitTestToolbar(mxRel, myRel, ctx->toolbarRect, ctx->toolbarMetrics);
- int ht = HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
- // 非文字标注 hover:优先用选中项的手柄命中(箭头=端点;矩形/圆=8 手柄;画笔=无),否则普通命中
- int ha = -1;
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
- bool onHandle = (HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY,
- ctx->backDC, ctx->handleMetrics.handleSize) != RH_None);
- if (onHandle) {
- ha = ctx->selectedAnnotation; // 悬停在手柄上视为悬停选中项(光标由 SETCURSOR 处理)
- }
- }
- if (ha < 0) {
- ha = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
- }
- // 倒角手柄"靠近"判定:感应区比命中框大一圈(handleSize + cornerProximity),
- // 鼠标靠近某角即显示该角手柄;选区四角互不相邻,取最近的一个。
- int newNear = FindNearestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
- ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
- ctx->selectionCornerRadius, ctx->handleMetrics.cornerProximity);
- // 仅当 hover 状态真正变化时才重绘(去掉纯 moved 无变化的重绘,减少无意义全屏帧)。
- // 脏区域 = 各变化项的旧位置 ∪ 新位置(工具栏/标注边框高亮/倒角手柄变化)。
- if (h != ctx->resizeHandle || tb != ctx->hoverToolbarBtn
- || ht != ctx->hoveredTextAnnotation || ha != ctx->hoveredAnnotation
- || newNear != ctx->hoveredCornerHandle) {
- RECT dirty = {0,0,0,0};
- // 工具栏 hover 变化:整条工具栏(按钮高亮)
- if (tb != ctx->hoverToolbarBtn) {
- dirty = UnionRectSafe(dirty, InflateRectBy(ctx->toolbarRect, 2));
- }
- // 文字标注 hover 变化:旧 ∪ 新 标注盒(backDC 坐标)
- if (ht != ctx->hoveredTextAnnotation) {
- if (ctx->hoveredTextAnnotation >= 0 && ctx->hoveredTextAnnotation < (int)ctx->annotations.size()) {
- RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ctx->hoveredTextAnnotation]);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, 3));
- }
- if (ht >= 0 && ht < (int)ctx->annotations.size()) {
- RECT r = MeasureTextAnnotation(ctx->backDC, ctx->annotations[ht]);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, 3));
- }
- }
- // 非文字标注 hover 变化:旧 ∪ 新 标注盒
- // 选中态标注在 hover 进入/离开时会显示/隐藏 resize 手柄,手柄贴在包围盒边缘外,
- // inflate 量需覆盖手柄半径(handleSize/2 + 余量 = handleMetrics.handleMargin),否则手柄痕迹残留。
- if (ha != ctx->hoveredAnnotation) {
- const int handleMargin = ctx->handleMetrics.handleMargin;
- if (ctx->hoveredAnnotation >= 0 && ctx->hoveredAnnotation < (int)ctx->annotations.size()) {
- RECT r = MeasureAnnotationBounds(ctx->annotations[ctx->hoveredAnnotation], ctx->backDC);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
- }
- if (ha >= 0 && ha < (int)ctx->annotations.size()) {
- RECT r = MeasureAnnotationBounds(ctx->annotations[ha], ctx->backDC);
- r.left -= ctx->virtualX; r.top -= ctx->virtualY;
- r.right -= ctx->virtualX; r.bottom -= ctx->virtualY;
- dirty = UnionRectSafe(dirty, InflateRectBy(r, handleMargin));
- }
- }
- // 倒角手柄靠近变化:旧 ∪ 新角手柄位置重绘(出现/消失/切换角)。
- if (newNear != ctx->hoveredCornerHandle) {
- if (IsCornerRadiusHandle(ctx->hoveredCornerHandle)) {
- dirty = UnionRectSafe(dirty, CornerHandleDirtyRect(ctx, ctx->hoveredCornerHandle));
- }
- if (IsCornerRadiusHandle(newNear)) {
- dirty = UnionRectSafe(dirty, CornerHandleDirtyRect(ctx, newNear));
- }
- }
- ctx->resizeHandle = h;
- ctx->hoverToolbarBtn = tb;
- ctx->hoveredTextAnnotation = ht;
- ctx->hoveredAnnotation = ha;
- ctx->hoveredCornerHandle = newNear;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- // 兜底(理论上不会到这里)
- InvalidateRect(hwnd, NULL, FALSE);
- }
- }
- }
- return 0;
- }
-
- case WM_LBUTTONUP: {
- if (ctx->state == CS_Selecting) {
- int w = abs(ctx->endX - ctx->startX);
- int h = abs(ctx->endY - ctx->startY);
-
- RECT finalRect;
- if (w <= 1 && h <= 1) {
- // 点击 -> 使用悬停窗口矩形
- int idx = FindWindowAtPoint(ctx->windows, ctx->mouseX, ctx->mouseY);
- if (idx >= 0) {
- finalRect = ctx->windows[idx].rect;
- } else {
- // 匹配不到窗口时,默认选区为鼠标所在的屏幕
- POINT pt = { ctx->mouseX, ctx->mouseY };
- HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
- if (hMonitor) {
- MONITORINFO monitorInfo;
- monitorInfo.cbSize = sizeof(MONITORINFO);
- if (GetMonitorInfo(hMonitor, &monitorInfo)) {
- finalRect = monitorInfo.rcMonitor;
- } else {
- // 获取失败,降级为虚拟屏幕
- finalRect = { ctx->virtualX, ctx->virtualY,
- ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
- }
- } else {
- // 获取显示器失败,降级为虚拟屏幕
- finalRect = { ctx->virtualX, ctx->virtualY,
- ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH };
- }
- }
- } else {
- finalRect.left = (std::min)(ctx->startX, ctx->endX);
- finalRect.top = (std::min)(ctx->startY, ctx->endY);
- finalRect.right = (std::max)(ctx->startX, ctx->endX);
- finalRect.bottom = (std::max)(ctx->startY, ctx->endY);
- }
-
- // 进入确认态(可调整/拖动/工具栏),而非直接完成
- EnterConfirmed(ctx, finalRect);
- // 自动确认模式:跳过编辑态,直接提取选区并完成截图
- if (ctx->autoConfirm) {
- ScreenshotResult* result = ExtractRegionResult(ctx->memDC, finalRect,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius);
- if (g_screenshotTsfn != nullptr) {
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- } else {
- delete result;
- }
- ctx->state = CS_Done;
- DestroyWindow(hwnd);
- return 0;
- }
- InvalidateRect(hwnd, NULL, FALSE);
- } else if (ctx->state == CS_Resizing) {
- if (IsCornerRadiusHandle(ctx->resizeHandle)) {
- // 圆角调整结束:钳制半径即足够(选区矩形未变),无需最小尺寸逻辑。
- ClampCornerRadius(ctx);
- ctx->resizeHandle = RH_None;
- // 松手后重算靠近角:鼠标仍在该角附近则保持显示,否则回 RH_None 由 MOUSEMOVE 再探测。
- ctx->hoveredCornerHandle = FindNearestCornerRadiusHandle(ctx->mouseX, ctx->mouseY,
- ctx->selection, ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset,
- ctx->selectionCornerRadius, ctx->handleMetrics.cornerProximity);
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- } else {
- // resize 结束:从按下快照和最终鼠标位置重算,并只沿活动端当前所在侧补足最小尺寸。
- // 不能复用 EnterConfirmed 的固定向右/下扩张,否则穿越后会移动按下时的固定点。
- // 叠加键盘微调位移(kbDX/kbDY),使其在松开时一并固化到最终选区。
- int dx = (ctx->mouseX - ctx->dragStartX) + ctx->kbDX;
- int dy = (ctx->mouseY - ctx->dragStartY) + ctx->kbDY;
- RECT virtualBounds = {
- ctx->virtualX, ctx->virtualY,
- ctx->virtualX + ctx->virtualW, ctx->virtualY + ctx->virtualH
- };
- RECT contentBounds = {0, 0, 0, 0};
- bool hasContent = CalcAnnotationsBounds(ctx->annotations, contentBounds, ctx->backDC);
- ctx->selection = ResizeSelectionFromHandle(
- ctx->dragStartSelection, ctx->resizeHandle, dx, dy, virtualBounds,
- hasContent, contentBounds, true);
- ctx->kbDX = 0;
- ctx->kbDY = 0;
- ctx->resizeHandle = RH_None;
- ctx->hoveredCornerHandle = RH_None;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- ClampCornerRadius(ctx); // 选区尺寸变化后,半径可能越界,钳制
- InvalidateRect(hwnd, NULL, FALSE);
- }
- } else if (ctx->state == CS_Moving) {
- // 整体拖动结束仍可走通用确认流程;移动不会改变 resize 固定点语义。
- EnterConfirmed(ctx, ctx->selection);
- InvalidateRect(hwnd, NULL, FALSE);
- } else if (ctx->state == CS_Drawing) {
- // 绘制结束 -> 提交标注(仅在有有效尺寸时)
- bool valid = false;
- if (ctx->hasCurDrawing) {
- if (ctx->curDrawing.type == AT_Brush) {
- valid = ctx->curDrawing.pts.size() >= 2;
- } else if (ctx->curDrawing.type == AT_Mosaic) {
- if (ctx->curDrawing.mosaicRect) {
- // 框选模式:需要有效矩形尺寸
- valid = (abs(ctx->curDrawing.x2 - ctx->curDrawing.x1) >= 2
- || abs(ctx->curDrawing.y2 - ctx->curDrawing.y1) >= 2);
- } else {
- // 涂抹模式:至少 1 个点(单击也能产生一个马赛克圆)
- valid = ctx->curDrawing.pts.size() >= 1;
- }
- } else {
- valid = (abs(ctx->curDrawing.x2 - ctx->curDrawing.x1) >= 2
- || abs(ctx->curDrawing.y2 - ctx->curDrawing.y1) >= 2);
- }
- }
- if (valid) {
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(ctx->curDrawing);
- // reveal-mask 模型:base 与标注无关,下一帧 WM_PAINT 自动把新蒙版揭示出来。
- }
- ctx->hasCurDrawing = false;
- ctx->curDrawing = {};
- ctx->mosaicDrawLastIdx = 0;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- } else if (ctx->state == CS_TextEditing) {
- // 文字选择结束
- if (ctx->textDraggingSelection) {
- ctx->textDraggingSelection = false;
- // 如果选择范围相同,清除选择
- if (ctx->textSelStart == ctx->textSelEnd) {
- ctx->textSelStart = -1;
- ctx->textSelEnd = -1;
- }
- }
- } else if (ctx->resizingAnnotation >= 0) {
- // 非文字标注缩放结束:先清缩放标志,再刷新最后位置与上帧位置的并集。
- // WM_PAINT 将按最终确认态重画选中手柄,避免沿用拖拽态光标/外观。
- int idx = ctx->resizingAnnotation;
- RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
- ctx->resizingAnnotation = -1;
- ctx->annotationResizeHandle = RH_None;
- ctx->annotationOpHistoryPushed = false;
- InvalidateAnnotationOp(hwnd, ctx, finalBox);
- } else if (ctx->draggingAnnotation >= 0) {
- // 非文字标注拖拽结束:先退出拖拽态再补刷最终脏区,不退化为全屏重绘。
- int idx = ctx->draggingAnnotation;
- RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
- ctx->draggingAnnotation = -1;
- ctx->annotationOpHistoryPushed = false;
- InvalidateAnnotationOp(hwnd, ctx, finalBox);
- } else if (ctx->draggingTextAnnotation >= 0) {
- // 文字拖动结束:先退出拖动态,再按最终文字边框区域完成局部刷新。
- int idx = ctx->draggingTextAnnotation;
- RECT finalBox = MeasureAnnotationBounds(ctx->annotations[idx], ctx->backDC);
- ctx->draggingTextAnnotation = -1;
- ctx->annotationOpHistoryPushed = false;
- InvalidateAnnotationOp(hwnd, ctx, finalBox);
- }
- return 0;
- }
-
- case WM_LBUTTONDBLCLK: {
- // 确认态下双击选区内部 -> 确认截图
- if ((ctx->state == CS_Confirmed) && PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius);
- if (g_screenshotTsfn != nullptr) {
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- ctx->state = CS_Done;
- DestroyWindow(hwnd);
- }
- return 0;
- }
-
- case WM_RBUTTONDOWN: {
- ctx->state = CS_Cancelled;
- // 回调失败结果
- if (g_screenshotTsfn != nullptr) {
- ScreenshotResult* result = new ScreenshotResult();
- result->success = false;
- result->x = 0; result->y = 0; result->x2 = 0; result->y2 = 0;
- result->width = 0; result->height = 0;
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- DestroyWindow(hwnd);
- return 0;
- }
-
- case WM_KEYDOWN: {
- // 方向键微调选区:调整手柄拖拽中(CS_Resizing)微调活动边,或确认态整体平移。
- // 文字编辑态(CS_TextEditing)不拦截,走下方原有光标逻辑。
- if ((ctx->state == CS_Resizing || ctx->state == CS_Confirmed)
- && (wParam == VK_LEFT || wParam == VK_RIGHT
- || wParam == VK_UP || wParam == VK_DOWN)
- && HandleSelectionNudgeKey(hwnd, ctx, wParam)) {
- return 0;
- }
- if (wParam == VK_ESCAPE) {
- // 文字编辑态:ESC 取消输入,回到确认态
- if (ctx->state == CS_TextEditing) {
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- // 其它状态:ESC 取消截图
- ctx->state = CS_Cancelled;
- if (g_screenshotTsfn != nullptr) {
- ScreenshotResult* result = new ScreenshotResult();
- result->success = false;
- result->x = 0; result->y = 0; result->x2 = 0; result->y2 = 0;
- result->width = 0; result->height = 0;
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- DestroyWindow(hwnd);
- } else if (wParam == VK_RETURN) {
- // 文字编辑态:Enter 提交文字
- if (ctx->state == CS_TextEditing) {
- if (!ctx->textBuf.empty()) {
- Annotation textAnnotation = {};
- textAnnotation.type = AT_Text;
- textAnnotation.color = SC_COLOR_PRESETS[ctx->drawColorIdx];
- textAnnotation.thickness = SC_FONT_SIZES[ctx->fontSizeIdx];
- textAnnotation.x1 = ctx->textAnchorX;
- textAnnotation.y1 = ctx->textAnchorY;
- textAnnotation.text = ctx->textBuf;
- PushAnnotationHistory(ctx);
- ctx->annotations.push_back(textAnnotation);
- }
- ctx->textBuf.clear();
- ctx->textCaretPos = 0;
- ctx->state = CS_Confirmed;
- ctx->needFullRedraw = true;
- InvalidateRect(hwnd, NULL, FALSE);
- return 0;
- }
- // 确认态:Enter 确认截图
- if (ctx->state == CS_Confirmed) {
- ScreenshotResult* result = ExtractRegionResult(ctx->memDC, ctx->selection,
- ctx->virtualX, ctx->virtualY, ctx->dpiScale, ctx->annotations, ctx->selectionCornerRadius);
- if (g_screenshotTsfn != nullptr) {
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- ctx->state = CS_Done;
- DestroyWindow(hwnd);
- }
- } else if (wParam == VK_BACK) {
- // 文字编辑态:退格删除(文字内容变化,整行重排,刷新文字行区域)
- if (ctx->state == CS_TextEditing && ctx->textCaretPos > 0) {
- ctx->textBuf.erase(ctx->textCaretPos - 1, 1);
- ctx->textCaretPos--;
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- } else if (wParam == VK_DELETE) {
- // 文字编辑态:Delete 删除
- if (ctx->state == CS_TextEditing && ctx->textCaretPos < (int)ctx->textBuf.size()) {
- ctx->textBuf.erase(ctx->textCaretPos, 1);
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- // 确认态:Delete 删除当前选中的覆盖物
- if (ctx->state == CS_Confirmed) {
- if (ctx->selectedTextAnnotation >= 0
- && ctx->selectedTextAnnotation < (int)ctx->annotations.size()) {
- RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
- PushAnnotationHistory(ctx);
- ctx->annotations.erase(ctx->annotations.begin() + ctx->selectedTextAnnotation);
- ctx->selectedTextAnnotation = -1;
- ctx->hoveredTextAnnotation = -1;
- ctx->draggingTextAnnotation = -1;
- ctx->activeTool = TB_Drag;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- if (ctx->selectedAnnotation >= 0
- && ctx->selectedAnnotation < (int)ctx->annotations.size()) {
- RECT dirty = CalcSelectionDirty(ctx, true /*includeToolbar*/);
- PushAnnotationHistory(ctx);
- ctx->annotations.erase(ctx->annotations.begin() + ctx->selectedAnnotation);
- ctx->selectedAnnotation = -1;
- ctx->hoveredAnnotation = -1;
- ctx->draggingAnnotation = -1;
- ctx->resizingAnnotation = -1;
- ctx->annotationResizeHandle = RH_None;
- ctx->activeTool = TB_Drag;
- ctx->popupTool = -1;
- ctx->popupOpen = false;
- if (IsValidRect(dirty)) {
- InvalidateRect(hwnd, &dirty, FALSE);
- } else {
- InvalidateRect(hwnd, NULL, FALSE);
- }
- return 0;
- }
- }
- } else if (wParam == VK_LEFT) {
- // 文字编辑态:左箭头移动光标(仅光标位置变化)
- if (ctx->state == CS_TextEditing && ctx->textCaretPos > 0) {
- ctx->textCaretPos--;
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- } else if (wParam == VK_RIGHT) {
- // 文字编辑态:右箭头移动光标
- if (ctx->state == CS_TextEditing && ctx->textCaretPos < (int)ctx->textBuf.size()) {
- ctx->textCaretPos++;
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- } else if (wParam == VK_HOME) {
- // 文字编辑态:Home 移动到行首
- if (ctx->state == CS_TextEditing) {
- ctx->textCaretPos = 0;
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- } else if (wParam == VK_END) {
- // 文字编辑态:End 移动到行尾
- if (ctx->state == CS_TextEditing) {
- ctx->textCaretPos = (int)ctx->textBuf.size();
- InvalidateTextLine(hwnd, ctx);
- return 0;
- }
- }
- return 0;
- }
-
- case WM_IME_COMPOSITION: {
- // 处理中文输入法(IME)输入
- if (ctx->state == CS_TextEditing) {
- if (lParam & GCS_RESULTSTR) {
- HIMC hIMC = ImmGetContext(hwnd);
- if (hIMC) {
- // 获取输入法完成的字符串长度
- LONG len = ImmGetCompositionStringW(hIMC, GCS_RESULTSTR, NULL, 0);
- if (len > 0) {
- // 分配缓冲区并获取字符串
- std::wstring result(len / sizeof(wchar_t), L'\0');
- ImmGetCompositionStringW(hIMC, GCS_RESULTSTR, &result[0], len);
- result.resize(len / sizeof(wchar_t));
-
- // 插入到当前光标位置
- ctx->textBuf.insert(ctx->textCaretPos, result);
- ctx->textCaretPos += (int)result.size();
- InvalidateTextLine(hwnd, ctx);
- }
- ImmReleaseContext(hwnd, hIMC);
- }
- return 0;
- }
- }
- return DefWindowProc(hwnd, msg, wParam, lParam);
- }
-
- case WM_CHAR: {
- // 文字编辑态:接收字符输入(ASCII 和直接输入)
- if (ctx->state == CS_TextEditing) {
- wchar_t ch = (wchar_t)wParam;
- // 过滤控制字符(除了可打印字符)
- // 忽略 IME 相关的控制字符
- if (ch >= 32 && ch != 127) {
- ctx->textBuf.insert(ctx->textCaretPos, 1, ch);
- ctx->textCaretPos++;
- InvalidateTextLine(hwnd, ctx);
- }
- return 0;
- }
- return 0;
- }
-
- case WM_SETCURSOR: {
- // 马赛克涂抹模式(确认态/绘制态):用预生成的半径圆光标(OS 跟随,无重绘延迟)
- if (ctx->activeTool == TB_Mosaic && !ctx->mosaicRectMode
- && (ctx->state == CS_Confirmed || ctx->state == CS_Drawing)
- && ctx->mosaicBrushCursorsInited
- && ctx->mosaicRadiusIdx >= 0 && ctx->mosaicRadiusIdx < SC_MOSAIC_RADIUS_COUNT
- && ctx->mosaicBrushCursors[ctx->mosaicRadiusIdx]) {
- // 工具栏/子菜单上仍用箭头/手型
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
- if (PointInRect(mxRel, myRel, ctx->toolbarRect)) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
- return TRUE;
- }
- if (ctx->popupOpen && PointInRect(mxRel, myRel, ctx->popupRect)) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_HAND));
- return TRUE;
- }
- if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- SetCursor(ctx->mosaicBrushCursors[ctx->mosaicRadiusIdx]);
- return TRUE;
- }
- }
- // 文字编辑中:I-beam 光标
- if (ctx->state == CS_TextEditing) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_IBEAM));
- return TRUE;
- }
- // 绘制中:十字光标
- if (ctx->state == CS_Drawing) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_CROSS));
- return TRUE;
- }
- // 非文字标注缩放中:对应四角 resize 光标
- if (ctx->resizingAnnotation >= 0) {
- SetCursor(LoadCursorW(NULL, HandleCursor(ctx->annotationResizeHandle)));
- return TRUE;
- }
- // 非文字标注拖拽中:四向箭头光标
- if (ctx->draggingAnnotation >= 0) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- return TRUE;
- }
- // 拖动文字标注中:四向箭头光标
- if (ctx->draggingTextAnnotation >= 0) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- return TRUE;
- }
- // 确认态:根据 hover 位置切换 resize/move/箭头/十字光标
- if (ctx->state == CS_Confirmed) {
- // 工具栏或子菜单 -> 箭头
- int mxRel = ctx->mouseX - ctx->virtualX;
- int myRel = ctx->mouseY - ctx->virtualY;
- if (PointInRect(mxRel, myRel, ctx->toolbarRect)) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
- return TRUE;
- }
- if (ctx->popupOpen && PointInRect(mxRel, myRel, ctx->popupRect)) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_HAND));
- return TRUE;
- }
- // 手柄 -> 对应 resize 光标
- int h = HitTestHandle(ctx->mouseX, ctx->mouseY, ctx->selection, ctx->handleMetrics.handleSize);
- if (h != RH_None) {
- SetCursor(LoadCursorW(NULL, HandleCursor(h)));
- return TRUE;
- }
- // 圆角手柄 -> 对应对角 resize 光标(按所在角)
- int cornerH = HitTestCornerRadiusHandle(ctx->mouseX, ctx->mouseY, ctx->selection,
- ctx->handleMetrics.handleSize, ctx->handleMetrics.cornerKnobInset, ctx->selectionCornerRadius);
- if (cornerH != RH_None) {
- SetCursor(LoadCursorW(NULL, HandleCursor(cornerH)));
- return TRUE;
- }
- // 已选中的非文字标注手柄 -> 对应 resize 光标(缩放入入口)
- // 箭头=起点/终点端点手柄(固定四向箭头);矩形/圆=8 手柄(方向自适应);画笔=无
- // 独立命中测试,不依赖 hovered 缓存(RDP 节流下缓存会滞后)
- if (ctx->selectedAnnotation >= 0 && ctx->selectedAnnotation < (int)ctx->annotations.size()
- && ctx->annotations[ctx->selectedAnnotation].type != AT_Text) {
- Annotation& sel = ctx->annotations[ctx->selectedAnnotation];
- int handle = HitTestAnnotationResizeHandle(sel, ctx->mouseX, ctx->mouseY, ctx->backDC, ctx->handleMetrics.handleSize);
- if (handle != RH_None) {
- SetCursor(LoadCursorW(NULL, HandleCursor(handle)));
- return TRUE;
- }
- }
- // 非文字标注悬停 -> 四向箭头(可拖动/选中),与下方文字悬停判定并列
- {
- int hit = HitTestAnnotation(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC);
- if (hit >= 0 && ctx->annotations[hit].type != AT_Text) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- return TRUE;
- }
- }
- // 文字标注悬停 -> 四向箭头(可拖动/选中)
- // 注意:此处必须独立做命中测试,不能依赖 ctx->hoveredTextAnnotation。
- // 在远程桌面(RDP)下鼠标移动事件常被节流/合并,WM_MOUSEMOVE 更新
- // hoveredTextAnnotation 存在滞后,导致 WM_SETCURSOR 看到过期值。
- // 文字工具未激活时,悬停已确认文字 -> 拖动光标;
- // 文字工具激活时,悬停已确认文字 -> 仍为拖动光标(可选中改属性)。
- if (HitTestTextAnnotations(ctx->annotations, ctx->mouseX, ctx->mouseY, ctx->backDC) >= 0) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- return TRUE;
- }
- // 选区内部:
- // 矢量/文字/马赛克工具激活 -> 十字;
- // 已有标注内容 -> 箭头(禁止整体拖动);
- // 否则 -> 移动光标
- if (PointInRect(ctx->mouseX, ctx->mouseY, ctx->selection)) {
- if (IsVectorTool(ctx->activeTool) || ctx->activeTool == TB_Text
- || ctx->activeTool == TB_Mosaic) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_CROSS));
- } else if (!ctx->annotations.empty()) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
- } else {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- }
- return TRUE;
- }
- // 选区外 -> 箭头
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_ARROW));
- return TRUE;
- }
- if (ctx->state == CS_Resizing) {
- SetCursor(LoadCursorW(NULL, HandleCursor(ctx->resizeHandle)));
- return TRUE;
- }
- if (ctx->state == CS_Moving) {
- SetCursor(LoadCursorW(NULL, (LPCWSTR)IDC_SIZEALL));
- return TRUE;
- }
- return DefWindowProc(hwnd, msg, wParam, lParam);
- }
-
- case WM_DESTROY: {
- g_screenshotOverlayWindow = NULL;
- PostQuitMessage(0);
- return 0;
- }
- }
-
- return DefWindowProc(hwnd, msg, wParam, lParam);
-}
-
-// 截图线程(预截屏 + 双缓冲架构)
-static void ScreenshotCaptureThread() {
- // 设置 DPI 感知
- typedef DPI_AWARENESS_CONTEXT (WINAPI *SetThreadDpiAwarenessContextProc)(DPI_AWARENESS_CONTEXT);
- HMODULE user32 = GetModuleHandleW(L"user32.dll");
- if (user32) {
- auto setDpiProc = (SetThreadDpiAwarenessContextProc)GetProcAddress(user32, "SetThreadDpiAwarenessContext");
- if (setDpiProc) {
- setDpiProc(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
- }
- }
-
- double uiScale = GetDpiScaleFactor();
- double dpiScale = uiScale;
-
- // 预截屏整个虚拟屏幕
- HDC memDC = NULL;
- HBITMAP screenBitmap = NULL;
- int vx, vy, vw, vh;
- if (!AcquireScreenshotBase(memDC, screenBitmap, vx, vy, vw, vh, dpiScale)) {
- g_isCapturing = false;
- return;
- }
-
- // 创建双缓冲
- HDC backDC = NULL;
- HBITMAP backBmp = NULL;
- if (!CreateBackBuffer(backDC, backBmp, vw, vh)) {
- DeleteDC(memDC);
- DeleteObject(screenBitmap);
- g_isCapturing = false;
- return;
- }
-
- // 枚举窗口
- std::vector windows = EnumWindowsForCapture();
-
- // 初始化 GDI 资源
- SCPanelMetrics panelMetrics = CalcPanelMetrics(uiScale);
- SCGdiResources gdi;
- gdi.Init(panelMetrics.fontPx, panelMetrics.crosshair);
- // 创建选区外遮罩缓冲(需虚拟屏幕尺寸)
- gdi.InitMask(vw, vh);
-
- // 初始化上下文
- CaptureContext ctx = {};
- ctx.state = CS_Idle;
- ctx.autoConfirm = g_autoConfirm.load();
- ctx.virtualX = vx; ctx.virtualY = vy;
- ctx.virtualW = vw; ctx.virtualH = vh;
- ctx.startX = 0; ctx.startY = 0;
- ctx.endX = 0; ctx.endY = 0;
- ctx.hoveredWindow = -1;
- ctx.screenBitmap = screenBitmap;
- ctx.memDC = memDC;
- ctx.backDC = backDC;
- ctx.backBitmap = backBmp;
- ctx.lastPanelRect = {0,0,0,0};
- ctx.lastSelectionRect = {0,0,0,0};
- ctx.lastLabelRect = {0,0,0,0};
- ctx.lastHighlightRect = {0,0,0,0};
- ctx.lastToolbarRect = {0,0,0,0};
- ctx.lastPopupRect = {0,0,0,0};
- ctx.lastCaretRect = {0,0,0,0};
- ctx.hasLastCaret = false;
- ctx.lastAnnotationBox = {0,0,0,0};
- ctx.hasLastAnnotationBox = false;
- ctx.lastDrawingBox = {0,0,0,0};
- ctx.hasLastDrawingBox = false;
- ctx.selection = {0,0,0,0};
- ctx.resizeHandle = RH_None;
- ctx.dragStartX = 0; ctx.dragStartY = 0;
- ctx.dragStartSelection = {0,0,0,0};
- ctx.toolbarRect = {0,0,0,0};
- ctx.hoverToolbarBtn = -1;
- ctx.activeTool = -1;
- ctx.popupTool = -1;
- ctx.needFullRedraw = true;
- ctx.dpiScale = dpiScale;
- ctx.gdi = gdi;
- ctx.panelMetrics = panelMetrics;
- ctx.windows = std::move(windows);
-
- // 工具栏几何(按 DPI 缩放)+ 图标位图缓存(按 DPI 预渲染)
- ctx.toolbarMetrics = CalcToolbarMetrics(uiScale);
- ctx.handleMetrics = CalcHandleMetrics(uiScale);
- ctx.iconCache.Init(ctx.toolbarMetrics.iconSize);
- // GDI+ 会话级初始化(必须在 InitMosaicBrushCursors 及任何 GDI+ 调用之前):
- // 会话内单次 Startup,避免每帧反复初始化导致拖拽卡顿。
- if (!InitGdipResources(&ctx)) {
- gdi.Cleanup();
- ctx.iconCache.Cleanup();
- DeleteDC(backDC); DeleteObject(backBmp);
- DeleteDC(memDC); DeleteObject(screenBitmap);
- g_captureCtx = nullptr;
- g_isCapturing = false;
- return;
- }
- // 涂抹光标缓存(按半径预生成,DPI 缩放半径)
- for (int i = 0; i < SC_MOSAIC_RADIUS_COUNT; i++) ctx.mosaicBrushCursors[i] = NULL;
- ctx.mosaicBrushCursorsInited = false;
- InitMosaicBrushCursors(&ctx);
- // 子菜单几何(按 DPI 缩放)+ 标注绘制默认值
- ctx.popupMetrics = CalcPopupMetrics(uiScale);
- ctx.popupOpen = false;
- ctx.popupRect = {0,0,0,0};
- ctx.drawColorIdx = SC_DEFAULT_COLOR_IDX;
- ctx.drawThickIdx = SC_DEFAULT_THICK_IDX;
- ctx.fontSizeIdx = SC_DEFAULT_FONT_IDX;
- ctx.mosaicSizeIdx = SC_DEFAULT_MOSAIC_IDX;
- ctx.mosaicRadiusIdx = SC_DEFAULT_MOSAIC_RADIUS_IDX;
- ctx.mosaicRectMode = false; // 默认涂抹模式
- ctx.mosaicBaseDC = NULL;
- ctx.mosaicBaseBitmap = NULL;
- ctx.mosaicBaseW = 0;
- ctx.mosaicBaseH = 0;
- ctx.mosaicBaseBlockPx = 0;
- ctx.mosaicDrawLastIdx = 0;
- ctx.hasCurDrawing = false;
- // 文字编辑初始化
- ctx.textBuf.clear();
- ctx.textAnchorX = 0;
- ctx.textAnchorY = 0;
- ctx.textCaretPos = 0;
- ctx.textCaretVisible = true;
- ctx.textCaretLastBlink = GetTickCount();
- ctx.textSelStart = -1;
- ctx.textSelEnd = -1;
- ctx.textDraggingSelection = false;
- ctx.hoveredTextAnnotation = -1;
- ctx.selectedTextAnnotation = -1;
- ctx.draggingTextAnnotation = -1;
- ctx.textDragStartX = 0;
- ctx.textDragStartY = 0;
- // 非文字标注选中/拖拽/缩放状态初始化(与文字机制互斥)
- ctx.hoveredAnnotation = -1;
- ctx.selectedAnnotation = -1;
- ctx.draggingAnnotation = -1;
- ctx.resizingAnnotation = -1;
- ctx.annotationResizeHandle = RH_None;
- ctx.annotationDragStartX = 0;
- ctx.annotationDragStartY = 0;
- ctx.annotationOpHistoryPushed = false;
- ctx.dragStartAnnotation = {};
- ctx.annotationResizeStartBox = { 0, 0, 0, 0 };
-
- // 获取初始鼠标位置和颜色
- POINT pt;
- GetCursorPos(&pt);
- ctx.mouseX = pt.x;
- ctx.mouseY = pt.y;
- ctx.currentColor = GetPixelColorFromBitmap(memDC, pt.x, pt.y, vx, vy, dpiScale);
-
- g_captureCtx = &ctx;
-
- // 注册窗口类
- WNDCLASSEXW wc = {0};
- wc.cbSize = sizeof(WNDCLASSEXW);
- wc.lpfnWndProc = ScreenshotOverlayWndProc;
- wc.hInstance = GetModuleHandle(NULL);
- wc.hCursor = LoadCursor(NULL, IDC_ARROW); // 默认鼠标样式
- wc.lpszClassName = L"ZToolsScreenshotOverlay";
-
- if (!RegisterClassExW(&wc)) {
- gdi.Cleanup();
- ctx.iconCache.Cleanup();
- DeleteDC(backDC); DeleteObject(backBmp);
- DeleteDC(memDC); DeleteObject(screenBitmap);
- g_captureCtx = nullptr;
- g_isCapturing = false;
- return;
- }
-
- // 创建普通 WS_POPUP 窗口(非分层窗口)
- g_screenshotOverlayWindow = CreateWindowExW(
- WS_EX_TOPMOST | WS_EX_TOOLWINDOW,
- L"ZToolsScreenshotOverlay",
- L"Screenshot Overlay",
- WS_POPUP,
- vx, vy, vw, vh,
- NULL, NULL, GetModuleHandle(NULL), NULL
- );
-
- if (g_screenshotOverlayWindow == NULL) {
- UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL));
- gdi.Cleanup();
- ctx.iconCache.Cleanup();
- DeleteDC(backDC); DeleteObject(backBmp);
- DeleteDC(memDC); DeleteObject(screenBitmap);
- g_captureCtx = nullptr;
- g_isCapturing = false;
- return;
- }
-
- ShowWindow(g_screenshotOverlayWindow, SW_SHOW);
- SetForegroundWindow(g_screenshotOverlayWindow);
-
- // 消息循环
- MSG msg;
- while (true) {
- if (ctx.state == CS_Done || ctx.state == CS_Cancelled) break;
-
- if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) {
- if (msg.message == WM_QUIT) break;
- TranslateMessage(&msg);
- DispatchMessage(&msg);
- } else {
- // 文字编辑态:光标闪烁(每 500ms 切换)
- if (ctx.state == CS_TextEditing) {
- DWORD now = GetTickCount();
- if (now - ctx.textCaretLastBlink >= 500) {
- ctx.textCaretVisible = !ctx.textCaretVisible;
- ctx.textCaretLastBlink = now;
- // 仅刷新光标区域(光标位置不变,只切换可见性)。首次无缓存时全屏。
- if (ctx.hasLastCaret) {
- RECT r = InflateRectBy(ctx.lastCaretRect, 2);
- InvalidateRect(g_screenshotOverlayWindow, &r, FALSE);
- } else {
- InvalidateRect(g_screenshotOverlayWindow, NULL, FALSE);
- }
- }
- }
-
- // 检查 ESC 键(窗口可能没有焦点)
- if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) {
- if (ctx.state != CS_Done && ctx.state != CS_Cancelled) {
- ctx.state = CS_Cancelled;
- if (g_screenshotTsfn != nullptr) {
- ScreenshotResult* result = new ScreenshotResult();
- result->success = false;
- result->x = 0; result->y = 0; result->x2 = 0; result->y2 = 0;
- result->width = 0; result->height = 0;
- napi_call_threadsafe_function(g_screenshotTsfn, result, napi_tsfn_nonblocking);
- }
- DestroyWindow(g_screenshotOverlayWindow);
- break;
- }
- }
- Sleep(1);
- }
- }
-
- // 清理
- g_captureCtx = nullptr;
- gdi.Cleanup();
- ctx.iconCache.Cleanup();
- FreeMosaicBase(&ctx);
- FreeMosaicBrushCursors(&ctx);
- DeleteDC(backDC); DeleteObject(backBmp);
- DeleteDC(memDC); DeleteObject(screenBitmap);
- // GDI+ 会话级资源最后释放(所有 GDI+ 调用均已结束后才可 Shutdown)
- ShutdownGdipResources(&ctx);
- UnregisterClassW(L"ZToolsScreenshotOverlay", GetModuleHandle(NULL));
- g_isCapturing = false;
-}
-
-// 启动区域截图
-Napi::Value StartRegionCapture(const Napi::CallbackInfo& info) {
- return StartRegionCaptureWithPrimedFrame(info);
-}
-
-// 供 JS 主动触发首帧预抓取。
-Napi::Value PrimeScreenshotFrame(const Napi::CallbackInfo& info) {
- Napi::Env env = info.Env();
- const bool success = PrimeScreenshotFrameNow();
- return Napi::Boolean::New(env, success);
-}
-
-Napi::Value StartRegionCaptureWithPrimedFrame(const Napi::CallbackInfo& info) {
- Napi::Env env = info.Env();
-
- if (g_isCapturing) {
- Napi::Error::New(env, "Screenshot already in progress").ThrowAsJavaScriptException();
- return env.Undefined();
- }
-
- // 解析可选参数(顺序无关,便于后续扩展):
- // - 回调函数:截图完成后回调
- // - 选项对象:目前支持 { autoConfirm: boolean }
- // 默认 autoConfirm=true,选区确定后直接出图,跳过编辑态;
- // 传 false 才进入编辑态(工具栏/标注)
- bool autoConfirm = true;
- for (int i = 0; i < (int)info.Length(); i++) {
- if (info[i].IsFunction()) {
- Napi::Function callback = info[i].As();
- napi_value resource_name;
- napi_create_string_utf8(env, "ScreenshotCallback", NAPI_AUTO_LENGTH, &resource_name);
-
- napi_status status = napi_create_threadsafe_function(
- env, callback, nullptr, resource_name,
- 0, 1, nullptr, nullptr, nullptr,
- CallScreenshotJs, &g_screenshotTsfn
- );
-
- if (status != napi_ok) {
- Napi::Error::New(env, "Failed to create threadsafe function").ThrowAsJavaScriptException();
- return env.Undefined();
- }
- } else if (info[i].IsObject()) {
- Napi::Object opts = info[i].As();
- if (opts.Has("autoConfirm")) {
- Napi::Value v = opts.Get("autoConfirm");
- if (v.IsBoolean()) {
- autoConfirm = v.As().Value();
- }
- }
- }
- }
- g_autoConfirm = autoConfirm;
-
- g_isCapturing = true;
-
- g_screenshotThread = std::thread(ScreenshotCaptureThread);
- g_screenshotThread.detach();
-
- return env.Undefined();
-}