From 1efdf4dbad384448d763ef1dc3f8f427b3382334 Mon Sep 17 00:00:00 2001 From: easyz Date: Wed, 25 Mar 2026 22:51:19 +0800 Subject: [PATCH] Refactor QML capture flow and polish sticky tools --- .gitignore | 1 + CMakeLists.txt | 17 +- qml.qrc | 40 +- qml/Capture/TZCaptureInputController.qml | 56 ++ qml/Capture/TZGifRecordBar.qml | 194 ++++++ qml/Capture/TZHotkeyGestureController.qml | 96 +++ qml/Capture/TZLable.qml | 26 + qml/Capture/TZMagnifyingGlass.qml | 116 ++++ qml/Capture/TZMarkToolInfo.qml | 257 ++++++++ qml/Capture/TZRectMaskOverlay.qml | 48 ++ qml/Capture/TZResizeHandle.qml | 14 + qml/Capture/TZResizeHandleGroup.qml | 301 +++++++++ qml/Capture/TZSelectionInfoLabel.qml | 19 + qml/Capture/TZSelectionOverlay.qml | 147 +++++ qml/Capture/TZSelectionPositioner.qml | 34 + qml/Capture/TZTextAnnotationEditor.qml | 101 +++ qml/Capture/TZTool.qml | 265 ++++++++ qml/Capture/TZWindow.qml | 764 ++++++++++++++++++++++ qml/Capture/TZWindowFactory.qml | 116 ++++ qml/Capture/TZWindowHighlightOverlay.qml | 46 ++ qml/LongCapture/TZLongCaptureBar.qml | 110 ++++ qml/LongCapture/TZLongCaptureFrame.qml | 53 ++ qml/LongCapture/TZLongCapturePreview.qml | 83 +++ qml/TZMagnifyingGlass.qml | 4 +- qml/TZStickyWindow.qml | 15 +- qml/TZWindow.qml | 2 + qml/Windows/TZChat.qml | 265 ++++++++ qml/Windows/TZLoadingSpinner.qml | 52 ++ qml/Windows/TZOcrResult.qml | 328 ++++++++++ qml/Windows/TZStickyExtraToolBar.qml | 40 ++ qml/Windows/TZStickyWindow.qml | 640 ++++++++++++++++++ qml/main.qml | 41 +- src/magnifier_provider.cpp | 111 ++-- src/main.cpp | 15 +- src/model/desktop_snapshot.cpp | 345 ++++++---- src/model/desktop_snapshot.h | 39 +- src/viewmodel/screenshot_view_model.cpp | 204 +++--- src/viewmodel/screenshot_view_model.h | 59 +- src/viewmodel/sticky_view_model.cpp | 68 +- src/viewmodel/sticky_view_model.h | 1 + 40 files changed, 4756 insertions(+), 377 deletions(-) create mode 100644 qml/Capture/TZCaptureInputController.qml create mode 100644 qml/Capture/TZGifRecordBar.qml create mode 100644 qml/Capture/TZHotkeyGestureController.qml create mode 100644 qml/Capture/TZLable.qml create mode 100644 qml/Capture/TZMagnifyingGlass.qml create mode 100644 qml/Capture/TZMarkToolInfo.qml create mode 100644 qml/Capture/TZRectMaskOverlay.qml create mode 100644 qml/Capture/TZResizeHandle.qml create mode 100644 qml/Capture/TZResizeHandleGroup.qml create mode 100644 qml/Capture/TZSelectionInfoLabel.qml create mode 100644 qml/Capture/TZSelectionOverlay.qml create mode 100644 qml/Capture/TZSelectionPositioner.qml create mode 100644 qml/Capture/TZTextAnnotationEditor.qml create mode 100644 qml/Capture/TZTool.qml create mode 100644 qml/Capture/TZWindow.qml create mode 100644 qml/Capture/TZWindowFactory.qml create mode 100644 qml/Capture/TZWindowHighlightOverlay.qml create mode 100644 qml/LongCapture/TZLongCaptureBar.qml create mode 100644 qml/LongCapture/TZLongCaptureFrame.qml create mode 100644 qml/LongCapture/TZLongCapturePreview.qml create mode 100644 qml/Windows/TZChat.qml create mode 100644 qml/Windows/TZLoadingSpinner.qml create mode 100644 qml/Windows/TZOcrResult.qml create mode 100644 qml/Windows/TZStickyExtraToolBar.qml create mode 100644 qml/Windows/TZStickyWindow.qml diff --git a/.gitignore b/.gitignore index fe6afa0..ac988cf 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ x64/ x86/ [Bb]uild/ [Bb]uild*/ +dist/ CMakeFiles/ CMakeCache.txt cmake_install.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 17bcefe..d9b81d0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,11 @@ find_package(Qt6 REQUIRED COMPONENTS qt_standard_project_setup() +# Help Qt Creator's QML code model resolve local qmldir-based modules +# such as `import TZControls 1.0`. +set(QML_IMPORT_PATH "${CMAKE_CURRENT_SOURCE_DIR}/qml" CACHE STRING "Local QML import paths" FORCE) +set(QML2_IMPORT_PATH "${CMAKE_CURRENT_SOURCE_DIR}/qml" CACHE STRING "Local QML import paths" FORCE) + # ── Third-party build options ───────────────────────────────── option(BUILD_THIRDPART_OCR "Auto build OCR libs" ON) option(OCR_FETCH_FROM_GITHUB "Fetch Leptonica/Tesseract from GitHub before building" ON) @@ -148,6 +153,10 @@ qt_add_executable(TZshot ${SOURCES} ) +set_target_properties(TZshot PROPERTIES + QT_QML_IMPORT_PATH "${CMAKE_CURRENT_SOURCE_DIR}/qml" +) + # ── include 路径 ────────────────────────────────────────── target_include_directories(TZshot PRIVATE src @@ -393,9 +402,15 @@ if(WIN32) target_include_directories(TZshot PRIVATE "${OCR_INSTALL_DIR}/include") target_link_directories(TZshot PRIVATE "${OCR_INSTALL_DIR}/lib") + set(OCR_LEPTONICA_LINK_NAME "leptonica-${OCR_LEPTONICA_VERSION}$<$:d>") + if(MINGW) + # Our MinGW Leptonica ExternalProject installs libleptonica.dll.a + # without a version/debug suffix, so link against that stable name. + set(OCR_LEPTONICA_LINK_NAME "leptonica") + endif() target_link_libraries(TZshot PRIVATE "tesseract55$<$:d>" - "leptonica-${OCR_LEPTONICA_VERSION}$<$:d>" + "${OCR_LEPTONICA_LINK_NAME}" Ws2_32 ) diff --git a/qml.qrc b/qml.qrc index a9a2437..8527319 100644 --- a/qml.qrc +++ b/qml.qrc @@ -1,13 +1,28 @@ qml/main.qml - qml/TZStickyWindow.qml - qml/TZWindow.qml - qml/TZLable.qml - qml/TZTool.qml - qml/TZResizeHandle.qml - qml/TZResizeHandleGroup.qml - qml/TZChat.qml + qml/Capture/TZWindow.qml + qml/Capture/TZSelectionOverlay.qml + qml/Capture/TZCaptureInputController.qml + qml/Capture/TZHotkeyGestureController.qml + qml/Capture/TZRectMaskOverlay.qml + qml/Capture/TZSelectionPositioner.qml + qml/Capture/TZSelectionInfoLabel.qml + qml/Capture/TZTextAnnotationEditor.qml + qml/Capture/TZWindowHighlightOverlay.qml + qml/Capture/TZWindowFactory.qml + qml/Capture/TZLable.qml + qml/Capture/TZTool.qml + qml/Capture/TZResizeHandle.qml + qml/Capture/TZResizeHandleGroup.qml + qml/Capture/TZMarkToolInfo.qml + qml/Capture/TZMagnifyingGlass.qml + qml/Capture/TZGifRecordBar.qml + qml/Windows/TZStickyWindow.qml + qml/Windows/TZStickyExtraToolBar.qml + qml/Windows/TZOcrResult.qml + qml/Windows/TZChat.qml + qml/Windows/TZLoadingSpinner.qml qml/Setting/TZSettingWin.qml qml/Setting/TZGeneralSettings.qml qml/Setting/TZRightPanel.qml @@ -29,19 +44,14 @@ resource/img/lc_highlighter.svg resource/img/lc_undo.svg resource/img/tray_icon.svg - qml/TZMarkToolInfo.qml - qml/TZMagnifyingGlass.qml resource/img/lc_close.svg resource/img/lc_chat.svg resource/img/lc_emoji.svg resource/img/lc_file.svg resource/img/lc_send.svg - qml/TZLoadingSpinner.qml - qml/TZGifRecordBar.qml - qml/TZOcrResult.qml - qml/TZLongCaptureBar.qml - qml/TZLongCapturePreview.qml - qml/TZLongCaptureFrame.qml + qml/LongCapture/TZLongCaptureBar.qml + qml/LongCapture/TZLongCapturePreview.qml + qml/LongCapture/TZLongCaptureFrame.qml resource/img/lc_gif_record.svg resource/img/lc_gif_stop.svg resource/img/lc_ocr.svg diff --git a/qml/Capture/TZCaptureInputController.qml b/qml/Capture/TZCaptureInputController.qml new file mode 100644 index 0000000..c19a997 --- /dev/null +++ b/qml/Capture/TZCaptureInputController.qml @@ -0,0 +1,56 @@ +import QtQuick + +QtObject { + id: controller + + property bool spaceHeld: false + property bool textClickPending: false + property bool textPressMoved: false + property point textClickAbsPoint: Qt.point(0, 0) + property point textClickLocalPoint: Qt.point(0, 0) + + function resetTextClickTracking() { + textClickPending = false + textPressMoved = false + textClickAbsPoint = Qt.point(0, 0) + textClickLocalPoint = Qt.point(0, 0) + } + + function beginTextClick(absX, absY, localX, localY) { + textClickPending = true + textPressMoved = false + textClickAbsPoint = Qt.point(absX, absY) + textClickLocalPoint = Qt.point(localX, localY) + } + + function updatePendingTextClick(absX, absY, moveThreshold) { + if (!textClickPending) + return false + + const threshold = moveThreshold === undefined ? 3 : moveThreshold + const dx = absX - textClickAbsPoint.x + const dy = absY - textClickAbsPoint.y + if (Math.abs(dx) > threshold || Math.abs(dy) > threshold) { + textPressMoved = true + textClickPending = false + return false + } + + return true + } + + function shouldOpenTextEditor() { + return textClickPending && !textPressMoved + } + + function translatedRect(initialRect, startPoint, currentX, currentY) { + const dx = currentX - startPoint.x + const dy = currentY - startPoint.y + return Qt.rect( + initialRect.x + dx, + initialRect.y + dy, + initialRect.width, + initialRect.height + ) + } +} diff --git a/qml/Capture/TZGifRecordBar.qml b/qml/Capture/TZGifRecordBar.qml new file mode 100644 index 0000000..f5b4003 --- /dev/null +++ b/qml/Capture/TZGifRecordBar.qml @@ -0,0 +1,194 @@ +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import QtQuick.Controls 2.5 + +// GIF 录制控制栏:在截图选区确定后显示 +Rectangle { + id: gifBar + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + layer.enabled: true + + // 外部传入的录制区域 + property rect captureRect: Qt.rect(0, 0, 0, 0) + + signal signalCancel + signal signalRecordFinished + + readonly property bool isRecording: O_GifRecordVM.isRecording + readonly property bool isEncoding: O_GifRecordVM.isEncoding + readonly property int elapsedSecs: O_GifRecordVM.elapsedSecs + readonly property int frameCount: O_GifRecordVM.frameCount + + Connections { + target: O_GifRecordVM + + function onEncodingFinished(savedPath) { + gifBar.signalRecordFinished() + } + + function onEncodingFailed(error) { + console.warn("[GifRecordBar] 录制失败:", error) + gifBar.signalRecordFinished() + } + } + + SequentialAnimation { + id: blinkAnim + running: gifBar.isRecording + loops: Animation.Infinite + PropertyAnimation { target: recDot; property: "opacity"; to: 0.2; duration: 500 } + PropertyAnimation { target: recDot; property: "opacity"; to: 1.0; duration: 500 } + } + + RowLayout { + anchors.fill: parent + anchors.margins: 6 + layoutDirection: Qt.RightToLeft + spacing: 4 + + // 取消按钮 + Rectangle { + width: 32; height: 32; radius: 7 + visible: !gifBar.isEncoding + color: cancelArea.containsMouse ? "#FEE2E2" : "transparent" + Behavior on color { ColorAnimation { duration: 100 } } + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_x.svg" + width: 18; height: 18 + fillMode: Image.PreserveAspectFit + smooth: true + } + MouseArea { + id: cancelArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: { + O_GifRecordVM.cancelRecording() + gifBar.signalCancel() + } + } + } + + // 停止录制按钮 + Rectangle { + width: 32; height: 32; radius: 7 + visible: gifBar.isRecording && !gifBar.isEncoding + color: stopArea.containsMouse ? "#FEF3C7" : "transparent" + Behavior on color { ColorAnimation { duration: 100 } } + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_gif_stop.svg" + width: 18; height: 18 + fillMode: Image.PreserveAspectFit + smooth: true + layer.enabled: true + layer.effect: null + } + MouseArea { + id: stopArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: O_GifRecordVM.stopRecording() + } + } + + Rectangle { + width: 1; height: 20 + visible: gifBar.isRecording && !gifBar.isEncoding + color: "#E2E8F0" + Layout.alignment: Qt.AlignVCenter + } + + // 录制中:时间 + 帧数 + Item { + visible: gifBar.isRecording && !gifBar.isEncoding + Layout.fillWidth: true + height: 32 + implicitWidth: recRow.implicitWidth + 8 + + Row { + id: recRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { + id: recDot + width: 8; height: 8; radius: 4 + color: "#EF4444" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: { + var m = Math.floor(gifBar.elapsedSecs / 60) + var s = gifBar.elapsedSecs % 60 + return (m < 10 ? "0" + m : m) + ":" + (s < 10 ? "0" + s : s) + } + font.pixelSize: 13 + font.family: "monospace" + color: "#1E293B" + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: gifBar.frameCount + "f" + font.pixelSize: 11 + color: "#94A3B8" + anchors.verticalCenter: parent.verticalCenter + } + } + } + + // 编码中:进度提示 + Item { + visible: gifBar.isEncoding + Layout.fillWidth: true + height: 32 + implicitWidth: encodingRow.implicitWidth + 16 + + Row { + id: encodingRow + anchors.centerIn: parent + spacing: 8 + + Rectangle { + width: 16; height: 16 + color: "transparent" + anchors.verticalCenter: parent.verticalCenter + Rectangle { + width: 16; height: 16 + radius: 8 + color: "transparent" + border.color: "#3B82F6" + border.width: 2 + Rectangle { + width: 6; height: 6 + color: "#3B82F6" + radius: 3 + x: 5; y: -1 + } + RotationAnimation on rotation { + running: gifBar.isEncoding + from: 0; to: 360 + duration: 800 + loops: Animation.Infinite + } + } + } + + Text { + text: qsTr("正在生成 GIF...") + font.pixelSize: 13 + color: "#1E293B" + anchors.verticalCenter: parent.verticalCenter + } + } + } + } +} diff --git a/qml/Capture/TZHotkeyGestureController.qml b/qml/Capture/TZHotkeyGestureController.qml new file mode 100644 index 0000000..03fc84b --- /dev/null +++ b/qml/Capture/TZHotkeyGestureController.qml @@ -0,0 +1,96 @@ +import QtQuick + +Item { + id: controller + + visible: false + + property bool enabled: false + property bool armed: false + property bool longPressed: false + property string mainToken: "" + property int longPressInterval: 280 + + signal longPressTriggered() + + function parseMainKeyToken(shortcutSeq) { + if (!shortcutSeq || shortcutSeq.length === 0) + return "" + + var parts = shortcutSeq.split("+") + if (parts.length === 0) + return "" + + return parts[parts.length - 1].trim().toUpperCase() + } + + function keyMatchesToken(key, token) { + if (!token || token.length === 0) + return false + + if (token.length === 1) { + if (token >= "A" && token <= "Z") + return key === (Qt.Key_A + (token.charCodeAt(0) - "A".charCodeAt(0))) + if (token >= "0" && token <= "9") + return key === (Qt.Key_0 + (token.charCodeAt(0) - "0".charCodeAt(0))) + } + + if (token[0] === "F") { + var fn = Number(token.slice(1)) + if (fn >= 1 && fn <= 24) + return key === (Qt.Key_F1 + fn - 1) + } + + if (token === "SPACE") + return key === Qt.Key_Space + if (token === "ESC" || token === "ESCAPE") + return key === Qt.Key_Escape + if (token === "ENTER" || token === "RETURN") + return key === Qt.Key_Return || key === Qt.Key_Enter + return false + } + + function beginGesture(shortcutSeq) { + enabled = true + armed = false + longPressed = false + mainToken = parseMainKeyToken(shortcutSeq) + longPressTimer.stop() + } + + function clearGesture() { + enabled = false + armed = false + longPressed = false + mainToken = "" + longPressTimer.stop() + } + + function armGesture() { + if (!enabled) + return + armed = true + longPressed = false + longPressTimer.restart() + } + + function handleKeyRelease(key) { + if (!armed || !keyMatchesToken(key, mainToken)) + return false + + longPressTimer.stop() + return true + } + + Timer { + id: longPressTimer + interval: controller.longPressInterval + repeat: false + onTriggered: { + if (controller.armed) { + controller.longPressed = true + controller.longPressTriggered() + } + } + } +} diff --git a/qml/Capture/TZLable.qml b/qml/Capture/TZLable.qml new file mode 100644 index 0000000..0863d50 --- /dev/null +++ b/qml/Capture/TZLable.qml @@ -0,0 +1,26 @@ +import QtQuick + +// 显示当前截图大小 +Rectangle { + property string lableText: "" + + radius: 6 + color: "#1E293B" + opacity: 0.85 + border.color: "#334155" + border.width: 1 + + implicitWidth: labelContent.implicitWidth + 16 + implicitHeight: labelContent.implicitHeight + 8 + + Text { + id: labelContent + anchors.centerIn: parent + font.pixelSize: 12 + font.family: "Microsoft YaHei" + color: "#F1F5F9" + text: lableText + renderType: Text.NativeRendering + } +} + diff --git a/qml/Capture/TZMagnifyingGlass.qml b/qml/Capture/TZMagnifyingGlass.qml new file mode 100644 index 0000000..fc4705a --- /dev/null +++ b/qml/Capture/TZMagnifyingGlass.qml @@ -0,0 +1,116 @@ +import QtQuick +import QtQuick.Window + +// ── 放大镜 ─────────────────────────────────────────────── +Rectangle { + id: magnifier + width: 130 + height: 170 // 额外 40px 供颜色信息显示 + z: 10 + color: "transparent" + visible: root.visibility === Window.FullScreen + + // 跟随鼠标移动,始终显示(不随截图状态切换) + property int magnifierMouseX: 0 + property int magnifierMouseY: 0 + property int magnifierAbsX: 0 + property int magnifierAbsY: 0 + property color magnifierPixelColor: "#000000" + + // 跟随鼠标,偏移 +15px,防止被遮挡;超出屏幕时翻转 + x: { + var px = magnifierMouseX + 15 + if (px + width > root.width) px = magnifierMouseX - width - 15 + return px + } + y: { + var py = magnifierMouseY + 15 + if (py + height > root.height) py = magnifierMouseY - height - 15 + return py + } + + // 外框 + Rectangle { + id: magnifierFrame + anchors.top: parent.top + width: 130 + height: 130 + radius: 4 + color: "#1a1a1a" + border.color: "#ffffff" + border.width: 1 + clip: true + + // 放大图像(来自 ImageProvider) + Image { + id: magnifierImage + anchors.centerIn: parent + width: 110 + height: 110 + // cache: false 确保每次坐标变化时重新请求图像 + cache: false + // source 用时间戳 +坐标,强制 QML 重新加载 + source: magnifier.visible + ? ("image://magnifier/" + magnifierAbsX + "," + magnifierAbsY + "?t=" + Date.now()) + : "" + fillMode: Image.Stretch + } + + // 中心像素颜色色块(8×8,叠在图像中心上方,不遮挡准线) + Rectangle { + anchors.centerIn: parent + width: 8 + height: 8 + color: magnifierPixelColor + border.color: "#ffffff" + border.width: 1 + visible: false // 色块已在 RGB 文本行体现,避免遮挡准线 + } + } + + // 颜色信息栏 + Rectangle { + id: colorInfoBar + anchors.top: magnifierFrame.bottom + anchors.topMargin: 2 + width: 130 + height: 38 + radius: 4 + color: "#cc1a1a1a" + border.color: "#555555" + border.width: 1 + + Row { + anchors.centerIn: parent + spacing: 6 + + // 色块 + Rectangle { + width: 16 + height: 16 + radius: 2 + color: magnifierPixelColor + border.color: "#ffffff" + border.width: 1 + anchors.verticalCenter: parent.verticalCenter + } + + // RGB 文本 + Text { + id: rgbText + anchors.verticalCenter: parent.verticalCenter + color: "#ffffff" + font.pixelSize: 11 + font.family: "monospace" + text: { + var c = magnifierPixelColor + var r = Math.round(c.r * 255) + var g = Math.round(c.g * 255) + var b = Math.round(c.b * 255) + return "R:" + r + " G:" + g + " B:" + b + } + } + } + } +} +// ── 放大镜 end ─────────────────────────────────────────── diff --git a/qml/Capture/TZMarkToolInfo.qml b/qml/Capture/TZMarkToolInfo.qml new file mode 100644 index 0000000..5c35099 --- /dev/null +++ b/qml/Capture/TZMarkToolInfo.qml @@ -0,0 +1,257 @@ +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +Rectangle { + id: panel + + property color currentColor: "#F43F5E" + property int currentSize: 6 + property bool showColorRow: true + property string toolName: "" + + property string annotationText: qsTr("文本") + property bool textBackgroundEnabled: true + property int mosaicBlurLevel: 2 + property bool numberAutoIncrement: true + property int numberValue: 1 + + width: 260 + height: toolName === "text" ? 128 + : (toolName === "number" ? 132 + : ((toolName === "mosaic") ? 92 : (showColorRow ? 84 : 50))) + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + RowLayout { + spacing: 6 + Layout.fillWidth: true + visible: panel.showColorRow + height: panel.showColorRow ? implicitHeight : 0 + + Repeater { + model: ["#F43F5E","#EF4444","#F97316","#EAB308", + "#22C55E","#3B82F6","#8B5CF6","#000000","#FFFFFF"] + delegate: Rectangle { + width: 18; height: 18; radius: 9 + color: modelData + border.color: panel.currentColor === modelData ? "#3B82F6" : "#CBD5E1" + border.width: panel.currentColor === modelData ? 2 : 1 + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onPressed: panel.currentColor = modelData + } + } + } + } + + RowLayout { + spacing: 6 + Layout.fillWidth: true + + Repeater { + model: [ + { size: 2, label: qsTr("细") }, + { size: 4, label: qsTr("中") }, + { size: 6, label: qsTr("粗") }, + { size: 10, label: qsTr("特粗") } + ] + delegate: Rectangle { + width: 48; height: 28 + radius: 6 + color: panel.currentSize === modelData.size ? "#EFF6FF" : (sizeArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: panel.currentSize === modelData.size ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + Column { + anchors.centerIn: parent + spacing: 3 + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: 24 + height: Math.min(modelData.size, 6) + radius: height / 2 + color: panel.currentSize === modelData.size ? "#3B82F6" : "#94A3B8" + } + Text { + anchors.horizontalCenter: parent.horizontalCenter + text: modelData.label + font.pixelSize: 10 + color: panel.currentSize === modelData.size ? "#3B82F6" : "#64748B" + } + } + + MouseArea { + id: sizeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.currentSize = modelData.size + } + } + } + } + + RowLayout { + visible: panel.toolName === "text" + Layout.fillWidth: true + spacing: 8 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 34 + radius: 6 + color: "#F8FAFC" + border.color: textInput.activeFocus ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + TextInput { + id: textInput + anchors.fill: parent + anchors.margins: 8 + verticalAlignment: TextInput.AlignVCenter + color: "#0F172A" + font.pixelSize: 13 + selectByMouse: true + text: panel.annotationText + onTextChanged: panel.annotationText = text + } + + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: 8 + visible: textInput.text.length === 0 && !textInput.activeFocus + text: qsTr("输入文字") + color: "#94A3B8" + font.pixelSize: 13 + } + } + + CheckBox { + text: qsTr("背景") + checked: panel.textBackgroundEnabled + onToggled: panel.textBackgroundEnabled = checked + } + } + + RowLayout { + visible: panel.toolName === "mosaic" + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("模糊") + color: "#334155" + } + + Slider { + Layout.fillWidth: true + from: 1 + to: 4 + stepSize: 1 + value: panel.mosaicBlurLevel + onMoved: panel.mosaicBlurLevel = Math.round(value) + } + + Text { + text: panel.mosaicBlurLevel + color: "#64748B" + } + } + + ColumnLayout { + visible: panel.toolName === "number" + Layout.fillWidth: true + spacing: 8 + + CheckBox { + text: qsTr("自动递增") + checked: panel.numberAutoIncrement + onToggled: panel.numberAutoIncrement = checked + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + Text { + text: qsTr("数字") + color: "#334155" + } + + Rectangle { + width: 28 + height: 28 + radius: 6 + color: minusArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "-" + color: "#334155" + font.pixelSize: 16 + } + + MouseArea { + id: minusArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.numberValue = Math.max(1, panel.numberValue - 1) + } + } + + Rectangle { + Layout.fillWidth: true + height: 28 + radius: 6 + color: "#F8FAFC" + border.color: "#E2E8F0" + border.width: 1 + + Text { + anchors.centerIn: parent + text: panel.numberValue + color: "#0F172A" + font.pixelSize: 13 + } + } + + Rectangle { + width: 28 + height: 28 + radius: 6 + color: plusArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: "+" + color: "#334155" + font.pixelSize: 16 + } + + MouseArea { + id: plusArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: panel.numberValue = Math.min(9999, panel.numberValue + 1) + } + } + } + } + } +} diff --git a/qml/Capture/TZRectMaskOverlay.qml b/qml/Capture/TZRectMaskOverlay.qml new file mode 100644 index 0000000..7cf17d4 --- /dev/null +++ b/qml/Capture/TZRectMaskOverlay.qml @@ -0,0 +1,48 @@ +import QtQuick + +Item { + id: overlay + + property rect holeRect: Qt.rect(0, 0, 0, 0) + property color maskColor: "#99000000" + + readonly property rect normalizedHoleRect: { + const left = Math.min(holeRect.x, holeRect.x + holeRect.width) + const right = Math.max(holeRect.x, holeRect.x + holeRect.width) + const top = Math.min(holeRect.y, holeRect.y + holeRect.height) + const bottom = Math.max(holeRect.y, holeRect.y + holeRect.height) + return Qt.rect(left, top, Math.max(0, right - left), Math.max(0, bottom - top)) + } + + Rectangle { + x: 0 + y: 0 + width: overlay.width + height: Math.max(0, overlay.normalizedHoleRect.y) + color: overlay.maskColor + } + + Rectangle { + x: 0 + y: overlay.normalizedHoleRect.y + width: Math.max(0, overlay.normalizedHoleRect.x) + height: Math.max(0, overlay.normalizedHoleRect.height) + color: overlay.maskColor + } + + Rectangle { + x: overlay.normalizedHoleRect.x + overlay.normalizedHoleRect.width + y: overlay.normalizedHoleRect.y + width: Math.max(0, overlay.width - x) + height: Math.max(0, overlay.normalizedHoleRect.height) + color: overlay.maskColor + } + + Rectangle { + x: 0 + y: overlay.normalizedHoleRect.y + overlay.normalizedHoleRect.height + width: overlay.width + height: Math.max(0, overlay.height - y) + color: overlay.maskColor + } +} diff --git a/qml/Capture/TZResizeHandle.qml b/qml/Capture/TZResizeHandle.qml new file mode 100644 index 0000000..91c8ab1 --- /dev/null +++ b/qml/Capture/TZResizeHandle.qml @@ -0,0 +1,14 @@ +import QtQuick + +Rectangle { +// id:leftTop +// x: screenshotArea.x - handleSize/2 +// y: screenshotArea.y - handleSize/2 + property int handleSize: 8 + width: handleSize + height: handleSize + radius: handleSize/2 + color: "#0088FF" + border.color: "white" + border.width: 1 +} diff --git a/qml/Capture/TZResizeHandleGroup.qml b/qml/Capture/TZResizeHandleGroup.qml new file mode 100644 index 0000000..2a158a4 --- /dev/null +++ b/qml/Capture/TZResizeHandleGroup.qml @@ -0,0 +1,301 @@ +import QtQuick +import QtQuick.Window + + +Rectangle{ + id:handleGroup + property rect area: Qt.rect(0,0,0,0) + property rect initialArea: Qt.rect(0,0,0,0) + property var mousePointStart: Qt.point(0,0) + property var coordinateItem: null + x:area.x + y:area.y + width: area.width + height: area.height + color: "transparent" + function pointInCoordinateItem(item, x, y) { + if (coordinateItem) + return item.mapToItem(coordinateItem, x, y) + return item.mapToItem(null, x, y) + } + //左上角控制点 + TZResizeHandle + { + id:leftTop + x: - handleSize/2 + y: - handleSize/2 + // 仅当选区宽度/高度非零时显示 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeFDiagCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(leftTop, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + // 重置所有临时变量 + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(leftTop, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.x = initialArea.x + dx + area.y = initialArea.y + dy + area.width = initialArea.width - dx + area.height = initialArea.height - dy + } + } + } + } + + //正上方控制点 + TZResizeHandle + { + id: top + x: (area.width - handleSize/2)/2 + y: -handleSize/2 + // 仅当选区宽度/高度非零时显示 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeVerCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(top, mouseX, mouseY); + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + // 重置所有临时变量 + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(top, mouseX, mouseY); + let dy = globalPos.y - mousePointStart.y + area.y = initialArea.y + dy + area.height = initialArea.height - dy + } + } + } + } + + //右上角控制点 + TZResizeHandle + { + id:rightTop + x: area.width - handleSize/2 + y: -handleSize/2 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeBDiagCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(rightTop, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(rightTop, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.y = initialArea.y + dy + area.width = initialArea.width + dx + area.height = initialArea.height - dy + } + } + } + + + } + + //右边控制点 + TZResizeHandle + { + id: right + x: area.width - handleSize/2 + y: (area.height -handleSize/2)/2 + visible: area.width !== 0 || area.height !== 0 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeHorCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(right, mouseX, mouseY); + mousePointStart.x = globalPos.x + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(right, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + area.width = initialArea.width + dx + } + } + } + + + } + + //右下角控制点 + TZResizeHandle + { + id:rightButtom + x: area.width - handleSize/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeFDiagCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(rightButtom, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(rightButtom, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.width = initialArea.width + dx + area.height = initialArea.height + dy + } + } + } + } + + //正下方控制点 + TZResizeHandle + { + id:buttom + x: (area.width - handleSize/2)/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeVerCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(buttom, mouseX, mouseY); + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(buttom, mouseX, mouseY); + let dy = globalPos.y - mousePointStart.y + area.height = initialArea.height + dy + } + } + } + } + + //左下角控制点 + TZResizeHandle + { + id:leftBottom + x: -handleSize/2 + y: area.height - handleSize/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeBDiagCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(leftBottom, mouseX, mouseY); + mousePointStart.x = globalPos.x + mousePointStart.y = globalPos.y + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(leftBottom, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + let dy = globalPos.y - mousePointStart.y + area.x = initialArea.x + dx + area.width = initialArea.width - dx + area.height = initialArea.height + dy + } + } + } + + } + + //左边控制点 + TZResizeHandle + { + id:left + x: -handleSize/2 + y: (area.height - handleSize/2)/2 + visible: area.width !== 0 || area.height !== 0 + z:2 + MouseArea { + anchors.fill: parent + cursorShape: Qt.SizeHorCursor + onPressed: { + let globalPos = handleGroup.pointInCoordinateItem(left, mouseX, mouseY); + mousePointStart.x = globalPos.x + initialArea = area + } + + onReleased: { + mousePointStart = Qt.point(0,0) + initialArea = area + } + + onPositionChanged: { + if (pressed) { + let globalPos = handleGroup.pointInCoordinateItem(left, mouseX, mouseY); + let dx = globalPos.x - mousePointStart.x + area.x = initialArea.x + dx + area.width = initialArea.width - dx + } + } + } + + } + +} diff --git a/qml/Capture/TZSelectionInfoLabel.qml b/qml/Capture/TZSelectionInfoLabel.qml new file mode 100644 index 0000000..966ddd5 --- /dev/null +++ b/qml/Capture/TZSelectionInfoLabel.qml @@ -0,0 +1,19 @@ +import QtQuick + +TZLable { + id: label + + property rect logicalRect: Qt.rect(0, 0, 0, 0) + property rect physicalRect: Qt.rect(0, 0, 0, 0) + + width: 360 + height: 20 + lableText: qsTr("X:%1 Y:%2 P:%3*%4 L:%5*%6 ") + .arg(logicalRect.x) + .arg(logicalRect.y) + .arg(Math.abs(physicalRect.x)) + .arg(Math.abs(physicalRect.y)) + .arg(Math.abs(logicalRect.width)) + .arg(Math.abs(logicalRect.height)) + +} diff --git a/qml/Capture/TZSelectionOverlay.qml b/qml/Capture/TZSelectionOverlay.qml new file mode 100644 index 0000000..2057c75 --- /dev/null +++ b/qml/Capture/TZSelectionOverlay.qml @@ -0,0 +1,147 @@ +import QtQuick + +QtObject { + id: overlay + + property rect area: Qt.rect(0, 0, 0, 0) + property rect initialArea: Qt.rect(0, 0, 0, 0) + property point dragOffset: Qt.point(0, 0) + property string dragMode: "none" // none | select | move + property var coordinateItem: null + + readonly property rect localArea: { + if (!coordinateItem) + return area + const topLeft = coordinateItem.mapFromGlobal(area.x, area.y) + return Qt.rect(topLeft.x, topLeft.y, area.width, area.height) + } + + readonly property rect localNormalizedArea: { + const bounds = getNormalizedRectBounds(localArea) + return bounds.isValid + ? Qt.rect(bounds.left, bounds.top, bounds.width, bounds.height) + : Qt.rect(0, 0, 0, 0) + } + + readonly property rect normalizedArea: { + const bounds = getNormalizedRectBounds(area) + return bounds.isValid + ? Qt.rect(bounds.left, bounds.top, bounds.width, bounds.height) + : Qt.rect(0, 0, 0, 0) + } + + function hasArea() { + return Math.abs(area.width) > 2 && Math.abs(area.height) > 2 + } + + function getNormalizedRectBounds(targetRect) { + if (targetRect.width === 0 || targetRect.height === 0) { + return { left: 0, right: 0, top: 0, bottom: 0, isValid: false } + } + + const left = Math.min(targetRect.x, targetRect.x + targetRect.width) + const right = Math.max(targetRect.x, targetRect.x + targetRect.width) + const top = Math.min(targetRect.y, targetRect.y + targetRect.height) + const bottom = Math.max(targetRect.y, targetRect.y + targetRect.height) + return { + left: left, + right: right, + top: top, + bottom: bottom, + width: right - left, + height: bottom - top, + isValid: true + } + } + + function containsPoint(targetRect, x, y) { + const bounds = getNormalizedRectBounds(targetRect) + if (!bounds.isValid) + return false + return x >= bounds.left && x <= bounds.right + && y >= bounds.top && y <= bounds.bottom + } + + function containsCurrentPoint(x, y) { + return containsPoint(area, x, y) + } + + function selectionRectFromDrag(startX, startY, endX, endY, withShift, withAlt) { + let x = startX + let y = startY + let w = endX - startX + let h = endY - startY + + if (withAlt) { + x = startX - (endX - startX) + y = startY - (endY - startY) + w = (endX - startX) * 2 + h = (endY - startY) * 2 + } + + if (withShift) { + const absW = Math.abs(w) + const absH = Math.abs(h) + const side = Math.min(absW, absH) + w = w < 0 ? -side : side + h = h < 0 ? -side : side + } + + return Qt.rect(x, y, w, h) + } + + function beginSelection(ax, ay) { + dragMode = "select" + initialArea = Qt.rect(ax, ay, 0, 0) + area = Qt.rect(ax, ay, 0, 0) + } + + function beginMove(ax, ay) { + dragMode = "move" + dragOffset = Qt.point(ax, ay) + initialArea = area + } + + function updateMove(ax, ay) { + const dx = ax - dragOffset.x + const dy = ay - dragOffset.y + area = Qt.rect( + initialArea.x + dx, + initialArea.y + dy, + initialArea.width, + initialArea.height + ) + } + + function updateSelection(ax, ay, modifiers) { + const useShift = (modifiers & Qt.ShiftModifier) !== 0 + const useAlt = (modifiers & Qt.AltModifier) !== 0 + area = selectionRectFromDrag( + initialArea.x, + initialArea.y, + ax, + ay, + useShift, + useAlt + ) + } + + function normalizeCurrentArea() { + const bounds = getNormalizedRectBounds(area) + if (bounds.isValid) { + area = Qt.rect(bounds.left, bounds.top, bounds.width, bounds.height) + initialArea = area + } + } + + function endInteraction() { + dragMode = "none" + } + + function reset() { + area = Qt.rect(0, 0, 0, 0) + initialArea = Qt.rect(0, 0, 0, 0) + dragOffset = Qt.point(0, 0) + dragMode = "none" + } +} diff --git a/qml/Capture/TZSelectionPositioner.qml b/qml/Capture/TZSelectionPositioner.qml new file mode 100644 index 0000000..3ea94c1 --- /dev/null +++ b/qml/Capture/TZSelectionPositioner.qml @@ -0,0 +1,34 @@ +import QtQuick + +QtObject { + id: positioner + + property int gap: 6 + + function clamp(value, minValue, maxValue) { + return Math.max(minValue, Math.min(value, maxValue)) + } + + function alignLeft(areaRect, itemWidth, viewportWidth) { + return clamp(areaRect.x, 0, Math.max(0, viewportWidth - itemWidth)) + } + + function alignRight(areaRect, itemWidth, viewportWidth) { + const preferredX = areaRect.x + areaRect.width - itemWidth + return clamp(preferredX, 0, Math.max(0, viewportWidth - itemWidth)) + } + + function placeBelowOrAbove(areaRect, itemHeight, viewportHeight, customGap) { + const spacing = customGap === undefined ? gap : customGap + const belowY = areaRect.y + areaRect.height + spacing + if (belowY + itemHeight <= viewportHeight) + return belowY + return areaRect.y - itemHeight - spacing + } + + function placeAbove(areaRect, itemHeight, viewportHeight, customGap) { + const spacing = customGap === undefined ? gap : customGap + const preferredY = areaRect.y - itemHeight - spacing + return clamp(preferredY, 0, Math.max(0, viewportHeight - itemHeight)) + } +} diff --git a/qml/Capture/TZTextAnnotationEditor.qml b/qml/Capture/TZTextAnnotationEditor.qml new file mode 100644 index 0000000..7b9a625 --- /dev/null +++ b/qml/Capture/TZTextAnnotationEditor.qml @@ -0,0 +1,101 @@ +import QtQuick + +Rectangle { + id: editor + + property alias editorText: textInput.text + + signal accepted(string text) + signal canceled() + + width: 260 + height: 88 + visible: false + radius: 8 + color: "#FFFFFF" + border.color: "#CBD5E1" + border.width: 1 + + function beginEdit(textValue) { + editorText = textValue + visible = true + textInput.forceActiveFocus() + textInput.selectAll() + } + + Column { + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + Rectangle { + width: parent.width + height: 34 + radius: 6 + color: "#F8FAFC" + border.color: textInput.activeFocus ? "#3B82F6" : "#E2E8F0" + border.width: 1 + + TextInput { + id: textInput + anchors.fill: parent + anchors.margins: 8 + verticalAlignment: TextInput.AlignVCenter + color: "#0F172A" + font.pixelSize: 13 + selectByMouse: true + onAccepted: editor.accepted(text) + } + } + + Row { + spacing: 8 + + Rectangle { + width: 72 + height: 28 + radius: 6 + color: cancelInputArea.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + + Text { + anchors.centerIn: parent + text: qsTr("Cancel") + color: "#334155" + font.pixelSize: 12 + } + + MouseArea { + id: cancelInputArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: editor.canceled() + } + } + + Rectangle { + width: 72 + height: 28 + radius: 6 + color: confirmInputArea.containsMouse ? "#2563EB" : "#3B82F6" + + Text { + anchors.centerIn: parent + text: qsTr("OK") + color: "#FFFFFF" + font.pixelSize: 12 + } + + MouseArea { + id: confirmInputArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onPressed: editor.accepted(textInput.text) + } + } + } + } +} diff --git a/qml/Capture/TZTool.qml b/qml/Capture/TZTool.qml new file mode 100644 index 0000000..5d6552b --- /dev/null +++ b/qml/Capture/TZTool.qml @@ -0,0 +1,265 @@ +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import QtQuick.Controls 2.5 + +Rectangle { + id: tool + radius: 10 + color: "#FFFFFF" + visible: false + border.color: "#E2E8F0" + border.width: 1 + layer.enabled: true + implicitWidth: toolRow.implicitWidth + 12 + implicitHeight: 44 + + signal signalScreenshot + signal signalCancel + signal signalSave + signal signalSticky + signal signalUndo + signal signalGifRecord + signal signalLongScreenshot + signal signalOcr + + property string activeTool: "" + property bool markToolInfoVisible: false + property string hoverTipText: "" + property real hoverTipCenterX: width / 2 + property bool showScreenshotButton: true + property bool showCancelButton: true + property bool showSaveButton: true + property bool showStickyButton: true + property bool showUndoButton: true + property bool showGifRecordButton: true + property bool showLongScreenshotButton: true + property bool showOcrButton: true + property bool showRectButton: true + property bool showCircleButton: true + property bool showArrowButton: true + property bool showPencilButton: true + property bool showHighlightButton: true + property bool showMosaicButton: true + property bool showTextButton: true + property bool showNumberButton: true + + readonly property int shapeType: { + switch (activeTool) { + case "pencil": return 0 + case "rect": return 1 + case "circle": return 2 + case "arrow": return 3 + case "mosaic": return 4 + case "text": return 5 + case "number": return 6 + case "highlight": return 7 + default: return 1 + } + } + + function setActiveTool(name) { + if (activeTool === name) { + activeTool = "" + markToolInfoVisible = false + } else { + activeTool = name + markToolInfoVisible = true + } + } + + function showTip(text, targetItem) { + hoverTipText = text + if (targetItem) { + var p = tool.mapFromItem(targetItem, targetItem.width / 2, targetItem.height) + hoverTipCenterX = p.x + } else { + hoverTipCenterX = width / 2 + } + } + + function hideTip(text) { + if (text === undefined || hoverTipText === text) + hoverTipText = "" + } + + RowLayout { + id: toolRow + anchors.fill: parent + anchors.margins: 6 + layoutDirection: Qt.RightToLeft + spacing: 2 + + Rectangle { + visible: tool.showScreenshotButton + width: 32; height: 32; radius: 7 + color: checkArea.containsMouse ? "#DCFCE7" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_check.svg"; width: 18; height: 18 } + MouseArea { id: checkArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("完成"), parent); onExited: tool.hideTip(qsTr("完成")); onPressed: signalScreenshot() } + } + + Rectangle { + visible: tool.showCancelButton + width: 32; height: 32; radius: 7 + color: closeArea.containsMouse ? "#FEE2E2" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_x.svg"; width: 18; height: 18 } + MouseArea { id: closeArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("取消"), parent); onExited: tool.hideTip(qsTr("取消")); onPressed: signalCancel() } + } + + Rectangle { visible: tool.showScreenshotButton || tool.showCancelButton; width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + visible: tool.showOcrButton + width: 32; height: 32; radius: 7 + color: ocrArea.containsMouse ? "#F0FDF4" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_ocr.svg"; width: 18; height: 18 } + MouseArea { id: ocrArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("OCR"), parent); onExited: tool.hideTip(qsTr("OCR")); onPressed: signalOcr() } + } + + Rectangle { + id: gifRecordBtn + visible: tool.showGifRecordButton + width: 32; height: 32; radius: 7 + color: gifArea.containsMouse ? "#FFF7ED" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_gif_record.svg"; width: 18; height: 18 } + MouseArea { id: gifArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("录制GIF"), parent); onExited: tool.hideTip(qsTr("录制GIF")); onPressed: signalGifRecord() } + } + + Rectangle { + visible: tool.showLongScreenshotButton + width: 32; height: 32; radius: 7 + color: longShotArea.containsMouse ? "#FFF7ED" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_longshot.svg"; width: 18; height: 18 } + MouseArea { id: longShotArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("长截图"), parent); onExited: tool.hideTip(qsTr("长截图")); onPressed: signalLongScreenshot() } + } + + Rectangle { visible: tool.showOcrButton || tool.showGifRecordButton || tool.showLongScreenshotButton; width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + visible: tool.showSaveButton + width: 32; height: 32; radius: 7 + color: saveArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_save.svg"; width: 18; height: 18 } + MouseArea { id: saveArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("保存"), parent); onExited: tool.hideTip(qsTr("保存")); onPressed: signalSave() } + } + + Rectangle { + visible: tool.showStickyButton + width: 32; height: 32; radius: 7 + color: fixArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_pin.svg"; width: 18; height: 18 } + MouseArea { id: fixArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("贴图"), parent); onExited: tool.hideTip(qsTr("贴图")); onPressed: signalSticky() } + } + + Rectangle { visible: tool.showSaveButton || tool.showStickyButton; width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter } + + Rectangle { + visible: tool.showRectButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "rect") ? "#EFF6FF" : (rectArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "rect") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_square.svg"; width: 18; height: 18 } + MouseArea { id: rectArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("矩形"), parent); onExited: tool.hideTip(qsTr("矩形")); onPressed: tool.setActiveTool("rect") } + } + + Rectangle { + visible: tool.showCircleButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "circle") ? "#EFF6FF" : (circleArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "circle") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_circle.svg"; width: 18; height: 18 } + MouseArea { id: circleArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("圆形"), parent); onExited: tool.hideTip(qsTr("圆形")); onPressed: tool.setActiveTool("circle") } + } + + Rectangle { + visible: tool.showArrowButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "arrow") ? "#EFF6FF" : (arrowArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "arrow") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_arrow.svg"; width: 18; height: 18 } + MouseArea { id: arrowArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("箭头"), parent); onExited: tool.hideTip(qsTr("箭头")); onPressed: tool.setActiveTool("arrow") } + } + + Rectangle { + visible: tool.showPencilButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "pencil") ? "#EFF6FF" : (pencilArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "pencil") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_pencil.svg"; width: 18; height: 18 } + MouseArea { id: pencilArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("画笔"), parent); onExited: tool.hideTip(qsTr("画笔")); onPressed: tool.setActiveTool("pencil") } + } + + Rectangle { + visible: tool.showHighlightButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "highlight") ? "#EFF6FF" : (highlightArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "highlight") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_highlighter.svg"; width: 18; height: 18 } + MouseArea { id: highlightArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("高亮"), parent); onExited: tool.hideTip(qsTr("高亮")); onPressed: tool.setActiveTool("highlight") } + } + + Rectangle { + visible: tool.showMosaicButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "mosaic") ? "#EFF6FF" : (mosaicArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "mosaic") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_mosaic.svg"; width: 18; height: 18 } + MouseArea { id: mosaicArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("马赛克"), parent); onExited: tool.hideTip(qsTr("马赛克")); onPressed: tool.setActiveTool("mosaic") } + } + + Rectangle { + visible: tool.showTextButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "text") ? "#EFF6FF" : (textArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "text") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_text.svg"; width: 18; height: 18 } + MouseArea { id: textArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("文字"), parent); onExited: tool.hideTip(qsTr("文字")); onPressed: tool.setActiveTool("text") } + } + + Rectangle { + visible: tool.showNumberButton + width: 32; height: 32; radius: 7 + color: (tool.activeTool === "number") ? "#EFF6FF" : (numberArea.containsMouse ? "#F1F5F9" : "transparent") + border.color: (tool.activeTool === "number") ? "#3B82F6" : "transparent" + border.width: 1 + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_list_ordered.svg"; width: 18; height: 18 } + MouseArea { id: numberArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("序号"), parent); onExited: tool.hideTip(qsTr("序号")); onPressed: tool.setActiveTool("number") } + } + + Rectangle { + visible: tool.showUndoButton + width: 32; height: 32; radius: 7 + color: undoArea.containsMouse ? "#EFF6FF" : "transparent" + Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_undo.svg"; width: 18; height: 18 } + MouseArea { id: undoArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: tool.showTip(qsTr("撤销"), parent); onExited: tool.hideTip(qsTr("撤销")); onPressed: signalUndo() } + } + } + + Rectangle { + id: tipBubble + visible: tool.visible && tool.hoverTipText.length > 0 + z: 100 + parent: tool.parent + width: Math.max(44, tipText.implicitWidth + 14) + height: 24 + radius: 6 + color: "#0F172A" + opacity: 0.92 + x: Math.max(tool.x, Math.min(tool.x + tool.width - width, tool.x + tool.hoverTipCenterX - width / 2)) + y: tool.y + tool.height + 6 + + Text { + id: tipText + anchors.centerIn: parent + text: tool.hoverTipText + color: "#FFFFFF" + font.pixelSize: 11 + } + } +} diff --git a/qml/Capture/TZWindow.qml b/qml/Capture/TZWindow.qml new file mode 100644 index 0000000..fb6fa3c --- /dev/null +++ b/qml/Capture/TZWindow.qml @@ -0,0 +1,764 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Dialogs +import CustomComponents 1.0 + +Window { + id: mainWin + color: "transparent"; + + // Virtual desktop offset injected by main.qml + property int screenVirtualX: 0 + property int screenVirtualY: 0 + + property alias initialScreenshotArea: selectionOverlay.initialArea + property alias screenshotArea: selectionOverlay.area + property alias offset: selectionOverlay.dragOffset + property alias dragMode: selectionOverlay.dragMode + property string screenShotUrl: "" + + property rect highlightRect: Qt.rect(0, 0, 0, 0) + property point pendingTextPoint: Qt.point(0, 0) + property string pendingTextValue: "" + property bool captureUiReady: false + + property bool gifRecordMode: false + readonly property bool gifAreaLocked: O_GifRecordVM.isRecording || O_GifRecordVM.isEncoding + + property alias tool: tool + property alias paintBoard: paintBoard + property alias magnifier: magnifier + property alias gifBarObj: gifBar + + function hasSelectionArea() { + return selectionOverlay.hasArea() + } + + function beginHotkeyGesture(shortcutSeq) { + hotkeyGesture.beginGesture(shortcutSeq) + } + + function clearHotkeyGesture() { + hotkeyGesture.clearGesture() + } + + function maybeArmHotkeyGesture() { + if (!hotkeyGesture.enabled || !hasSelectionArea()) + return + hotkeyGesture.armGesture() + } + + function confirmSelectionToClipboard() { + root.visibility = Window.Hidden + Qt.callLater(function() { + var success = O_ScreenCapture.captureRectToClipboard(paintBoard, screenshotArea) + resetArea() + if (success) + trayHelper.showMessage(qsTr("Capture done"), qsTr("Copied to clipboard")) + else + trayHelper.showMessage(qsTr("Capture failed"), qsTr("Failed to copy image")) + }) + } + + function handleEscAction() { + if (textEditor.visible) { + hideTextEditor() + return true + } + + if (windowFactory.closeTopOcrWindow()) { + return true + } + + if (gifRecordMode) { + O_GifRecordVM.cancelRecording() + gifBar.visible = false + gifRecordMode = false + if (hasSelectionArea()) + tool.visible = true + return true + } + + if (tool.activeTool !== "") { + tool.activeTool = "" + return true + } + + if (tool.markToolInfoVisible) { + tool.markToolInfoVisible = false + return true + } + + if (tool.visible) { + tool.visible = false + return true + } + + if (hasSelectionArea()) { + resetArea() + return true + } + + return false + } + + function beginSelectionInteraction(ax, ay) { + if (screenshotArea.width !== 0 || screenshotArea.height !== 0) { + if (selectionOverlay.containsCurrentPoint(ax, ay)) { + selectionOverlay.beginMove(ax, ay) + return + } + + paintBoard.reset() + tool.activeTool = "" + tool.markToolInfoVisible = false + tool.visible = false + resizeHandleGroup.visible = false + hideTextEditor() + var winRect = O_ScreenCapture.windowAtPoint(ax, ay) + highlightRect = winRect.width > 0 + ? Qt.rect(winRect.x, winRect.y, winRect.width, winRect.height) + : Qt.rect(0, 0, 0, 0) + selectionOverlay.beginSelection(ax, ay) + return + } + + paintBoard.reset() + tool.activeTool = "" + selectionOverlay.beginSelection(ax, ay) + } + + function updateSelectionInteraction(ax, ay, isPressed, modifiers) { + if (isPressed) + resizeHandleGroup.visible = false + var c = O_ScreenCapture.getPixelColor(ax, ay) + if (c !== undefined && c !== null) + magnifier.magnifierPixelColor = c + + if (!isPressed && screenshotArea.width === 0 && screenshotArea.height === 0) { + var winRect = O_ScreenCapture.windowAtPoint(ax, ay) + highlightRect = winRect.width > 0 + ? Qt.rect(winRect.x, winRect.y, winRect.width, winRect.height) + : Qt.rect(0, 0, 0, 0) + return + } + if (!isPressed) { + return + } + + if (dragMode === "move") { + selectionOverlay.updateMove(ax, ay) + return + } + + if (dragMode === "select") { + selectionOverlay.updateSelection(ax, ay, modifiers) + if (tool.visible) tool.visible = false + } + } + + function finalizeSelectionInteraction() { + selectionOverlay.endInteraction() + if (Math.abs(screenshotArea.width) <= 5 && Math.abs(screenshotArea.height) <= 5) { + if (highlightRect.width > 0 && highlightRect.height > 0) { + screenshotArea = highlightRect + console.log("screenshotArea2-->",screenshotArea) + initialScreenshotArea = highlightRect + resizeHandleGroup.area = localScreenshotArea + resizeHandleGroup.initialArea = toLocalRect(initialScreenshotArea) + highlightRect = Qt.rect(0, 0, 0, 0) + Qt.callLater(function() { tool.visible = true }) + } + resizeHandleGroup.visible = (highlightRect.width > 0 && highlightRect.height > 0) + return + } + selectionOverlay.normalizeCurrentArea() + resizeHandleGroup.initialArea = toLocalRect(initialScreenshotArea) + if (Math.abs(screenshotArea.width) > 2 && Math.abs(screenshotArea.height) > 2) { + Qt.callLater(function() { tool.visible = true }) + } + resizeHandleGroup.area = localScreenshotArea + resizeHandleGroup.visible = true; + + } + function toAbsolutePoint(localX, localY) { + return screenshotRect.mapToGlobal(localX, localY) + } + function toLocalRect(absRect) { + var topLeft = screenshotRect.mapFromGlobal(absRect.x, absRect.y) + return Qt.rect(topLeft.x, topLeft.y, absRect.width, absRect.height) + } + function toAbsoluteRect(localRect) { + var topLeft = screenshotRect.mapToGlobal(localRect.x, localRect.y) + return Qt.rect(topLeft.x, topLeft.y, localRect.width, localRect.height) + } + function syncResizeHandles() { + resizeHandleGroup.area = localScreenshotArea + resizeHandleGroup.initialArea = toLocalRect(initialScreenshotArea) + } + function syncGifResizeHandles() { + gifResizeHandleGroup.area = localScreenshotArea + gifResizeHandleGroup.initialArea = toLocalRect(initialScreenshotArea) + } + function normalizeRect(rectValue) { + var left = Math.min(rectValue.x, rectValue.x + rectValue.width) + var right = Math.max(rectValue.x, rectValue.x + rectValue.width) + var top = Math.min(rectValue.y, rectValue.y + rectValue.height) + var bottom = Math.max(rectValue.y, rectValue.y + rectValue.height) + return Qt.rect(left, top, right - left, bottom - top) + } + + // screenshotArea 在本窗口本地坐标中的等价矩形(用于绘制) + readonly property rect localScreenshotArea: toLocalRect(screenshotArea) + readonly property rect localNormalizedScreenshotArea: { + return normalizeRect(localScreenshotArea) + } + readonly property rect normalizedScreenshotArea: { + return selectionOverlay.normalizedArea + } + readonly property rect physicalScreenshotArea: { + if (screenshotArea.width === 0 || screenshotArea.height === 0) + return Qt.rect(0, 0, 0, 0) + return O_ScreenCapture.mapLogicalRectToPhysicalRect(normalizedScreenshotArea) + } + readonly property rect localHighlightRect: Qt.rect( + highlightRect.x - screenVirtualX, + highlightRect.y - screenVirtualY, + highlightRect.width, + highlightRect.height + ) + + function createStickyWindow(imageUrl, imgRect) { + console.log("imgRect-->",imgRect) + return windowFactory.createStickyWindow(imageUrl, imgRect) + } + + function createOcrResultWindow() { + return windowFactory.createOcrResultWindow() + } + + function openTextEditor(localX, localY) { + pendingTextPoint = Qt.point(localX, localY) + pendingTextValue = markToolInfo.annotationText + textEditor.x = Math.max(0, Math.min(localX, mainWin.width - textEditor.width)) + textEditor.y = Math.max(0, Math.min(localY + 8, mainWin.height - textEditor.height)) + textEditor.beginEdit(pendingTextValue) + } + + function hideTextEditor() { + textEditor.visible = false + } + + function submitTextEditor(textValue) { + var text = textValue.trim() + if (text.length === 0) { + hideTextEditor() + return + } + markToolInfo.annotationText = text + paintBoard.addTextAnnotation(Math.round(pendingTextPoint.x), Math.round(pendingTextPoint.y), text) + hideTextEditor() + } + + function resetTextClickTracking() { + inputController.resetTextClickTracking() + } + + function beginGifInteraction(ax, ay) { + if (!gifRecordMode || gifAreaLocked) + return false + if (selectionOverlay.containsCurrentPoint(ax, ay)) { + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + } + return true + } + + function beginSpaceMoveInteraction(ax, ay) { + if (!inputController.spaceHeld || !hasSelectionArea()) + return false + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + return true + } + + function beginTextToolInteraction(ax, ay, localX, localY) { + if (tool.activeTool !== "text" + || screenshotArea.width === 0 + || screenshotArea.height === 0 + || !selectionOverlay.containsCurrentPoint(ax, ay)) { + return false + } + + inputController.beginTextClick(ax, ay, localX, localY) + offset.x = ax + offset.y = ay + initialScreenshotArea = screenshotArea + return true + } + + function updatePendingTextInteraction(ax, ay) { + var stillPending = inputController.updatePendingTextClick(ax, ay, 3) + if (!stillPending && inputController.textPressMoved && textEditor.visible) + hideTextEditor() + return stillPending + } + + function updateGifInteraction(ax, ay, isPressed) { + if (!gifRecordMode) + return false + if (gifAreaLocked) + return true + if (isPressed && selectionOverlay.containsCurrentPoint(ax, ay)) { + screenshotArea = inputController.translatedRect(initialScreenshotArea, offset, ax, ay) + O_GifRecordVM.updateCaptureRect( + screenshotArea.x, + screenshotArea.y, + screenshotArea.width, + screenshotArea.height + ) + } + return true + } + + function updateSpaceMoveInteraction(ax, ay, isPressed) { + if (!isPressed || !inputController.spaceHeld || !hasSelectionArea()) + return false + + screenshotArea = inputController.translatedRect(initialScreenshotArea, offset, ax, ay) + return true + } + + function updateMagnifierFromMouse(localX, localY, ax, ay) { + magnifier.magnifierMouseX = localX + magnifier.magnifierMouseY = localY + magnifier.magnifierAbsX = ax + magnifier.magnifierAbsY = ay + } + + function finalizeTextToolInteraction() { + if (inputController.shouldOpenTextEditor()) { + openTextEditor(inputController.textClickLocalPoint.x, + inputController.textClickLocalPoint.y) + resetTextClickTracking() + return true + } + + resetTextClickTracking() + return false + } + + function finalizeGifInteraction() { + if (!gifRecordMode) + return false + if (gifAreaLocked) + return true + + initialScreenshotArea = screenshotArea + gifResizeHandleGroup.initialArea = toLocalRect(initialScreenshotArea) + return true + } + + function resetArea() { + selectionOverlay.reset() + syncResizeHandles() + syncGifResizeHandles() + screenShotUrl = "" + paintBoard.reset() + tool.visible = false + tool.activeTool = "" + tool.markToolInfoVisible = false + hideTextEditor() + gifRecordMode = false + gifBar.visible = false + highlightRect = Qt.rect(0, 0, 0, 0) + resetTextClickTracking() + clearHotkeyGesture() + O_ScreenCapture.releaseDesktopSnapshot() + root.visibility = Window.Hidden; + } + + TZSelectionOverlay { + id: selectionOverlay + coordinateItem: screenshotRect + } + + TZWindowFactory { + id: windowFactory + } + + TZSelectionPositioner { + id: selectionPositioner + } + + TZCaptureInputController { + id: inputController + } + + TZHotkeyGestureController { + id: hotkeyGesture + onLongPressTriggered: { + if (hasSelectionArea()) + tool.visible = true + } + } + + FileDialog { + id: saveDialog + title: qsTr("Select save location") + currentFolder: O_ImageSaver.savePath + fileMode: FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: { + var success = O_ImageSaver.saveImage("data:image/png;base64,"+screenShotUrl, saveDialog.selectedFile) + if (success) { + console.log("Save success:", saveDialog.currentFolder) + trayHelper.showMessage(qsTr("Save success"), qsTr("Screenshot saved to file")) + } else { + console.log("Save failed") + trayHelper.showMessage(qsTr("Save failed"), qsTr("Failed to save screenshot file")) + } + resetArea() + } + } + + TZGifRecordBar { + id: gifBar + width: 260 + height: 44 + visible: false + z: 20 + captureRect: screenshotArea + x: selectionPositioner.alignRight(localNormalizedScreenshotArea, width, mainWin.width) + y: selectionPositioner.placeBelowOrAbove(localNormalizedScreenshotArea, height, mainWin.height) + onSignalCancel: { + gifBar.visible = false + gifRecordMode = false + tool.visible = true + } + onSignalRecordFinished: { + resetArea() + } + } + + Rectangle { + id: recordingBorder + visible: O_GifRecordVM.isRecording + // Draw border outside capture rect so it won't be included in recorded frames. + x: localNormalizedScreenshotArea.x - 2 + y: localNormalizedScreenshotArea.y - 2 + width: localNormalizedScreenshotArea.width + 4 + height: localNormalizedScreenshotArea.height + 4 + color: "transparent" + border.color: "#EF4444" + border.width: 2 + z: 10 + } + + PaintBoard { + id: paintBoard + anchors.fill: parent + z: 1 + enabled: tool.activeTool !== "" && tool.activeTool !== "text" + visible: captureUiReady && screenshotArea.width !== 0 && screenshotArea.height !== 0 + shapeType: tool.shapeType + penColor: markToolInfo.currentColor + penSize: markToolInfo.currentSize + annotationText: markToolInfo.annotationText + textBackgroundEnabled: markToolInfo.textBackgroundEnabled + mosaicBlurLevel: markToolInfo.mosaicBlurLevel + numberAutoIncrement: markToolInfo.numberAutoIncrement + numberValue: markToolInfo.numberValue + } + + Connections { + target: paintBoard + function onNumberValueChanged(value) { + markToolInfo.numberValue = value + } + } + + Rectangle { + id: screenshotRect + anchors.fill: parent + color: "transparent"; + focus: true + z: 2 + visible: captureUiReady && !gifRecordMode + + TZRectMaskOverlay { + anchors.fill: parent + holeRect: localNormalizedScreenshotArea + maskColor: "#99000000" + } + + TZSelectionInfoLabel { + visible: screenshotArea.width !== 0 && screenshotArea.height !== 0 + x: selectionPositioner.alignLeft(localNormalizedScreenshotArea, width, mainWin.width) + y: selectionPositioner.placeAbove(localNormalizedScreenshotArea, height, mainWin.height, 5) + logicalRect: normalizedScreenshotArea + physicalRect: physicalScreenshotArea + } + + TZTool { + id: tool + width: 656; height: 44 + z: 30 + + // Toolbar position (local coordinates) + x: selectionPositioner.alignRight(localNormalizedScreenshotArea, width, mainWin.width) + y: selectionPositioner.placeBelowOrAbove(localNormalizedScreenshotArea, height, mainWin.height) + + onActiveToolChanged: { + if (activeTool !== "text" && textEditor.visible) { + hideTextEditor() + } + + if (activeTool === "mosaic" && !paintBoard.hasBackgroundImg()) { + Qt.callLater(function() { + O_ScreenCapture.grabToPaintBoard( + paintBoard, + Qt.rect(screenVirtualX, screenVirtualY, mainWin.width, mainWin.height) + ) + }) + } + } + + onSignalScreenshot: { + confirmSelectionToClipboard() + } + + onSignalCancel: { resetArea() } + + onSignalSave: { + root.visibility = Window.Hidden + Qt.callLater(function() { + screenShotUrl = O_ScreenCapture.captureRectToBase64(paintBoard, screenshotArea) + root.visibility = Window.Windowed + saveDialog.open() + }) + } + + onSignalSticky: { + root.visibility = Window.Hidden + Qt.callLater(function() { + var captureRect = normalizedScreenshotArea + var url = O_ScreenCapture.captureRectToStickyUrl(paintBoard, captureRect) + console.log("[Sticky] imageUrl:", url) + O_StickyViewModel.requestSticky(url, captureRect) + resetArea() + }) + } + + onSignalUndo: { paintBoard.undo() } + + onSignalGifRecord: { + gifRecordMode = true + tool.visible = false + gifBar.visible = true + Qt.callLater(function() { + O_GifRecordVM.startRecording( + screenshotArea.x, screenshotArea.y, + screenshotArea.width, screenshotArea.height + ) + }) + } + + onSignalLongScreenshot: { + var captureRect = screenshotArea + root.beginLongCapture(captureRect) + } + + onSignalOcr: { + var captureRect = screenshotArea + Qt.callLater(function() { + var img = O_ScreenCapture.captureRectToImage(captureRect) + O_OcrVM.recognize(img) + }) + } + } + + TZMarkToolInfo { + id: markToolInfo + visible: tool.visible && tool.markToolInfoVisible + z: 30 + x: tool.x + y: tool.y + tool.height + toolName: tool.activeTool + showColorRow: tool.activeTool !== "mosaic" + } + + TZTextAnnotationEditor { + id: textEditor + z: 40 + onAccepted: function(text) { submitTextEditor(text) } + onCanceled: hideTextEditor() + } + + TZResizeHandleGroup { + id: resizeHandleGroup + coordinateItem: screenshotRect + // Handle control points use local coordinates + area: localScreenshotArea + initialArea: toLocalRect(initialScreenshotArea) + z: 2 + onAreaChanged: { + // 转回绝对坐标 + screenshotArea = toAbsoluteRect(area) + } + onInitialAreaChanged: { + initialScreenshotArea = toAbsoluteRect(initialArea) + } + } + } + + TZResizeHandleGroup { + id: gifResizeHandleGroup + coordinateItem: screenshotRect + area: localScreenshotArea + initialArea: toLocalRect(initialScreenshotArea) + visible: gifRecordMode && !gifAreaLocked + enabled: !gifAreaLocked + z: 15 + onAreaChanged: { + screenshotArea = toAbsoluteRect(area) + O_GifRecordVM.updateCaptureRect( + screenshotArea.x, screenshotArea.y, + screenshotArea.width, screenshotArea.height + ) + } + onInitialAreaChanged: { + initialScreenshotArea = toAbsoluteRect(initialArea) + } + } + + // Window highlight rectangle (local coordinates) + TZWindowHighlightOverlay { + id: windowHighlight + visible: captureUiReady && screenshotArea.width === 0 && screenshotArea.height === 0 + && highlightRect.width > 0 && highlightRect.height > 0 + x: localHighlightRect.x + y: localHighlightRect.y + width: localHighlightRect.width + height: localHighlightRect.height + z: 5 + } + + TZMagnifyingGlass { + id: magnifier + width: 130 + height: 170 + z: 3 + color: "transparent" + // Hide magnifier in GIF mode to avoid recording it + visible: captureUiReady && mainWin.visible && !tool.visible && !gifRecordMode + } + + MouseArea { + id: mouseArea + anchors.fill: parent + enabled: captureUiReady + hoverEnabled: true + focus: true + cursorShape: gifRecordMode + ? (gifAreaLocked ? Qt.ArrowCursor : Qt.SizeAllCursor) + : Qt.CrossCursor + + onPressed: function(mouse) { + var globalPos = screenshotRect.mapToGlobal(mouseX, mouseY) + + var ax = globalPos.x, ay = globalPos.y + resetTextClickTracking() + + if (beginGifInteraction(ax, ay)) { + return + } + + if (beginSpaceMoveInteraction(ax, ay)) { + return + } + + if (beginTextToolInteraction(ax, ay, mouseX, mouseY)) { + return + } + + if (textEditor.visible) { + hideTextEditor() + } + beginSelectionInteraction(ax, ay) + } + + onPositionChanged: function(mouse) { + var globalPos = screenshotRect.mapToGlobal(mouseX, mouseY) + var ax = globalPos.x, ay = globalPos.y + + if (pressed && updatePendingTextInteraction(ax, ay)) { + return + } + + if (updateGifInteraction(ax, ay, pressed)) { + return + } + + if (updateSpaceMoveInteraction(ax, ay, pressed)) { + return + } + + updateMagnifierFromMouse(mouseX, mouseY, ax, ay) + updateSelectionInteraction(ax, ay, pressed, mouse.modifiers) + } + + onReleased: function(mouse) { + if (finalizeTextToolInteraction()) { + return + } + + if (finalizeGifInteraction()) { + return + } + + finalizeSelectionInteraction() + } + + onWheel: function(wheel) { wheel.accepted = true } + + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Space) { + inputController.spaceHeld = true + event.accepted = true + return + } + if (event.key === Qt.Key_Escape) { + if (handleEscAction()) + event.accepted = true + return + } + } + + Keys.onReleased: function(event) { + if (event.key === Qt.Key_Space) { + inputController.spaceHeld = false + event.accepted = true + return + } + + if (hotkeyGesture.handleKeyRelease(event.key)) { + if (!hotkeyGesture.longPressed && hasSelectionArea()) + confirmSelectionToClipboard() + clearHotkeyGesture() + event.accepted = true + } + } + } + onVisibilityChanged: + { + if(root.visibility === Window.Hidden) { + resizeHandleGroup.visible = false; + gifResizeHandleGroup.visible = false; + } + } + +} diff --git a/qml/Capture/TZWindowFactory.qml b/qml/Capture/TZWindowFactory.qml new file mode 100644 index 0000000..42ceea7 --- /dev/null +++ b/qml/Capture/TZWindowFactory.qml @@ -0,0 +1,116 @@ +import QtQuick + +QtObject { + id: factory + + property var stickyWindows: [] + property var ocrWindows: [] + property var stickyComponent: null + property var ocrComponent: null + + function createStickyWindow(imageUrl, imgRect) { + if (!imageUrl || imageUrl.length === 0) { + console.error("Failed to create sticky window: invalid image URL") + return null + } + + if (stickyComponent === null) + stickyComponent = Qt.createComponent("qrc:/qml/Windows/TZStickyWindow.qml") + + var newWindow = null + if (stickyComponent.status === Component.Ready) { + newWindow = stickyComponent.createObject(null, { + "imageUrl": imageUrl, + "imgRect": imgRect + }) + } else if (stickyComponent.status === Component.Error) { + console.error("Failed to load TZStickyWindow.qml:", stickyComponent.errorString()) + } + + if (!newWindow) { + console.error("Failed to create sticky window object") + return null + } + + newWindow.visible = true + newWindow.raise() + newWindow.requestActivate() + + var stickyList = stickyWindows.slice() + stickyList.push(newWindow) + stickyWindows = stickyList + + var windowRef = newWindow + function onWindowClosing(closeEvent) { + var idx = stickyWindows.indexOf(windowRef) + if (idx !== -1) { + var updatedList = stickyWindows.slice() + updatedList.splice(idx, 1) + stickyWindows = updatedList + } + windowRef.closing.disconnect(onWindowClosing) + windowRef = null + } + newWindow.closing.connect(onWindowClosing) + return newWindow + } + + function createOcrResultWindow() { + if (ocrWindows.length > 0) { + var existing = ocrWindows[ocrWindows.length - 1] + if (existing) { + existing.show() + existing.raise() + existing.requestActivate() + return existing + } + } + + if (ocrComponent === null) + ocrComponent = Qt.createComponent("qrc:/qml/Windows/TZOcrResult.qml") + + var win = null + if (ocrComponent.status === Component.Ready) { + win = ocrComponent.createObject(null) + } else if (ocrComponent.status === Component.Error) { + console.error("Failed to load TZOcrResult.qml:", ocrComponent.errorString()) + } + + if (!win) + return null + + win.show() + win.raise() + win.requestActivate() + + var list = ocrWindows.slice() + list.push(win) + ocrWindows = list + + var winRef = win + function onOcrClosing(closeEvent) { + var idx = ocrWindows.indexOf(winRef) + if (idx !== -1) { + var updatedList = ocrWindows.slice() + updatedList.splice(idx, 1) + ocrWindows = updatedList + } + winRef.closing.disconnect(onOcrClosing) + winRef = null + } + win.closing.connect(onOcrClosing) + return win + } + + function closeTopOcrWindow() { + if (ocrWindows.length === 0) + return false + + var topWin = ocrWindows[ocrWindows.length - 1] + if (!topWin) + return false + + topWin.close() + return true + } +} diff --git a/qml/Capture/TZWindowHighlightOverlay.qml b/qml/Capture/TZWindowHighlightOverlay.qml new file mode 100644 index 0000000..ee873d3 --- /dev/null +++ b/qml/Capture/TZWindowHighlightOverlay.qml @@ -0,0 +1,46 @@ +import QtQuick + +Rectangle { + id: highlight + + color: "transparent" + border.color: "#3B82F6" + border.width: 2 + + Rectangle { + anchors.fill: parent + color: "#153B82F6" + } + + Repeater { + model: [ + { ax: 0, ay: 0, bx: 1, by: 0, cx: 0, cy: 1 }, + { ax: parent.width, ay: 0, bx: -1, by: 0, cx: 0, cy: 1 }, + { ax: 0, ay: parent.height, bx: 1, by: 0, cx: 0, cy: -1 }, + { ax: parent.width, ay: parent.height, bx: -1, by: 0, cx: 0, cy: -1 } + ] + + delegate: Item { + x: modelData.ax - (modelData.bx > 0 ? 0 : 3) + y: modelData.ay - (modelData.cy > 0 ? 0 : 3) + + Rectangle { + x: 0 + y: 0 + width: 12 + height: 3 + color: "#3B82F6" + transform: Scale { xScale: modelData.bx } + } + + Rectangle { + x: 0 + y: 0 + width: 3 + height: 12 + color: "#3B82F6" + transform: Scale { yScale: modelData.cy } + } + } + } +} diff --git a/qml/LongCapture/TZLongCaptureBar.qml b/qml/LongCapture/TZLongCaptureBar.qml new file mode 100644 index 0000000..ab97b06 --- /dev/null +++ b/qml/LongCapture/TZLongCaptureBar.qml @@ -0,0 +1,110 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts + +Window { + id: longBar + width: 500 + height: 56 + visible: false + color: "transparent" + flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus + + signal startRequested() + signal stopRequested() + + property bool running: false + property int frameCount: 0 + property string message: qsTr("点击开始后,滑动内容自动拼接") + + Rectangle { + anchors.fill: parent + radius: 12 + color: "#111827" + border.color: "#1F2937" + border.width: 1 + + RowLayout { + anchors.fill: parent + anchors.margins: 10 + spacing: 10 + + Rectangle { + width: 26 + height: 26 + radius: 13 + color: "#F97316" + Layout.alignment: Qt.AlignVCenter + Text { + anchors.centerIn: parent + text: "长" + color: "#FFFFFF" + font.pixelSize: 14 + font.bold: true + } + } + + Text { + Layout.fillWidth: true + text: longBar.message + color: "#E5E7EB" + font.pixelSize: 13 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + + Text { + text: qsTr("%1 帧").arg(longBar.frameCount) + color: "#D1D5DB" + font.pixelSize: 12 + verticalAlignment: Text.AlignVCenter + } + + Rectangle { + width: 72 + height: 30 + radius: 6 + visible: !longBar.running + color: startArea.containsMouse ? "#1D4ED8" : "#2563EB" + Layout.alignment: Qt.AlignVCenter + Behavior on color { ColorAnimation { duration: 100 } } + Text { + anchors.centerIn: parent + text: qsTr("开始") + color: "#FFFFFF" + font.pixelSize: 13 + } + MouseArea { + id: startArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: longBar.startRequested() + } + } + + Rectangle { + width: 72 + height: 30 + radius: 6 + color: stopArea.containsMouse ? "#DC2626" : "#EF4444" + Layout.alignment: Qt.AlignVCenter + Behavior on color { ColorAnimation { duration: 100 } } + Text { + anchors.centerIn: parent + text: longBar.running ? qsTr("完成") : qsTr("关闭") + color: "#FFFFFF" + font.pixelSize: 13 + } + MouseArea { + id: stopArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: longBar.stopRequested() + } + } + } + } +} + diff --git a/qml/LongCapture/TZLongCaptureFrame.qml b/qml/LongCapture/TZLongCaptureFrame.qml new file mode 100644 index 0000000..24e2739 --- /dev/null +++ b/qml/LongCapture/TZLongCaptureFrame.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Window + +Window { + id: longFrame + visible: false + color: "transparent" + flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowTransparentForInput + + property rect captureRect: Qt.rect(0, 0, 0, 0) + + x: captureRect.x - 2 + y: captureRect.y - 2 + width: Math.max(0, captureRect.width) + 4 + height: Math.max(0, captureRect.height) + 4 + + Rectangle { + anchors.fill: parent + color: "transparent" + border.color: "#0EA5E9" + border.width: 2 + radius: 2 + + Rectangle { + x: 0 + y: 0 + width: 6 + height: 6 + color: "#0EA5E9" + } + Rectangle { + x: parent.width - width + y: 0 + width: 6 + height: 6 + color: "#0EA5E9" + } + Rectangle { + x: 0 + y: parent.height - height + width: 6 + height: 6 + color: "#0EA5E9" + } + Rectangle { + x: parent.width - width + y: parent.height - height + width: 6 + height: 6 + color: "#0EA5E9" + } + } +} diff --git a/qml/LongCapture/TZLongCapturePreview.qml b/qml/LongCapture/TZLongCapturePreview.qml new file mode 100644 index 0000000..9be4818 --- /dev/null +++ b/qml/LongCapture/TZLongCapturePreview.qml @@ -0,0 +1,83 @@ +import QtQuick +import QtQuick.Window + +Window { + id: longPreview + width: 260 + height: 420 + visible: false + color: "transparent" + flags: Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus + + property string previewUrl: "" + property string statusText: "" + property int frameCount: 0 + + Rectangle { + anchors.fill: parent + radius: 12 + color: "#111827" + border.color: "#1F2937" + border.width: 1 + + Column { + anchors.fill: parent + anchors.margins: 10 + spacing: 8 + + Row { + width: parent.width + spacing: 8 + + Text { + text: qsTr("长截图预览") + color: "#F9FAFB" + font.pixelSize: 13 + font.bold: true + } + + Text { + text: qsTr("%1 帧").arg(longPreview.frameCount) + color: "#9CA3AF" + font.pixelSize: 12 + } + } + + Rectangle { + width: parent.width + height: parent.height - 58 + radius: 8 + color: "#0B1220" + border.color: "#334155" + border.width: 1 + clip: true + + Image { + anchors.fill: parent + anchors.margins: 6 + source: longPreview.previewUrl + fillMode: Image.PreserveAspectFit + smooth: true + cache: false + } + + Text { + anchors.centerIn: parent + visible: longPreview.previewUrl.length === 0 + text: qsTr("暂无预览") + color: "#64748B" + font.pixelSize: 12 + } + } + + Text { + width: parent.width + text: longPreview.statusText + color: "#CBD5E1" + font.pixelSize: 12 + elide: Text.ElideRight + } + } + } +} + diff --git a/qml/TZMagnifyingGlass.qml b/qml/TZMagnifyingGlass.qml index dc19798..5e92039 100644 --- a/qml/TZMagnifyingGlass.qml +++ b/qml/TZMagnifyingGlass.qml @@ -13,6 +13,8 @@ Rectangle { // 跟随鼠标移动,始终显示(不随截图状态切换) property int magnifierMouseX: 0 property int magnifierMouseY: 0 + property int magnifierAbsX: 0 + property int magnifierAbsY: 0 property color magnifierPixelColor: "#000000" // 跟随鼠标,偏移 +15px,防止被遮挡;超出屏幕时翻转 @@ -49,7 +51,7 @@ Rectangle { cache: false // source 用时间戳 +坐标,强制 QML 重新加载 source: root.visibility === Window.FullScreen - ? ("image://magnifier/" + magnifierMouseX + "," + magnifierMouseY + "?t=" + Date.now()) + ? ("image://magnifier/" + magnifierAbsX + "," + magnifierAbsY + "?t=" + Date.now()) : "" fillMode: Image.Stretch } diff --git a/qml/TZStickyWindow.qml b/qml/TZStickyWindow.qml index 2843113..1a04d53 100644 --- a/qml/TZStickyWindow.qml +++ b/qml/TZStickyWindow.qml @@ -661,17 +661,10 @@ Window { } onWheel: function(wheel) { - if (wheel.modifiers & Qt.ControlModifier) { - if (wheel.angleDelta.y > 0) - setZoomValue(stickyWin.zoomFactor + 0.1) - else if (wheel.angleDelta.y < 0) - setZoomValue(stickyWin.zoomFactor - 0.1) - } else { - if (wheel.angleDelta.y > 0) - setOpacityValue(stickyWin.imgOpacity + 0.05) - else if (wheel.angleDelta.y < 0) - setOpacityValue(stickyWin.imgOpacity - 0.05) - } + if (wheel.angleDelta.y > 0) + setZoomValue(stickyWin.zoomFactor + 0.1) + else if (wheel.angleDelta.y < 0) + setZoomValue(stickyWin.zoomFactor - 0.1) wheel.accepted = true } } diff --git a/qml/TZWindow.qml b/qml/TZWindow.qml index c59a7c1..6b8a86e 100644 --- a/qml/TZWindow.qml +++ b/qml/TZWindow.qml @@ -982,6 +982,8 @@ Window { // Magnifier follows mouse in local coordinates magnifier.magnifierMouseX = mouseX magnifier.magnifierMouseY = mouseY + magnifier.magnifierAbsX = ax + magnifier.magnifierAbsY = ay handleMouseMoved(ax, ay, pressed, mouse.modifiers) } diff --git a/qml/Windows/TZChat.qml b/qml/Windows/TZChat.qml new file mode 100644 index 0000000..6833c91 --- /dev/null +++ b/qml/Windows/TZChat.qml @@ -0,0 +1,265 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Layouts +import QtQuick.Controls.Basic + +QtObject { + id: chatPanel + + // 对应贴图的图片 URL,由外部绑定 + property string imageUrl: "" + + signal signalSendPrompt(var text) + + // 公开方法 + function open(px, py) { + win.x = px + win.y = py + win.visible = true + win.raise() + win.requestActivate() + } + + function close() { + win.visible = false + } + + // ── 内部 Window ───────────────────────────────────── + property var win: Window { + id: win + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + width: 420 + height: 380 + visible: false + + Rectangle { + anchors.fill: parent + color: "#ffffff" + radius: 10 + border.color: "#E2E8F0" + border.width: 1 + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + // ── 顶部标题栏 ──────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 36 + color: "transparent" + + // 拖动区域(z:-1 让关闭按钮的点击不被拦截) + MouseArea { + id: dragHandle + anchors.fill: parent + z: -1 + property point startPos + + onPressed: startPos = Qt.point(mouseX, mouseY) + onPositionChanged: { + if (pressed) { + win.x += mouseX - startPos.x + win.y += mouseY - startPos.y + } + } + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + } + + RowLayout { + anchors.fill: parent + spacing: 8 + + Image { + source: "qrc:/resource/img/lc_chat.svg" + width: 20 + height: 20 + fillMode: Image.PreserveAspectFit + } + + Text { + text: qsTr("AI 图像编辑") + font.pixelSize: 14 + font.weight: Font.Medium + color: "#1E293B" + } + + Item { Layout.fillWidth: true } + + // 关闭按钮 + Rectangle { + width: 28 + height: 28 + radius: 6 + color: clearMouse.containsMouse ? "#FEE2E2" : "transparent" + + Image { + anchors.centerIn: parent + source: "qrc:/resource/img/lc_close.svg" + width: 14 + height: 14 + fillMode: Image.PreserveAspectFit + } + + MouseArea { + id: clearMouse + anchors.fill: parent + hoverEnabled: true + onClicked: chatPanel.close() + } + } + } + + // 标题栏底部分割线 + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 1 + color: "#E2E8F0" + } + } + + // ── 贴图缩略图预览 ──────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 64 + radius: 6 + color: "#F1F5F9" + border.color: "#E2E8F0" + border.width: 1 + clip: true + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 10 + + // 缩略图 + Rectangle { + width: 48 + height: 48 + radius: 4 + color: "#E2E8F0" + clip: true + + Image { + anchors.fill: parent + source: chatPanel.imageUrl + fillMode: Image.PreserveAspectCrop + cache: false + } + } + + // 文字说明 + Column { + Layout.fillWidth: true + spacing: 3 + + Text { + text: qsTr("当前贴图") + font.pixelSize: 11 + color: "#94A3B8" + } + + Text { + width: parent.width + text: chatPanel.imageUrl !== "" + ? chatPanel.imageUrl.replace(/^image:\/\/[^/]+\//, "ID: ") + : qsTr("未关联贴图") + font.pixelSize: 12 + color: "#475569" + elide: Text.ElideRight + } + + Text { + text: qsTr("发送后将对此图执行 AI 编辑") + font.pixelSize: 10 + color: "#94A3B8" + } + } + } + } + + // ── 输入区域 ────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "#F8FAFC" + radius: 8 + border.color: "#E2E8F0" + border.width: 1 + + TextArea { + id: input + anchors.fill: parent + anchors.margins: 12 + font.family: "Segoe UI" + font.pixelSize: 13 + color: "#1E293B" + wrapMode: Text.Wrap + placeholderText: qsTr("描述您想要的修改...") + background: Rectangle { color: "transparent" } + selectByMouse: true + focus: true + } + } + + // ── 底部工具栏 ──────────────────────────────────── + Rectangle { + Layout.fillWidth: true + height: 40 + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 8 + + Item { Layout.fillWidth: true } + + // 取消按钮 + Rectangle { + width: 70; height: 32; radius: 6 + color: cancelMouse.containsMouse ? "#F1F5F9" : "#F8FAFC" + border.color: "#E2E8F0"; border.width: 1 + + Text { anchors.centerIn: parent; text: qsTr("取消"); font.pixelSize: 13; color: "#64748B" } + + MouseArea { + id: cancelMouse + anchors.fill: parent; hoverEnabled: true + onClicked: chatPanel.close() + } + } + + // 发送按钮 + Rectangle { + width: 70; height: 32; radius: 6 + color: sendMouse.pressed ? "#0066cc" : (sendMouse.containsMouse ? "#3399ff" : "#0088ff") + + RowLayout { + anchors.centerIn: parent; spacing: 4 + Image { source: "qrc:/resource/img/lc_send.svg"; width: 14; height: 14 } + Text { text: qsTr("发送"); font.pixelSize: 13; color: "white" } + } + + MouseArea { + id: sendMouse + anchors.fill: parent; hoverEnabled: true + onClicked: { + if (input.text.trim() !== "") { + chatPanel.signalSendPrompt(input.text) + chatPanel.close() + } + } + } + } + } + } + } + } + } +} + diff --git a/qml/Windows/TZLoadingSpinner.qml b/qml/Windows/TZLoadingSpinner.qml new file mode 100644 index 0000000..e4df73f --- /dev/null +++ b/qml/Windows/TZLoadingSpinner.qml @@ -0,0 +1,52 @@ +import QtQuick + +// ── AI 加载动画层 ───────────────────────────────────── +Rectangle { + + width: 100 + height: 100 + radius: 2 + color: "#99000000" // 半透明黑色遮罩 + + + // 旋转圆弧 + Item { + id: spinnerRoot + anchors.centerIn: parent + width: 48 + height: 48 + + RotationAnimator { + target: spinnerRoot + from: 0; to: 360 + duration: 900 + loops: Animation.Infinite + running: O_AIViewModel.isLoading + } + + // 四个小圆点模拟旋转圆弧 + Repeater { + model: 8 + Rectangle { + width: 6 + height: 6 + radius: 3 + color: "white" + opacity: (index + 1) / 8 + x: spinnerRoot.width / 2 + 18 * Math.cos(index * Math.PI / 4) - width / 2 + y: spinnerRoot.height / 2 + 18 * Math.sin(index * Math.PI / 4) - height / 2 + } + } + } + + // 提示文字 + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: spinnerRoot.bottom + anchors.topMargin: 10 + text: "AI 生成中..." + color: "white" + font.pixelSize: 13 + } +} + diff --git a/qml/Windows/TZOcrResult.qml b/qml/Windows/TZOcrResult.qml new file mode 100644 index 0000000..624766b --- /dev/null +++ b/qml/Windows/TZOcrResult.qml @@ -0,0 +1,328 @@ +import QtQuick +import QtQuick.Window +import QtQuick.Controls +import QtQuick.Layouts + +// TZOcrResult +// ----------- +// 无边框悬浮窗,展示 OCR 识别结果。 +// 由 TZWindow.qml 通过 Qt.createComponent 动态创建并显示。 + +Window { + id: ocrWin + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + width: 420 + height: 320 + + // 初始居中(创建后由调用方设置位置,此处给默认值) + x: (Screen.width - width) / 2 + y: (Screen.height - height) / 2 + + Component.onCompleted: { + ocrWin.raise() + ocrWin.requestActivate() + } + + Shortcut { + sequence: "Esc" + onActivated: ocrWin.close() + } + + // ── 阴影容器 ────────────────────────────────────────── + Rectangle { + id: shadow + anchors.fill: parent + anchors.margins: 10 + radius: 12 + color: "white" + layer.enabled: true + border.color: "#D1D5DB" + border.width: 1 + + // ── 标题栏 ───────────────────────────────────────── + Rectangle { + id: titleBar + anchors.top: shadow.top + anchors.left: shadow.left + anchors.right: shadow.right + height: 40 + radius: 12 + color: "#F8FAFC" + + // 圆角只在上半部分(下方盖住) + Rectangle { + anchors.bottom: titleBar.bottom + anchors.left: titleBar.left + anchors.right: titleBar.right + height: titleBar.radius + color: titleBar.color + } + + // 标题文字 + Text { + anchors.verticalCenter: titleBar.verticalCenter + anchors.left: titleBar.left + anchors.leftMargin: 14 + text: qsTr("OCR 识别结果") + font.pixelSize: 14 + font.weight: Font.Medium + color: "#1E293B" + } + + // 关闭按钮 + Rectangle { + id: closeBtn + anchors.right: titleBar.right + anchors.rightMargin: 8 + anchors.verticalCenter: titleBar.verticalCenter + width: 26; height: 26; radius: 5 + color: closeArea.containsMouse ? "#FEE2E2" : "transparent" + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: "✕" + color: closeArea.containsMouse ? "#DC2626" : "#94A3B8" + font.pixelSize: 13 + } + MouseArea { + id: closeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ocrWin.close() + } + } + + // 拖动标题栏移动窗口 + MouseArea { + anchors.fill: titleBar + anchors.rightMargin: 40 + property point pressPos + onPressed: function(mouse) { + pressPos = Qt.point(mouse.x, mouse.y) + } + onPositionChanged: function(mouse) { + if (pressed) { + ocrWin.x += mouse.x - pressPos.x + ocrWin.y += mouse.y - pressPos.y + } + } + } + } + + // ── 分割线 ───────────────────────────────────────── + Rectangle { + id: divider + anchors.top: titleBar.bottom + anchors.left: shadow.left + anchors.right: shadow.right + height: 1 + color: "#E2E8F0" + } + + // ── 内容区 ───────────────────────────────────────── + Item { + anchors.top: divider.bottom + anchors.left: shadow.left + anchors.right: shadow.right + anchors.bottom: bottomBar.top + + // Loading 遮罩 + Rectangle { + anchors.fill: parent + color: "#F8FAFC" + visible: O_OcrVM.isRecognizing + radius: 6 + + Item { + id: spinnerRoot + anchors.centerIn: parent + width: 48; height: 48 + + RotationAnimator { + target: spinnerRoot + from: 0; to: 360 + duration: 900 + loops: Animation.Infinite + running: O_OcrVM.isRecognizing + } + Repeater { + model: 8 + Rectangle { + width: 6; height: 6; radius: 3 + color: "#3B82F6" + opacity: (index + 1) / 8 + x: spinnerRoot.width / 2 + 18 * Math.cos(index * Math.PI / 4) - width / 2 + y: spinnerRoot.height / 2 + 18 * Math.sin(index * Math.PI / 4) - height / 2 + } + } + } + + Text { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: spinnerRoot.bottom + anchors.topMargin: 12 + text: qsTr("正在识别中…") + color: "#64748B" + font.pixelSize: 13 + } + } + + // 文本区域(识别完成后显示) + ScrollView { + anchors.fill: parent + anchors.margins: 8 + visible: !O_OcrVM.isRecognizing + clip: true + + TextArea { + id: resultArea + text: O_OcrVM.resultText + readOnly: false // 允许用户二次编辑 + wrapMode: TextEdit.Wrap + font.pixelSize: 14 + color: "#1E293B" + background: null + padding: 4 + selectByMouse: true + placeholderText: qsTr("识别结果将显示在这里…") + placeholderTextColor: "#94A3B8" + } + } + } + + // ── 底部操作栏 ───────────────────────────────────── + Rectangle { + id: bottomBar + anchors.bottom: shadow.bottom + anchors.left: shadow.left + anchors.right: shadow.right + height: 44 + radius: 12 + color: "#F8FAFC" + + // 圆角只在下半部分 + Rectangle { + anchors.top: bottomBar.top + anchors.left: bottomBar.left + anchors.right: bottomBar.right + height: bottomBar.radius + color: bottomBar.color + } + + // 分割线 + Rectangle { + anchors.top: bottomBar.top + anchors.left: bottomBar.left + anchors.right: bottomBar.right + height: 1 + color: "#E2E8F0" + } + + Row { + anchors.centerIn: parent + spacing: 10 + + // 一键复制 + Rectangle { + id: copyBtn + width: 100; height: 30; radius: 6 + color: copyArea.containsMouse ? "#DBEAFE" : "#EFF6FF" + border.color: "#3B82F6" + border.width: 1 + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: qsTr("复制全部") + color: "#3B82F6" + font.pixelSize: 13 + } + MouseArea { + id: copyArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + O_OcrVM.copyResultToClipboard() + copyFeedbackTimer.start() + } + } + } + + // 关闭 + Rectangle { + width: 70; height: 30; radius: 6 + color: closeBtn2Area.containsMouse ? "#F1F5F9" : "transparent" + border.color: "#CBD5E1" + border.width: 1 + Behavior on color { ColorAnimation { duration: 80 } } + + Text { + anchors.centerIn: parent + text: qsTr("关闭") + color: "#64748B" + font.pixelSize: 13 + } + MouseArea { + id: closeBtn2Area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: ocrWin.close() + } + } + } + } + + // 复制成功短暂提示 + Rectangle { + id: copyFeedback + anchors.horizontalCenter: shadow.horizontalCenter + anchors.bottom: bottomBar.top + anchors.bottomMargin: 6 + width: 100; height: 28; radius: 6 + color: "#22C55E" + visible: false + opacity: 0 + + Text { + anchors.centerIn: parent + text: qsTr("已复制!") + color: "white" + font.pixelSize: 12 + } + + Timer { + id: copyFeedbackTimer + interval: 1200 + onTriggered: copyFeedback.visible = false + } + + onVisibleChanged: { + if (visible) opacity = 1 + } + + Behavior on opacity { + NumberAnimation { duration: 300 } + } + } + + Connections { + target: copyFeedbackTimer + function onTriggered() { + copyFeedback.opacity = 0 + } + } + } + + // 启动计时后立即显示反馈 + Connections { + target: copyArea + function onClicked() { + copyFeedback.visible = true + } + } +} diff --git a/qml/Windows/TZStickyExtraToolBar.qml b/qml/Windows/TZStickyExtraToolBar.qml new file mode 100644 index 0000000..cef7b88 --- /dev/null +++ b/qml/Windows/TZStickyExtraToolBar.qml @@ -0,0 +1,40 @@ +import QtQuick + +Rectangle { + id: bar + radius: 10 + color: "#FFFFFF" + border.color: "#E2E8F0" + border.width: 1 + + signal zoomOut() + signal zoomIn() + signal rotate90() + signal mirror() + signal resetSize() + signal doneAndCopy() + signal hideToolbar() + signal hoverEntered(string text, Item targetItem) + signal hoverExited(string text) + + property real zoomFactor: 1.0 + + width: extraRow.implicitWidth + 16 + height: 40 + + Row { + id: extraRow + anchors.centerIn: parent + spacing: 6 + + Rectangle { width: 30; height: 30; radius: 7; color: zoomOutArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "-"; color: "#334155"; font.pixelSize: 16 } MouseArea { id: zoomOutArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Zoom out"), parent); onExited: bar.hoverExited(qsTr("Zoom out")); onPressed: bar.zoomOut() } } + Rectangle { width: 40; height: 30; radius: 7; color: "transparent"; Text { anchors.centerIn: parent; text: Math.round(bar.zoomFactor * 100) + "%"; color: "#64748B"; font.pixelSize: 11 } } + Rectangle { width: 30; height: 30; radius: 7; color: zoomInArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "+"; color: "#334155"; font.pixelSize: 16 } MouseArea { id: zoomInArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Zoom in"), parent); onExited: bar.hoverExited(qsTr("Zoom in")); onPressed: bar.zoomIn() } } + Rectangle { width: 30; height: 30; radius: 7; color: rotateArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "↻"; color: "#334155"; font.pixelSize: 14 } MouseArea { id: rotateArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Rotate 90°"), parent); onExited: bar.hoverExited(qsTr("Rotate 90°")); onPressed: bar.rotate90() } } + Rectangle { width: 30; height: 30; radius: 7; color: mirrorArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "⇋"; color: "#334155"; font.pixelSize: 14 } MouseArea { id: mirrorArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Mirror"), parent); onExited: bar.hoverExited(qsTr("Mirror")); onPressed: bar.mirror() } } + Rectangle { width: 30; height: 30; radius: 7; color: resetSizeArea.containsMouse ? "#F1F5F9" : "transparent"; Text { anchors.centerIn: parent; text: "1:1"; color: "#334155"; font.pixelSize: 10 } MouseArea { id: resetSizeArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Reset size"), parent); onExited: bar.hoverExited(qsTr("Reset size")); onPressed: bar.resetSize() } } + Rectangle { width: 1; height: 18; color: "#E2E8F0"; anchors.verticalCenter: parent.verticalCenter } + Rectangle { width: 30; height: 30; radius: 7; color: overwriteArea.containsMouse ? "#E0F2FE" : "transparent"; Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_check.svg"; width: 18; height: 18 } MouseArea { id: overwriteArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Done and copy"), parent); onExited: bar.hoverExited(qsTr("Done and copy")); onPressed: bar.doneAndCopy() } } + Rectangle { width: 30; height: 30; radius: 7; color: clearToolArea.containsMouse ? "#FEE2E2" : "transparent"; Image { anchors.centerIn: parent; source: "qrc:/resource/img/lc_x.svg"; width: 18; height: 18 } MouseArea { id: clearToolArea; anchors.fill: parent; hoverEnabled: true; cursorShape: Qt.PointingHandCursor; onEntered: bar.hoverEntered(qsTr("Hide toolbar"), parent); onExited: bar.hoverExited(qsTr("Hide toolbar")); onPressed: bar.hideToolbar() } } + } +} diff --git a/qml/Windows/TZStickyWindow.qml b/qml/Windows/TZStickyWindow.qml new file mode 100644 index 0000000..104c232 --- /dev/null +++ b/qml/Windows/TZStickyWindow.qml @@ -0,0 +1,640 @@ +import QtQuick +import QtQuick.Window +import Qt.labs.platform 1.0 as Platform +import QtQuick.Effects +import QtQuick.Controls.Basic +import QtCore +import CustomComponents 1.0 +import "../Capture" + +Window { + id: stickyWin + flags: Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool + color: "transparent" + + property string imageUrl: "" + property rect imgRect: Qt.rect(0, 0, 0, 0) + property bool contentActive: true + property bool cleanedUp: false + + property real imgOpacity: 1.0 + property string activeTool: "" + property bool toolbarVisible: true + property string toolbarHoverTipText: "" + property real toolbarHoverTipCenterX: 0 + property bool contextMenuOpen: false + + property int imageRevision: 0 + property int sourcePixelW: 0 + property int sourcePixelH: 0 + property real currentDpr: (screen && screen.devicePixelRatio > 0) ? screen.devicePixelRatio : 1.0 + property int imageBaseW: Math.max(1, Math.abs(imgRect.width)) + property int imageBaseH: Math.max(1, Math.abs(imgRect.height)) + property real zoomFactor: 1.0 + + readonly property int toolbarHeight: 40 + readonly property int toolbarGap: 8 + readonly property int toolbarTipGap: 6 + readonly property int toolbarTipHeight: 24 + readonly property int shadowRadius: 12 + readonly property int shadowOffset: 4 + readonly property int imageTopLeftX: (imgRect.width >= 0 ? imgRect.x : imgRect.x + imgRect.width) + readonly property int imageTopLeftY: (imgRect.height >= 0 ? imgRect.y : imgRect.y + imgRect.height) + property var targetScreen: null + + readonly property int displayW: Math.max(1, Math.round(imageBaseW * zoomFactor)) + readonly property int displayH: Math.max(1, Math.round(imageBaseH * zoomFactor)) + readonly property int extraToolbarWidth: stickyExtraBar.width + readonly property int toolbarContentWidth: stickyTool.implicitWidth + extraToolbarWidth + 6 + readonly property int toolbarBlockX: Math.max(8, Math.min(stickyWin.width - toolbarContentWidth - 8, (stickyWin.displayW - toolbarContentWidth) / 2)) + + readonly property int stickyShapeType: { + switch (activeTool) { + case "pencil": return 0 + case "rect": return 1 + case "circle": return 2 + case "arrow": return 3 + case "mosaic": return 4 + default: return 1 + } + } + + width: Math.max(displayW, toolbarContentWidth + 16) + height: displayH + toolbarGap + toolbarHeight + toolbarTipGap + toolbarTipHeight + 4 + function findScreenForPoint(px, py) { + var screens = Qt.application.screens + for (var i = 0; i < screens.length; ++i) { + var s = screens[i] + if (!s || !s.geometry) + continue + var g = s.geometry + if (px >= g.x && px < g.x + g.width && py >= g.y && py < g.y + g.height) + return s + } + return null + } + + function syncInitialPosition() { + O_StickyViewModel.positionStickyWindow(stickyWin, imgRect) + } + + Settings { + id: menuPrefs + category: "sticky_menu" + property bool pinSaveMerged: true + property bool pinCopy: true + property bool pinAi: false + property bool pinToolbar: false + property bool pinClose: false + } + + function setActiveTool(name) { + if (stickyTool.activeTool === name) + stickyTool.activeTool = "" + else + stickyTool.activeTool = name + } + + function showToolbarTip(text, item) { + toolbarHoverTipText = text + if (item) { + var p = stickyExtraBar.mapFromItem(item, item.width / 2, item.height) + toolbarHoverTipCenterX = p.x + } else { + toolbarHoverTipCenterX = stickyExtraBar.width / 2 + } + } + + function hideToolbarTip(text) { + if (text === undefined || toolbarHoverTipText === text) + toolbarHoverTipText = "" + } + + function displaySourceUrl() { + if (typeof stickyWin.imageUrl !== "string" || stickyWin.imageUrl.length === 0) + return "" + return stickyWin.imageUrl + "?rev=" + stickyWin.imageRevision + } + + function setOpacityValue(v) { + stickyWin.imgOpacity = Math.max(0.1, Math.min(1.0, v)) + } + + function setZoomValue(v) { + zoomFactor = Math.max(0.3, Math.min(4.0, v)) + } + + function resetDisplaySize() { + zoomFactor = 1.0 + } + + function refreshDisplayMetrics() { + currentDpr = (screen && screen.devicePixelRatio > 0) ? screen.devicePixelRatio : 1.0 + if (sourcePixelW > 0 && sourcePixelH > 0) { + imageBaseW = Math.max(1, Math.round(sourcePixelW / currentDpr)) + imageBaseH = Math.max(1, Math.round(sourcePixelH / currentDpr)) + } else { + imageBaseW = Math.max(1, Math.abs(imgRect.width)) + imageBaseH = Math.max(1, Math.abs(imgRect.height)) + } + } + + function updateImageMetricsFromStore() { + var img = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + if (img && img.width > 0 && img.height > 0) { + sourcePixelW = img.width + sourcePixelH = img.height + refreshDisplayMetrics() + stickyPaintBoard.setBackgroundImg(img) + } else { + sourcePixelW = 0 + sourcePixelH = 0 + refreshDisplayMetrics() + syncPaintboardBackgroundFromDisplay() + } + } + + function syncPaintboardBackgroundFromDisplay() { + if (!stickyWin.visible || !stickyWin.contentActive) + return + if (stickyImage.status !== Image.Ready) + return + stickyImage.grabToImage(function(result) { + if (!result || !result.image || result.image.width <= 0 || result.image.height <= 0) + return + stickyPaintBoard.setBackgroundImg(result.image) + }, Qt.size(Math.max(1, imageBaseW), Math.max(1, imageBaseH))) + } + + function refreshAfterImageChanged() { + imageRevision += 1 + stickyPaintBoard.reset() + updateImageMetricsFromStore() + } + + function overwriteWithEdits() { + var layer = stickyPaintBoard.renderToImage() + var ok = O_StickyViewModel.overwriteWithAnnotations(stickyWin.imageUrl, layer) + if (ok) + refreshAfterImageChanged() + } + + function rotateCurrent(degrees) { + if (O_StickyViewModel.rotateImage(stickyWin.imageUrl, degrees)) + refreshAfterImageChanged() + } + + function mirrorCurrent() { + if (O_StickyViewModel.mirrorImage(stickyWin.imageUrl, true, false)) + refreshAfterImageChanged() + } + + function saveMergedTo(pathUrl) { + var layer = stickyPaintBoard.renderToImage() + O_StickyViewModel.saveMergedImage(stickyWin.imageUrl, layer, pathUrl) + } + + function completeEditsAndCopy() { + var layer = stickyPaintBoard.renderToImage() + if (layer && layer.width > 0 && layer.height > 0) + overwriteWithEdits() + O_StickyViewModel.copyImageToClipboard(stickyWin.imageUrl) + stickyTool.activeTool = "" + stickyWin.activeTool = "" + stickyWin.toolbarVisible = false + } + + function cleanup() { + if (cleanedUp) + return + cleanedUp = true + + stickyWin.visible = false + contentActive = false + stickyWin.releaseResources() + O_StickyViewModel.releaseImage(imageUrl) + imageUrl = "" + } + + Component.onCompleted: { + refreshDisplayMetrics() + syncInitialPosition() + console.log("[StickyWindow] imgRect:", imgRect.x, imgRect.y, imgRect.width, imgRect.height) + console.log("[StickyWindow] imageTopLeft:", imageTopLeftX, imageTopLeftY) + console.log("[StickyWindow] windowPos:", x, y, "display:", displayW, displayH) + updateImageMetricsFromStore() + stickyWin.raise() + stickyWin.requestActivate() + } + + Shortcut { + sequence: "Esc" + onActivated: { + if (stickyWin.activeTool !== "") { + stickyTool.activeTool = "" + stickyWin.activeTool = "" + return + } + if (stickyWin.toolbarVisible) { + stickyWin.toolbarVisible = false + return + } + stickyWin.close() + } + } + + onImageUrlChanged: { + imageRevision = 0 + resetDisplaySize() + sourcePixelW = 0 + sourcePixelH = 0 + refreshDisplayMetrics() + refreshAfterImageChanged() + } + + onActiveToolChanged: { + if (activeTool === "mosaic") + updateImageMetricsFromStore() + } + + onImgRectChanged: { + refreshDisplayMetrics() + syncInitialPosition() + } + + onScreenChanged: refreshDisplayMetrics() + + Component.onDestruction: cleanup() + + onClosing: function(close) { + close.accepted = true + chatPanel.close() + cleanup() + Qt.callLater(function() { stickyWin.destroy() }) + } + + Rectangle { + id: imgContainer + visible: stickyWin.contentActive + z: 10 + x: 0 + y: 0 + width: stickyWin.displayW + height: stickyWin.displayH + radius: 2 + clip: true + color: "transparent" + + Item { + id: visualStage + anchors.centerIn: parent + width: stickyWin.imageBaseW + height: stickyWin.imageBaseH + scale: stickyWin.zoomFactor + + Image { + id: stickyImage + anchors.fill: parent + source: displaySourceUrl() + fillMode: Image.Stretch + opacity: stickyWin.imgOpacity + cache: false + onStatusChanged: { + if (status === Image.Ready && stickyWin.activeTool === "mosaic" && !stickyPaintBoard.hasBackgroundImg()) + stickyWin.syncPaintboardBackgroundFromDisplay() + } + } + + PaintBoard { + id: stickyPaintBoard + anchors.fill: parent + z: 20 + visible: stickyWin.contentActive + enabled: stickyWin.activeTool !== "" + shapeType: stickyWin.stickyShapeType + penColor: stickyMarkTool.currentColor + penSize: stickyMarkTool.currentSize + mosaicBlurLevel: stickyMarkTool.mosaicBlurLevel + } + } + } + + MultiEffect { + visible: stickyWin.contentActive + z: 5 + source: stickyImage + anchors.fill: imgContainer + shadowEnabled: true + shadowColor: "#55000000" + shadowBlur: stickyWin.shadowRadius / 20.0 + shadowVerticalOffset: stickyWin.shadowOffset + shadowHorizontalOffset: 0 + opacity: stickyWin.imgOpacity + } + + TZLoadingSpinner { + anchors.centerIn: imgContainer + width: stickyWin.displayW + height: stickyWin.displayH + visible: O_AIViewModel.isLoading && stickyWin.contentActive + } + + TZMarkToolInfo { + id: stickyMarkTool + z: 32 + visible: stickyWin.toolbarVisible && stickyWin.activeTool !== "" + toolName: stickyWin.activeTool + showColorRow: stickyWin.activeTool !== "mosaic" + x: Math.max(8, toolbarBlockX) + y: stickyTool.y - height - 6 + } + + TZTool { + id: stickyTool + z: 30 + visible: stickyWin.toolbarVisible + width: implicitWidth + height: stickyWin.toolbarHeight + 4 + x: stickyWin.toolbarBlockX + y: stickyWin.displayH + stickyWin.toolbarGap + showScreenshotButton: false + showCancelButton: false + showSaveButton: false + showStickyButton: false + showGifRecordButton: false + showLongScreenshotButton: false + showHighlightButton: false + showTextButton: false + showNumberButton: false + + onActiveToolChanged: { + stickyWin.activeTool = activeTool + if (activeTool === "mosaic" && !stickyPaintBoard.hasBackgroundImg()) { + Qt.callLater(function() { + var retryImg = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + if (retryImg && retryImg.width > 0 && retryImg.height > 0) + stickyPaintBoard.setBackgroundImg(retryImg) + if (!stickyPaintBoard.hasBackgroundImg()) + stickyWin.syncPaintboardBackgroundFromDisplay() + }) + } + } + onSignalOcr: { + var img = O_StickyViewModel.getImageByUrl(stickyWin.imageUrl) + O_OcrVM.recognize(img) + } + onSignalUndo: stickyPaintBoard.undo() + } + + TZStickyExtraToolBar { + id: stickyExtraBar + z: 30 + visible: stickyWin.toolbarVisible + height: stickyWin.toolbarHeight + x: stickyWin.toolbarBlockX + stickyTool.width + 6 + y: stickyTool.y + 2 + zoomFactor: stickyWin.zoomFactor + onZoomOut: setZoomValue(stickyWin.zoomFactor - 0.1) + onZoomIn: setZoomValue(stickyWin.zoomFactor + 0.1) + onRotate90: rotateCurrent(90) + onMirror: mirrorCurrent() + onResetSize: resetDisplaySize() + onDoneAndCopy: completeEditsAndCopy() + onHideToolbar: { + stickyTool.activeTool = "" + stickyWin.activeTool = "" + stickyWin.toolbarVisible = false + } + onHoverEntered: function(text, targetItem) { stickyWin.showToolbarTip(text, targetItem) } + onHoverExited: function(text) { stickyWin.hideToolbarTip(text) } + } + + Rectangle { + id: stickyToolbarTip + parent: stickyWin.contentItem + visible: stickyWin.toolbarVisible && stickyWin.toolbarHoverTipText.length > 0 + z: 40 + width: Math.min(stickyWin.width - 8, Math.max(64, stickyToolbarTipText.implicitWidth + 16)) + height: 24 + radius: 6 + color: "#0F172A" + opacity: 0.92 + x: Math.max(4, Math.min(stickyWin.width - width - 4, stickyExtraBar.x + stickyWin.toolbarHoverTipCenterX - width / 2)) + y: Math.min(stickyWin.height - height - 4, stickyExtraBar.y + stickyExtraBar.height + stickyWin.toolbarTipGap) + + Text { + id: stickyToolbarTipText + anchors.centerIn: parent + text: stickyWin.toolbarHoverTipText + color: "#FFFFFF" + font.pixelSize: 11 + } + } + + readonly property int chatPanelGap: 6 + + TZChat { + id: chatPanel + imageUrl: stickyWin.imageUrl + onSignalSendPrompt: function(text) { + O_AIViewModel.sendPrompt(text, stickyWin.imageUrl) + } + } + + Connections { + target: O_AIViewModel + function onSignalRequestComplete(oldImageUrl, newImageUrl) { + if (oldImageUrl !== stickyWin.imageUrl) + return + var old = oldImageUrl + stickyWin.imageUrl = newImageUrl + Qt.callLater(function() { O_StickyViewModel.releaseImage(old) }) + } + function onSignalRequestFailed(errorMsg) { + console.warn("[TZStickyWindow] AI request failed: " + errorMsg) + } + } + + function openChatPanel() { + var imgLeft = stickyWin.x + var imgTop = stickyWin.y + var imgBottom = imgTop + stickyWin.displayH + + var screenH = Screen.desktopAvailableHeight + var screenW = Screen.desktopAvailableWidth + + var cx = imgLeft + if (cx + chatPanel.win.width > screenW) + cx = imgLeft + stickyWin.displayW - chatPanel.win.width + + var cy + if (imgBottom + chatPanelGap + chatPanel.win.height <= screenH) + cy = imgBottom + chatPanelGap + else + cy = imgTop - chatPanelGap - chatPanel.win.height + + chatPanel.open(Math.max(0, cx), Math.max(0, cy)) + } + + Platform.Menu { + id: contextMenu + onAboutToShow: stickyWin.contextMenuOpen = true + onAboutToHide: stickyWin.contextMenuOpen = false + + Platform.MenuItem { text: qsTr("当前透明度 %1%").arg(Math.round(stickyWin.imgOpacity * 100)); enabled: false } + Platform.MenuSeparator {} + + Platform.MenuItem { visible: menuPrefs.pinSaveMerged; text: qsTr("覆盖贴图(合并标注)"); onTriggered: overwriteWithEdits() } + Platform.MenuItem { visible: menuPrefs.pinCopy; text: qsTr("复制贴图"); onTriggered: O_StickyViewModel.copyImageToClipboard(imageUrl) } + Platform.MenuItem { visible: menuPrefs.pinAi; text: qsTr("AI 编辑"); onTriggered: openChatPanel() } + Platform.MenuItem { + visible: menuPrefs.pinToolbar + text: stickyWin.toolbarVisible ? qsTr("隐藏工具栏") : qsTr("显示工具栏") + onTriggered: { + stickyWin.toolbarVisible = !stickyWin.toolbarVisible + if (!stickyWin.toolbarVisible) { + stickyTool.activeTool = "" + stickyWin.activeTool = "" + } + } + } + Platform.MenuItem { visible: menuPrefs.pinClose; text: qsTr("关闭窗口"); onTriggered: stickyWin.close() } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("透明度 +5%"); onTriggered: setOpacityValue(stickyWin.imgOpacity + 0.05) } + Platform.MenuItem { text: qsTr("透明度 -5%"); onTriggered: setOpacityValue(stickyWin.imgOpacity - 0.05) } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("缩小"); onTriggered: setZoomValue(stickyWin.zoomFactor - 0.1) } + Platform.MenuItem { text: qsTr("放大"); onTriggered: setZoomValue(stickyWin.zoomFactor + 0.1) } + Platform.MenuItem { text: qsTr("旋转 90°"); onTriggered: rotateCurrent(90) } + Platform.MenuItem { text: qsTr("镜像"); onTriggered: mirrorCurrent() } + Platform.MenuItem { text: qsTr("恢复原始大小"); onTriggered: resetDisplaySize() } + + Platform.MenuSeparator {} + + Platform.MenuItem { + text: qsTr("重置位置") + onTriggered: { + syncInitialPosition() + } + } + + Platform.MenuSeparator {} + + Platform.MenuItem { + text: stickyWin.toolbarVisible ? qsTr("隐藏工具栏") : qsTr("显示工具栏") + onTriggered: { + stickyWin.toolbarVisible = !stickyWin.toolbarVisible + if (!stickyWin.toolbarVisible) { + stickyTool.activeTool = "" + stickyWin.activeTool = "" + } + } + } + + Platform.MenuItem { text: qsTr("AI 编辑"); onTriggered: openChatPanel() } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("保存原图"); onTriggered: saveDialog.open() } + Platform.MenuItem { text: qsTr("另存为(合并标注)"); onTriggered: mergedSaveDialog.open() } + Platform.MenuItem { text: qsTr("覆盖贴图(合并标注)"); onTriggered: overwriteWithEdits() } + Platform.MenuItem { text: qsTr("复制贴图"); onTriggered: O_StickyViewModel.copyImageToClipboard(imageUrl) } + + Platform.MenuSeparator {} + + Platform.Menu { + title: qsTr("常用项置顶") + Platform.MenuItem { text: qsTr("覆盖贴图"); checkable: true; checked: menuPrefs.pinSaveMerged; onTriggered: menuPrefs.pinSaveMerged = !menuPrefs.pinSaveMerged } + Platform.MenuItem { text: qsTr("复制贴图"); checkable: true; checked: menuPrefs.pinCopy; onTriggered: menuPrefs.pinCopy = !menuPrefs.pinCopy } + Platform.MenuItem { text: qsTr("AI 编辑"); checkable: true; checked: menuPrefs.pinAi; onTriggered: menuPrefs.pinAi = !menuPrefs.pinAi } + Platform.MenuItem { text: qsTr("显示/隐藏工具栏"); checkable: true; checked: menuPrefs.pinToolbar; onTriggered: menuPrefs.pinToolbar = !menuPrefs.pinToolbar } + Platform.MenuItem { text: qsTr("关闭窗口"); checkable: true; checked: menuPrefs.pinClose; onTriggered: menuPrefs.pinClose = !menuPrefs.pinClose } + } + + Platform.MenuSeparator {} + + Platform.MenuItem { text: qsTr("关闭窗口"); onTriggered: stickyWin.close() } + } + + Platform.FileDialog { + id: saveDialog + title: qsTr("选择保存位置") + folder: O_ImageSaver.savePath + fileMode: Platform.FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: O_StickyViewModel.saveImage(imageUrl, saveDialog.file) + } + + Platform.FileDialog { + id: mergedSaveDialog + title: qsTr("另存为(合并标注)") + folder: O_ImageSaver.savePath + fileMode: Platform.FileDialog.SaveFile + nameFilters: ["PNG (*.png)", "JPG (*.jpg)", "BMP (*.bmp)"] + onAccepted: saveMergedTo(mergedSaveDialog.file) + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + enabled: stickyWin.activeTool === "" && !stickyWin.contextMenuOpen + property point dragPos: Qt.point(0, 0) + property bool leftDragActive: false + z: 0 + + function inImageArea(mx, my) { + return mx >= 0 + && mx <= stickyWin.displayW + && my >= 0 + && my <= stickyWin.displayH + } + + onPressed: function(mouse) { + if (mouse.button === Qt.LeftButton) { + dragPos = Qt.point(mouseX, mouseY) + leftDragActive = true + } else { + leftDragActive = false + } + } + + onPositionChanged: function(mouse) { + //console.log("mouse.x",mouseX) + if (pressed && leftDragActive && (mouse.buttons & Qt.LeftButton) && stickyWin.activeTool === "" && !stickyWin.contextMenuOpen) { + stickyWin.x += mouseX - dragPos.x + stickyWin.y += mouseY - dragPos.y + } + } + + onReleased: function(mouse) { + if (!(mouse.buttons & Qt.LeftButton)) + leftDragActive = false + } + + onDoubleClicked: function(mouse) { + if (mouse.button === Qt.LeftButton && inImageArea(mouseX, mouseY)) + resetDisplaySize() + } + + onClicked: function(mouse) { + if (mouse.button === Qt.RightButton) { + leftDragActive = false + contextMenu.open() + } + } + + onWheel: function(wheel) { + if (wheel.angleDelta.y > 0) + setZoomValue(stickyWin.zoomFactor + 0.1) + else if (wheel.angleDelta.y < 0) + setZoomValue(stickyWin.zoomFactor - 0.1) + wheel.accepted = true + } + } +} + + diff --git a/qml/main.qml b/qml/main.qml index 6791280..dbb1349 100644 --- a/qml/main.qml +++ b/qml/main.qml @@ -1,8 +1,9 @@ -import QtQuick +import QtQuick import QtQuick.Window import QtQuick.Shapes import QtQuick.Layouts import com.Tz.tray 1.0 +import "Capture" TZWindow { id: root @@ -31,6 +32,7 @@ TZWindow { paintBoard.reset() tool.visible = false + tool.activeTool = "" tool.markToolInfoVisible = false @@ -59,22 +61,12 @@ TZWindow { Qt.callLater(function() { O_ScreenCapture.captureDesktop() - - var screens = Qt.application.screens - if (screens.length === 0) + var virtualRect = O_ScreenCapture.virtualGeometry() + if (virtualRect.width <= 0 || virtualRect.height <= 0) return - // Single-screen mode: always use the first screen as capture target. - var primary = screens[0] - - var vx = (primary.virtualX !== undefined) ? primary.virtualX : 0 - var vy = (primary.virtualY !== undefined) ? primary.virtualY : 0 - root.screenVirtualX = vx - root.screenVirtualY = vy - root.x = vx - root.y = vy - root.width = primary.width - root.height = primary.height + root.screenVirtualX = virtualRect.x + root.screenVirtualY = virtualRect.y O_ScreenCapture.grabToPaintBoard( root.paintBoard, @@ -100,7 +92,7 @@ TZWindow { return } if (longCaptureBarComponent === null) - longCaptureBarComponent = Qt.createComponent("qrc:/qml/TZLongCaptureBar.qml") + longCaptureBarComponent = Qt.createComponent("qrc:/qml/LongCapture/TZLongCaptureBar.qml") if (longCaptureBarComponent.status === Component.Ready) { longCaptureBarObj = longCaptureBarComponent.createObject(null) if (longCaptureBarObj) { @@ -136,7 +128,7 @@ TZWindow { return } if (longCapturePreviewComponent === null) - longCapturePreviewComponent = Qt.createComponent("qrc:/qml/TZLongCapturePreview.qml") + longCapturePreviewComponent = Qt.createComponent("qrc:/qml/LongCapture/TZLongCapturePreview.qml") if (longCapturePreviewComponent.status === Component.Ready) { longCapturePreviewObj = longCapturePreviewComponent.createObject(null) if (longCapturePreviewObj) { @@ -152,7 +144,7 @@ TZWindow { function showLongCaptureFrame(captureRect) { if (longCaptureFrameComponent === null) - longCaptureFrameComponent = Qt.createComponent("qrc:/qml/TZLongCaptureFrame.qml") + longCaptureFrameComponent = Qt.createComponent("qrc:/qml/LongCapture/TZLongCaptureFrame.qml") if (longCaptureFrameObj === null && longCaptureFrameComponent.status === Component.Ready) { longCaptureFrameObj = longCaptureFrameComponent.createObject(null) } else if (longCaptureFrameComponent.status === Component.Error) { @@ -343,7 +335,18 @@ TZWindow { onShowAboutTriggered: { showAboutWin() } onExitTriggered: Qt.quit() } - + // MouseArea + // { + // anchors.fill: parent; + // MouseArea { + // anchors.fill: parent + // onClicked: { + // console.log("x->", mouseX) + // console.log("y-->", mouseY) + // } + // onDoubleClicked: Qt.quit() + // } + // } Component.onCompleted: { trayHelper.setIcon(":/resource/img/tray_icon.svg") trayHelper.show() diff --git a/src/magnifier_provider.cpp b/src/magnifier_provider.cpp index eb26f98..d5a2431 100644 --- a/src/magnifier_provider.cpp +++ b/src/magnifier_provider.cpp @@ -1,4 +1,5 @@ -#include "magnifier_provider.h" +#include "magnifier_provider.h" + #include MagnifierProvider::MagnifierProvider(ScreenshotViewModel *viewModel) @@ -7,87 +8,85 @@ MagnifierProvider::MagnifierProvider(ScreenshotViewModel *viewModel) { } -// requestImage -// ------------ -// id 格式:","(由 QML 传入的逻辑像素鼠标坐标) -QImage MagnifierProvider::requestImage(const QString &id, QSize *size, const QSize & /*requestedSize*/) +QImage MagnifierProvider::requestImage(const QString &id, + QSize *size, + const QSize & /*requestedSize*/) { const QString cleanId = id.section('?', 0, 0); - - // 解析坐标 const QStringList parts = cleanId.split(','); if (parts.size() < 2) { QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); blank.fill(Qt::black); - if (size) *size = blank.size(); + if (size) { + *size = blank.size(); + } return blank; } + bool okX = false; bool okY = false; - const int cx = parts[0].toInt(&okX); - const int cy = parts[1].toInt(&okY); + const int logicalX = parts[0].toInt(&okX); + const int logicalY = parts[1].toInt(&okY); if (!okX || !okY) { QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); blank.fill(Qt::black); - if (size) *size = blank.size(); + if (size) { + *size = blank.size(); + } return blank; } - // 从桌面快照中取一份线程安全的副本 - const QImage snapshot = m_viewModel->desktopSnapshot(); + const int srcDiameter = kSrcRadius * 2; + QImage region(srcDiameter, srcDiameter, QImage::Format_ARGB32_Premultiplied); + region.fill(Qt::black); + + const QPixmap snapshot = m_viewModel->desktopSnapshot(); if (snapshot.isNull()) { QImage blank(kOutputSize, kOutputSize, QImage::Format_ARGB32_Premultiplied); blank.fill(Qt::black); - if (size) *size = blank.size(); + if (size) { + *size = blank.size(); + } return blank; } - // 逻辑坐标 → 快照像素坐标(按虚拟桌面逻辑尺寸与快照物理像素比例换算,支持多屏) - const QRect vg = m_viewModel->virtualGeometry(); - const qreal scaleX = (vg.width() > 0) ? (qreal(snapshot.width()) / qreal(vg.width())) : 1.0; - const qreal scaleY = (vg.height() > 0) ? (qreal(snapshot.height()) / qreal(vg.height())) : 1.0; - const int pcx = qRound((cx - vg.x()) * scaleX); - const int pcy = qRound((cy - vg.y()) * scaleY); + const QRect virtualRect = m_viewModel->virtualGeometry(); + const QPoint center(logicalX - virtualRect.x(), logicalY - virtualRect.y()); + QRect srcRect(center.x() - kSrcRadius, + center.y() - kSrcRadius, + srcDiameter, + srcDiameter); - // 采样半径按 X 轴比例缩放;Y 轴保持一致比例,保证中心点准确 - const int srcRadiusX = qMax(1, qRound(kSrcRadius * scaleX)); - const int srcRadiusY = qMax(1, qRound(kSrcRadius * scaleY)); - const int srcDiamW = srcRadiusX * 2; - const int srcDiamH = srcRadiusY * 2; - - // 计算裁剪矩形(以物理像素光标为中心) - QRect srcRect(pcx - srcRadiusX, pcy - srcRadiusY, srcDiamW, srcDiamH); - - // 处理边界:当光标靠近屏幕边缘时,用黑色填充越界区域 - QImage region(srcDiamW, srcDiamH, QImage::Format_ARGB32_Premultiplied); - region.fill(Qt::black); - { - QPainter rp(®ion); - QRect bounded = srcRect.intersected(snapshot.rect()); - if (!bounded.isEmpty()) { - int destX = bounded.x() - srcRect.x(); - int destY = bounded.y() - srcRect.y(); - rp.drawImage(destX, destY, snapshot, bounded.x(), bounded.y(), - bounded.width(), bounded.height()); - } - rp.end(); + QPainter regionPainter(®ion); + const QRect bounded = srcRect.intersected(snapshot.rect()); + if (!bounded.isEmpty()) { + const int destX = bounded.x() - srcRect.x(); + const int destY = bounded.y() - srcRect.y(); + regionPainter.drawPixmap(destX, + destY, + snapshot, + bounded.x(), + bounded.y(), + bounded.width(), + bounded.height()); } + regionPainter.end(); - // 放大到 kOutputSize×kOutputSize - QImage zoomed = region.scaled(kOutputSize, kOutputSize, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + QImage zoomed = region.scaled(kOutputSize, + kOutputSize, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); - // 绘制红色十字准线(1px 宽) - { - QPainter p(&zoomed); - p.setRenderHint(QPainter::Antialiasing, false); - QPen pen(QColor(255, 0, 0, 220), 1); - p.setPen(pen); - const int half = kOutputSize / 2; - p.drawLine(half, 0, half, kOutputSize - 1); // 竖线 - p.drawLine(0, half, kOutputSize - 1, half); // 横线 - p.end(); + QPainter p(&zoomed); + p.setRenderHint(QPainter::Antialiasing, false); + p.setPen(QPen(QColor(255, 0, 0, 220), 1)); + const int half = kOutputSize / 2; + p.drawLine(half, 0, half, kOutputSize - 1); + p.drawLine(0, half, kOutputSize - 1, half); + p.end(); + + if (size) { + *size = zoomed.size(); } - - if (size) *size = zoomed.size(); return zoomed; } diff --git a/src/main.cpp b/src/main.cpp index e7c880b..56d22ed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,8 @@ -#include +#include #include #include +#include +#include #include "tray_icon_helper.h" #include "paint_board/paint_board.h" @@ -77,7 +79,7 @@ int main(int argc, char *argv[]) // ── 暴露给 QML 的 ViewModel ─────────────────────────── engine.rootContext()->setContextProperty("O_ScreenCapture", &screenshotViewModel); - engine.rootContext()->setContextProperty("O_StickyViewModel", &stickyViewModel); + engine.rootContext()->setContextProperty("O_StickyViewModel", &stickyViewModel); engine.rootContext()->setContextProperty("O_AIViewModel", &aiViewModel); engine.rootContext()->setContextProperty("O_ImageSaver", &storageViewModel); engine.rootContext()->setContextProperty("O_GlobalShortcut", &globalShortcut); @@ -94,5 +96,14 @@ int main(int argc, char *argv[]) QCoreApplication::exit(-1); }, Qt::QueuedConnection); engine.load(url); + + if (!engine.rootObjects().isEmpty()) { + if (auto *window = qobject_cast(engine.rootObjects().constFirst())) { + if (QScreen *screen = QGuiApplication::primaryScreen()) { + window->setGeometry(screen->virtualGeometry()); + } + } + } + return app.exec(); } diff --git a/src/model/desktop_snapshot.cpp b/src/model/desktop_snapshot.cpp index 129bde4..1f00baa 100644 --- a/src/model/desktop_snapshot.cpp +++ b/src/model/desktop_snapshot.cpp @@ -1,134 +1,243 @@ -#include "desktop_snapshot.h" +#include "desktop_snapshot.h" -#include -#include -#include -#include -#include -#include #include +#include +#include +#include +#include +#include +#include -/* ── grab / release / pixelColor / windowAtPoint ──────── - * 这几个函数与平台无关,统一实现 - * ────────────────────────────────────────────────────── */ +#ifdef Q_OS_WIN +#define WIN32_LEAN_AND_MEAN +#include +#endif + +namespace { + +QRect desktopGeometry() +{ +#ifdef Q_OS_WIN + return QRect(GetSystemMetrics(SM_XVIRTUALSCREEN), + GetSystemMetrics(SM_YVIRTUALSCREEN), + GetSystemMetrics(SM_CXVIRTUALSCREEN), + GetSystemMetrics(SM_CYVIRTUALSCREEN)); +#else + QRect geometry; + + for (QScreen* const screen : QGuiApplication::screens()) { + QRect scrRect = screen->geometry(); + // https://doc.qt.io/qt-6/highdpi.html#device-independent-screen-geometry + qreal dpr = screen->devicePixelRatio(); + scrRect.moveTo(QPointF(scrRect.x() / dpr, scrRect.y() / dpr).toPoint()); + geometry = geometry.united(scrRect); + } + return geometry; +#endif +} + +#ifdef Q_OS_WIN +QImage captureVirtualDesktopImage(const QRect& geometry) +{ + HDC screenDC = GetDC(nullptr); + HDC memoryDC = CreateCompatibleDC(screenDC); + HBITMAP bitmap = CreateCompatibleBitmap(screenDC, geometry.width(), geometry.height()); + DeleteObject(SelectObject(memoryDC, bitmap)); + BitBlt(memoryDC, + 0, + 0, + geometry.width(), + geometry.height(), + screenDC, + geometry.x(), + geometry.y(), + SRCCOPY); + + QImage image(geometry.width(), geometry.height(), QImage::Format_ARGB32_Premultiplied); + BITMAPINFO bmi = { sizeof(BITMAPINFOHEADER), + static_cast(geometry.width()), + -static_cast(geometry.height()), + 1, + 32, + BI_RGB, + static_cast(geometry.width() * 4 * geometry.height()), + 0, + 0, + 0, + 0 }; + GetDIBits(memoryDC, + bitmap, + 0, + geometry.height(), + image.bits(), + &bmi, + DIB_RGB_COLORS); + + DeleteDC(memoryDC); + DeleteObject(bitmap); + ReleaseDC(nullptr, screenDC); + return image; +} +#endif + +} void DesktopSnapshot::grab() { - QScreen *primary = QGuiApplication::primaryScreen(); - if (!primary) { - qWarning() << "[DesktopSnapshot] 无法获取主屏"; + const QRect geometry = desktopGeometry(); + if (geometry.isEmpty()) { + release(); return; } - // 等待合成器/DWM 将截图窗口从帧缓冲移除 - QEventLoop loop; - QTimer::singleShot(50, &loop, &QEventLoop::quit); - loop.exec(); +#ifdef Q_OS_WIN + const QImage image = captureVirtualDesktopImage(geometry); + if (image.isNull()) { + release(); + return; + } + m_snapshot = QPixmap::fromImage(image); +#else + const QList screens = QGuiApplication::screens(); + const int minLogicalX = geometry.x(); + const int minLogicalY = geometry.y(); - // 单屏模式:仅使用主屏坐标系。 - m_virtualGeometry = primary->geometry(); - qDebug() << "[DesktopSnapshot] 单屏模式,主屏逻辑矩形:" << m_virtualGeometry; + QPixmap desktop(geometry.size()); + desktop.fill(Qt::black); - // 创建与虚拟桌面等大的物理像素画布(以主屏 DPR 为基准) - // 主屏 DPR 即虚拟桌面坐标系的缩放基准,与 QML 侧一致 - const qreal baseDpr = primary->devicePixelRatio(); - - const int canvasW = qRound(m_virtualGeometry.width() * baseDpr); - const int canvasH = qRound(m_virtualGeometry.height() * baseDpr); - m_snapshot = QImage(canvasW, canvasH, QImage::Format_ARGB32_Premultiplied); - m_snapshot.fill(Qt::black); - - // 仅抓取主屏 - QPainter painter(&m_snapshot); - QImage img = primary->grabWindow(0).toImage(); - if (img.width() != canvasW || img.height() != canvasH) - img = img.scaled(canvasW, canvasH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - painter.drawImage(0, 0, img); + QPainter painter(&desktop); + painter.setCompositionMode(QPainter::CompositionMode_Source); + for (QScreen* screen : screens) { + if (!screen) { + continue; + } + const QRect screenGeom = screen->geometry(); + painter.drawPixmap(QRect(screenGeom.x() - minLogicalX, + screenGeom.y() - minLogicalY, + screenGeom.width(), + screenGeom.height()), + screen->grabWindow(0)); + } painter.end(); + m_snapshot = desktop; +#endif - qDebug() << "[DesktopSnapshot] 快照已缓存,尺寸:" - << m_snapshot.width() << "x" << m_snapshot.height(); - + m_virtualGeometry = geometry; buildWindowList(); } void DesktopSnapshot::release() { - m_snapshot = QImage(); + m_snapshot = QPixmap(); m_virtualGeometry = QRect(); m_windowRects.clear(); } +QPoint DesktopSnapshot::mapLogicalPointToPhysical(const QPoint &logicalPoint) const +{ + if (m_virtualGeometry.isEmpty()) { + return {}; + } + return logicalPoint - m_virtualGeometry.topLeft(); +} + +QRect DesktopSnapshot::mapLogicalRectToPhysical(const QRect &logicalRect) const +{ + const QRect normalized = logicalRect.normalized(); + if (normalized.isEmpty() || m_virtualGeometry.isEmpty()) { + return {}; + } + QRect translated = normalized.translated(-m_virtualGeometry.topLeft()); + return translated.intersected(m_snapshot.rect()); +} + QColor DesktopSnapshot::pixelColor(int logicalX, int logicalY) const { - if (m_snapshot.isNull() || m_virtualGeometry.isEmpty()) return QColor(); +#ifdef Q_OS_WIN + HDC screenDC = GetDC(nullptr); + if (!screenDC) { + return {}; + } + const COLORREF color = GetPixel(screenDC, logicalX, logicalY); + ReleaseDC(nullptr, screenDC); + if (color == CLR_INVALID) { + return {}; + } + return QColor(GetRValue(color), GetGValue(color), GetBValue(color)); +#else + if (m_snapshot.isNull() || m_virtualGeometry.isEmpty()) { + return {}; + } - // 逻辑坐标相对于虚拟桌面原点的偏移 - const int relX = logicalX - m_virtualGeometry.x(); - const int relY = logicalY - m_virtualGeometry.y(); - - // 按快照实际尺寸与虚拟桌面逻辑尺寸比例换算(统一 DPR) - const qreal scaleX = m_virtualGeometry.width() > 0 - ? qreal(m_snapshot.width()) / qreal(m_virtualGeometry.width()) : 1.0; - const qreal scaleY = m_virtualGeometry.height() > 0 - ? qreal(m_snapshot.height()) / qreal(m_virtualGeometry.height()) : 1.0; - - const int px = qRound(relX * scaleX); - const int py = qRound(relY * scaleY); - - if (px < 0 || py < 0 || px >= m_snapshot.width() || py >= m_snapshot.height()) - return QColor(); - - return QColor(m_snapshot.pixel(px, py)); + const QPoint pixelPoint = mapLogicalPointToPhysical(QPoint(logicalX, logicalY)); + if (!m_snapshot.rect().contains(pixelPoint)) { + return {}; + } + const QImage image = m_snapshot.toImage(); + return QColor::fromRgba(image.pixel(pixelPoint)); +#endif } QRect DesktopSnapshot::windowAtPoint(int x, int y) const { for (const QRect &r : m_windowRects) { - if (r.contains(x, y)) + if (r.contains(x, y)) { return r; + } } - return QRect(); + return {}; } -/* ══════════════════════════════════════════════════════════ - * buildWindowList — 平台专属实现 - * ══════════════════════════════════════════════════════════ */ - -/* ──────────────────────────────────────────────────────── - * Linux / Unix:通过 _NET_CLIENT_LIST_STACKING 枚举窗口 - * ──────────────────────────────────────────────────────── */ #ifdef Q_OS_UNIX -#include #include +#include void DesktopSnapshot::buildWindowList() { m_windowRects.clear(); Display *dpy = XOpenDisplay(nullptr); - if (!dpy) return; + if (!dpy) { + return; + } Window root = DefaultRootWindow(dpy); Atom netClientListStacking = XInternAtom(dpy, "_NET_CLIENT_LIST_STACKING", False); Atom actualType; - int actualFormat; - unsigned long nitems = 0, bytesAfter = 0; - unsigned char *data = nullptr; + int actualFormat; + unsigned long nitems = 0; + unsigned long bytesAfter = 0; + unsigned char *data = nullptr; - int ret = XGetWindowProperty(dpy, root, netClientListStacking, - 0, 65536, False, XA_WINDOW, - &actualType, &actualFormat, - &nitems, &bytesAfter, &data); + int ret = XGetWindowProperty(dpy, + root, + netClientListStacking, + 0, + 65536, + False, + XA_WINDOW, + &actualType, + &actualFormat, + &nitems, + &bytesAfter, + &data); if (ret != Success || !data) { Atom netClientList = XInternAtom(dpy, "_NET_CLIENT_LIST", False); - ret = XGetWindowProperty(dpy, root, netClientList, - 0, 65536, False, XA_WINDOW, - &actualType, &actualFormat, - &nitems, &bytesAfter, &data); + ret = XGetWindowProperty(dpy, + root, + netClientList, + 0, + 65536, + False, + XA_WINDOW, + &actualType, + &actualFormat, + &nitems, + &bytesAfter, + &data); if (ret != Success || !data) { XCloseDisplay(dpy); return; @@ -136,92 +245,89 @@ void DesktopSnapshot::buildWindowList() } Window *wins = reinterpret_cast(data); - for (long i = static_cast(nitems) - 1; i >= 0; --i) { Window w = wins[i]; XWindowAttributes attr; - if (!XGetWindowAttributes(dpy, w, &attr)) continue; - if (attr.map_state != IsViewable) continue; - if (attr.width < 10) continue; - if (attr.height < 10) continue; + if (!XGetWindowAttributes(dpy, w, &attr) || attr.map_state != IsViewable || + attr.width < 10 || attr.height < 10) { + continue; + } - int screenX = 0, screenY = 0; + int screenX = 0; + int screenY = 0; Window child; XTranslateCoordinates(dpy, w, root, 0, 0, &screenX, &screenY, &child); QRect r(screenX, screenY, attr.width, attr.height); r = r.intersected(m_virtualGeometry); - if (r.isEmpty()) continue; - - m_windowRects.append(r); + if (!r.isEmpty()) { + m_windowRects.append(r); + } } XFree(data); XCloseDisplay(dpy); - - qDebug() << "[DesktopSnapshot] 枚举到顶层窗口数:" << m_windowRects.size(); } -#endif /* Q_OS_UNIX */ +#endif -/* ──────────────────────────────────────────────────────── - * Windows:通过 EnumWindows 枚举可见顶层窗口 - * ──────────────────────────────────────────────────────── */ #ifdef Q_OS_WIN -#define WIN32_LEAN_AND_MEAN -#include - -/* EnumWindows 回调:收集所有可见且有标题栏的顶层窗口矩形 */ struct EnumWindowsParam { QVector *rects; - QRect snapshotRect; /* 逻辑像素,用于裁剪 */ + QRect snapshotRect; }; static qreal hwndScaleFactor(HWND hwnd) { - using GetDpiForWindowFn = UINT (WINAPI *)(HWND); - static GetDpiForWindowFn fn = - reinterpret_cast( - GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow")); + using GetDpiForWindowFn = UINT(WINAPI *)(HWND); + static GetDpiForWindowFn fn = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"user32.dll"), "GetDpiForWindow")); if (fn) { const UINT dpi = fn(hwnd); - if (dpi > 0) + if (dpi > 0) { return qreal(dpi) / 96.0; + } } return QGuiApplication::primaryScreen() - ? QGuiApplication::primaryScreen()->devicePixelRatio() - : 1.0; + ? QGuiApplication::primaryScreen()->devicePixelRatio() + : 1.0; } static BOOL CALLBACK enumWindowsProc(HWND hwnd, LPARAM lParam) { EnumWindowsParam *param = reinterpret_cast(lParam); - if (!IsWindowVisible(hwnd)) return TRUE; - if (IsIconic(hwnd)) return TRUE; /* 最小化,跳过 */ + if (!IsWindowVisible(hwnd) || IsIconic(hwnd)) { + return TRUE; + } - /* 跳过没有 WS_CAPTION 的工具窗口、桌面壳层等 */ LONG style = GetWindowLong(hwnd, GWL_STYLE); - if (!(style & WS_CAPTION)) return TRUE; + if (!(style & WS_CAPTION)) { + return TRUE; + } RECT rc; - if (!GetWindowRect(hwnd, &rc)) return TRUE; + if (!GetWindowRect(hwnd, &rc)) { + return TRUE; + } - int w = rc.right - rc.left; - int h = rc.bottom - rc.top; - if (w < 10 || h < 10) return TRUE; + const int w = rc.right - rc.left; + const int h = rc.bottom - rc.top; + if (w < 10 || h < 10) { + return TRUE; + } - /* GetWindowRect 返回物理像素坐标,按窗口自身 DPI 换算逻辑坐标 */ const qreal scale = hwndScaleFactor(hwnd); QRect r(qRound(rc.left / scale), qRound(rc.top / scale), qRound(w / scale), qRound(h / scale)); r = r.intersected(param->snapshotRect); - if (!r.isEmpty()) - param->rects->append(r); /* EnumWindows 本身按 Z 序(顶->底)枚举,append 才能保持从顶到底 */ + if (!r.isEmpty()) { + param->rects->append(r); + } return TRUE; } @@ -229,11 +335,8 @@ static BOOL CALLBACK enumWindowsProc(HWND hwnd, LPARAM lParam) void DesktopSnapshot::buildWindowList() { m_windowRects.clear(); - EnumWindowsParam param { &m_windowRects, m_virtualGeometry }; EnumWindows(enumWindowsProc, reinterpret_cast(¶m)); - - qDebug() << "[DesktopSnapshot] 枚举到顶层窗口数:" << m_windowRects.size(); } -#endif /* Q_OS_WIN */ +#endif diff --git a/src/model/desktop_snapshot.h b/src/model/desktop_snapshot.h index ecca6ea..18f20be 100644 --- a/src/model/desktop_snapshot.h +++ b/src/model/desktop_snapshot.h @@ -1,46 +1,43 @@ #ifndef DESKTOP_SNAPSHOT_H #define DESKTOP_SNAPSHOT_H -#include +#include +#include +#include #include #include -#include - -// DesktopSnapshot — Model -// 负责全屏快照的采集与存储,以及顶层窗口列表的枚举。 -// 支持多屏:将所有屏幕的快照拼合为一张以虚拟桌面坐标系为基准的大图。 -// 不依赖 QML,不继承 QObject,纯数据/系统调用。 class DesktopSnapshot { public: DesktopSnapshot() = default; - // 抓取所有屏幕快照并拼合,枚举顶层窗口列表(含 50ms 延时等待合成器) void grab(); - // 释放快照和窗口列表 void release(); bool isNull() const { return m_snapshot.isNull(); } - - // 拼合后的虚拟桌面快照(物理像素坐标系) - const QImage& image() const { return m_snapshot; } - - // 虚拟桌面的逻辑坐标矩形(所有屏幕的 geometry 联合,逻辑像素) + const QPixmap& image() const { return m_snapshot; } QRect virtualGeometry() const { return m_virtualGeometry; } - // 将逻辑坐标 (x,y) 转换为快照中的颜色(支持多屏 DPI 混合) QColor pixelColor(int logicalX, int logicalY) const; - - // 返回包含逻辑坐标点 (x,y) 的最上层窗口矩形(逻辑坐标) + QPoint mapLogicalPointToPhysical(const QPoint &logicalPoint) const; + QRect mapLogicalRectToPhysical(const QRect &logicalRect) const; QRect windowAtPoint(int x, int y) const; private: - void buildWindowList(); + struct ScreenEntry { + QRect logicalGeometry; + QRect physicalRect; + qreal dpr = 1.0; + }; - QImage m_snapshot; - QRect m_virtualGeometry; // 虚拟桌面逻辑矩形(所有屏幕 geometry 的联合) - QVector m_windowRects; // 按 Z 轴从顶到底排列(逻辑坐标) + void buildWindowList(); + const ScreenEntry* screenEntryForPoint(const QPoint &logicalPoint) const; + + QPixmap m_snapshot; + QRect m_virtualGeometry; + QVector m_windowRects; + QVector m_screenEntries; }; #endif // DESKTOP_SNAPSHOT_H diff --git a/src/viewmodel/screenshot_view_model.cpp b/src/viewmodel/screenshot_view_model.cpp index f757805..d669edf 100644 --- a/src/viewmodel/screenshot_view_model.cpp +++ b/src/viewmodel/screenshot_view_model.cpp @@ -1,13 +1,51 @@ -#include "screenshot_view_model.h" +#include "screenshot_view_model.h" #include "paint_board/paint_board.h" #include #include +#include #include #include -#include -#include #include +#include +#include + +namespace { + +qreal captureDprForRect(const QRect& rect) +{ +#ifdef Q_OS_WIN + if (QScreen* screen = QGuiApplication::screenAt(rect.center())) { + return screen->devicePixelRatio(); + } +#endif + if (QScreen* screen = QGuiApplication::primaryScreen()) { + return screen->devicePixelRatio(); + } + return 1.0; +} + +QRect captureRectInSnapshot(const DesktopSnapshot& snapshot, const QRect& rect, qreal dpr) +{ + if (snapshot.isNull()) { + return {}; + } + + if (rect.isEmpty()) { + return snapshot.image().rect(); + } + + const QRect localRect = rect.translated(-snapshot.virtualGeometry().topLeft()); + const QPoint topLeft = QPoint(qRound(localRect.left() * dpr), + qRound(localRect.top() * dpr)); + const QPoint bottomRight = QPoint(qRound(localRect.right() * dpr), + qRound(localRect.bottom() * dpr)); + QRect target; + target.setCoords(topLeft.x(), topLeft.y(), bottomRight.x(), bottomRight.y()); + return target.intersected(snapshot.image().rect()); +} + +} ScreenshotViewModel::ScreenshotViewModel(DesktopSnapshot &snapshot, StickyImageStore &stickyStore, @@ -18,118 +56,120 @@ ScreenshotViewModel::ScreenshotViewModel(DesktopSnapshot &snapshot, { } -// ── captureDesktop ──────────────────────────────────────── +QRect ScreenshotViewModel::captureRectForDisplay(const QRect &rect) const +{ + const QRect r = rect.normalized(); + if (r.isEmpty() || m_snapshot.isNull()) { + return {}; + } + return captureRectInSnapshot(m_snapshot, r, captureDprForRect(r)); +} + void ScreenshotViewModel::captureDesktop() { m_snapshot.grab(); } -// ── releaseDesktopSnapshot ──────────────────────────────── void ScreenshotViewModel::releaseDesktopSnapshot() { m_snapshot.release(); } -// ── getPixelColor ───────────────────────────────────────── QColor ScreenshotViewModel::getPixelColor(int x, int y) const { return m_snapshot.pixelColor(x, y); } -// ── windowAtPoint ───────────────────────────────────────── QRect ScreenshotViewModel::windowAtPoint(int x, int y) const { return m_snapshot.windowAtPoint(x, y); } -// ── captureRectToClipboard ──────────────────────────────── bool ScreenshotViewModel::captureRectToClipboard(QQuickItem *paintBoard, const QRect &rect) { const QRect r = rect.normalized(); if (r.isEmpty()) { - qWarning() << "[ScreenshotViewModel] 截图区域为空"; + qWarning() << "[ScreenshotViewModel] capture area is empty"; return false; } + QImage img = captureScreen(paintBoard, r); if (img.isNull()) { - qWarning() << "[ScreenshotViewModel] 截图失败,捕获到空图片"; + qWarning() << "[ScreenshotViewModel] failed to capture image"; return false; } + return writeImageToClipboard(img); } -// ── captureRectToBase64 ─────────────────────────────────── QString ScreenshotViewModel::captureRectToBase64(QQuickItem *paintBoard, const QRect &rect) { const QRect r = rect.normalized(); if (r.isEmpty()) { - qWarning() << "[ScreenshotViewModel] 截图区域为空"; + qWarning() << "[ScreenshotViewModel] capture area is empty"; return {}; } + QImage img = captureScreen(paintBoard, r); if (img.isNull()) { - qWarning() << "[ScreenshotViewModel] 截图失败,捕获到空图片"; + qWarning() << "[ScreenshotViewModel] failed to capture image"; return {}; } + QByteArray data; - QBuffer buf(&data); + QBuffer buf(&data); buf.open(QIODevice::WriteOnly); if (!img.save(&buf, "PNG")) { - qWarning() << "[ScreenshotViewModel] PNG 编码失败"; + qWarning() << "[ScreenshotViewModel] failed to encode PNG"; return {}; } return data.toBase64(); } -// ── captureRectToStickyUrl ──────────────────────────────── QString ScreenshotViewModel::captureRectToStickyUrl(QQuickItem *paintBoard, const QRect &rect) { const QRect r = rect.normalized(); if (r.isEmpty()) { - qWarning() << "[ScreenshotViewModel] 贴图区域为空"; + qWarning() << "[ScreenshotViewModel] sticky area is empty"; return {}; } + QImage img = captureScreen(paintBoard, r); if (img.isNull()) { - qWarning() << "[ScreenshotViewModel] 贴图截图失败"; + qWarning() << "[ScreenshotViewModel] failed to capture sticky image"; return {}; } + return m_stickyStore.storeImage(img); } -// ── grabToPaintBoard ────────────────────────────────────── void ScreenshotViewModel::grabToPaintBoard(QQuickItem *paintBoard, const QRect &rect) { const QRect r = rect.normalized(); - if (r.isEmpty()) return; - captureScreen(paintBoard, r, /*setBackground=*/true); + if (r.isEmpty()) { + return; + } + captureScreen(paintBoard, r, true); } -// ── captureRectToImage ──────────────────────────────────── QImage ScreenshotViewModel::captureRectToImage(const QRect &rect) { const QRect r = rect.normalized(); if (r.isEmpty()) { - qWarning() << "[ScreenshotViewModel] OCR 区域为空"; + qWarning() << "[ScreenshotViewModel] OCR area is empty"; return {}; } if (!m_snapshot.isNull()) { - const QRect vg = m_snapshot.virtualGeometry(); - const qreal scaleX = vg.width() > 0 ? qreal(m_snapshot.image().width()) / qreal(vg.width()) : 1.0; - const qreal scaleY = vg.height() > 0 ? qreal(m_snapshot.image().height()) / qreal(vg.height()) : 1.0; - - const QRect physRect = QRect(qRound((r.x() - vg.x()) * scaleX), - qRound((r.y() - vg.y()) * scaleY), - qRound(r.width() * scaleX), - qRound(r.height() * scaleY)) - .intersected(m_snapshot.image().rect()); - return m_snapshot.image().copy(physRect); + const QRect captureRect = captureRectInSnapshot(m_snapshot, r, captureDprForRect(r)); + return m_snapshot.image().copy(captureRect).toImage(); } - qWarning() << "[ScreenshotViewModel] 快照未缓存,OCR 回退到实时抓屏(仅主屏)"; + qWarning() << "[ScreenshotViewModel] snapshot unavailable, falling back to primary screen"; QScreen *screen = QGuiApplication::primaryScreen(); - if (!screen) return {}; + if (!screen) { + return {}; + } return screen->grabWindow(0, r.x(), r.y(), r.width(), r.height()).toImage(); } @@ -149,7 +189,6 @@ bool ScreenshotViewModel::copyImageToClipboard(const QImage &image) return writeImageToClipboard(image); } -// ── captureScreen (private) ─────────────────────────────── QImage ScreenshotViewModel::captureScreen(QQuickItem *paintBoard, const QRect &rect, bool setBackground) @@ -157,93 +196,86 @@ QImage ScreenshotViewModel::captureScreen(QQuickItem *paintBoard, PaintBoard *board = qobject_cast(paintBoard); QImage screenshot; - qreal scaleX = 1.0; - qreal scaleY = 1.0; + qreal dpr = 1.0; if (!m_snapshot.isNull()) { - // 快照坐标系:逻辑坐标相对于虚拟桌面原点;物理像素按统一 DPR 缩放。 - // 将逻辑选区映射到快照物理像素矩形后裁剪。 - const QRect vg = m_snapshot.virtualGeometry(); - scaleX = vg.width() > 0 ? qreal(m_snapshot.image().width()) / qreal(vg.width()) : 1.0; - scaleY = vg.height() > 0 ? qreal(m_snapshot.image().height()) / qreal(vg.height()) : 1.0; - const QRect physRect = rect.isEmpty() - ? m_snapshot.image().rect() - : QRect(qRound((rect.x() - vg.x()) * scaleX), - qRound((rect.y() - vg.y()) * scaleY), - qRound(rect.width() * scaleX), - qRound(rect.height() * scaleY)) - .intersected(m_snapshot.image().rect()); - - screenshot = m_snapshot.image().copy(physRect); + if (board && board->window()) { + dpr = board->window()->devicePixelRatio(); + } else if (!rect.isEmpty()) { + dpr = captureDprForRect(rect); + } + const QRect captureRect = captureRectInSnapshot(m_snapshot, rect, dpr); + screenshot = m_snapshot.image().copy(captureRect).toImage(); } else { - qWarning() << "[ScreenshotViewModel] 快照未缓存,回退到实时抓屏(仅主屏)"; + qWarning() << "[ScreenshotViewModel] snapshot unavailable, falling back to primary screen"; QScreen *screen = QGuiApplication::primaryScreen(); - if (!screen) return {}; + if (!screen) { + return {}; + } screenshot = screen->grabWindow(0).toImage(); } if (board) { QImage annotations = board->renderToImage(); if (!annotations.isNull()) { - // 标注层是逻辑像素,先映射到物理像素坐标系,避免高 DPI 下偏移。 - const QSize physicalAnnoSize( - qMax(1, qRound(annotations.width() * scaleX)), - qMax(1, qRound(annotations.height() * scaleY))); - if (annotations.size() != physicalAnnoSize) { - annotations = annotations.scaled(physicalAnnoSize, - Qt::IgnoreAspectRatio, - Qt::SmoothTransformation); - } - QPainter p(&screenshot); - // PaintBoard 现在是全屏画布:导出选区时,需要从全屏标注层裁剪对应区域再叠加 if (!rect.isEmpty()) { QQuickWindow *win = board->window(); const int windowAbsX = win ? win->x() : 0; const int windowAbsY = win ? win->y() : 0; - const int boardAbsX = windowAbsX + qRound(board->x()); - const int boardAbsY = windowAbsY + qRound(board->y()); + const int boardAbsX = windowAbsX + qRound(board->x()); + const int boardAbsY = windowAbsY + qRound(board->y()); - const int wantX = qRound((rect.x() - boardAbsX) * scaleX); - const int wantY = qRound((rect.y() - boardAbsY) * scaleY); - QRect srcRect(wantX, wantY, screenshot.width(), screenshot.height()); + const int sourceX = rect.x() - boardAbsX; + const int sourceY = rect.y() - boardAbsY; + QRect srcRect(sourceX, sourceY, rect.width(), rect.height()); srcRect = srcRect.intersected(annotations.rect()); if (!srcRect.isEmpty()) { - const int dstX = srcRect.x() - wantX; - const int dstY = srcRect.y() - wantY; - p.drawImage(QPoint(dstX, dstY), annotations, srcRect); + const int dstX = qRound((srcRect.x() - sourceX) * dpr); + const int dstY = qRound((srcRect.y() - sourceY) * dpr); + const int dstW = qMax(1, qRound(srcRect.width() * dpr)); + const int dstH = qMax(1, qRound(srcRect.height() * dpr)); + p.drawImage(QRect(dstX, dstY, dstW, dstH), + annotations, + srcRect); } } else { p.drawImage(0, 0, annotations); } + p.end(); } if (setBackground) { - // PaintBoard 背景图使用逻辑像素尺寸,避免高 DPI 下比例异常。 - QImage logicalBackground = screenshot; - const QSize logicalSize( - qMax(1, qRound(screenshot.width() / scaleX)), - qMax(1, qRound(screenshot.height() / scaleY))); - if (logicalBackground.size() != logicalSize) { - logicalBackground = logicalBackground.scaled(logicalSize, - Qt::IgnoreAspectRatio, - Qt::SmoothTransformation); + QImage background = screenshot; + if (board->window() && board->window()->devicePixelRatio() > 1.0) { + const QSize logicalSize(qRound(background.width() / board->window()->devicePixelRatio()), + qRound(background.height() / board->window()->devicePixelRatio())); + if (logicalSize.isValid() && logicalSize != background.size()) { + background = background.scaled(logicalSize, + Qt::IgnoreAspectRatio, + Qt::SmoothTransformation); + } } - board->setBackgroundImg(logicalBackground); + board->setBackgroundImg(background); } } return screenshot; } -// ── writeImageToClipboard (private) ────────────────────── bool ScreenshotViewModel::writeImageToClipboard(const QImage &image) { - if (image.isNull()) return false; + if (image.isNull()) { + return false; + } + QClipboard *cb = QGuiApplication::clipboard(); - if (!cb) return false; + if (!cb) { + return false; + } + cb->setImage(image, QClipboard::Clipboard); #ifdef Q_OS_LINUX cb->setImage(image, QClipboard::Selection); diff --git a/src/viewmodel/screenshot_view_model.h b/src/viewmodel/screenshot_view_model.h index 32889a8..c1b484a 100644 --- a/src/viewmodel/screenshot_view_model.h +++ b/src/viewmodel/screenshot_view_model.h @@ -1,79 +1,56 @@ #ifndef SCREENSHOT_VIEW_MODEL_H #define SCREENSHOT_VIEW_MODEL_H -#include -#include #include +#include #include +#include +#include #include "model/desktop_snapshot.h" #include "model/app_settings.h" #include "sticky_image_store.h" -// ScreenshotViewModel -// ------------------- -// 编排截图完整流程:抓快照 → 裁剪 → 合并标注 → 输出(剪贴板/Base64/贴图)。 -// 暴露 Q_INVOKABLE 方法和信号供 QML 调用,自身不感知任何 QML 组件 id。 -// 依赖的 Model:DesktopSnapshot(数据)、StickyImageStore(贴图存储)。 - class ScreenshotViewModel : public QObject { Q_OBJECT public: explicit ScreenshotViewModel(DesktopSnapshot &snapshot, - StickyImageStore &stickyStore, - QObject *parent = nullptr); + StickyImageStore &stickyStore, + QObject *parent = nullptr); - // ── 供 MagnifierProvider 只读访问快照 ──────────────── - const QImage& desktopSnapshot() const { return m_snapshot.image(); } - - // 虚拟桌面的逻辑矩形(所有屏幕 geometry 的并集),供 QML 定位截图窗口 + const QPixmap& desktopSnapshot() const { return m_snapshot.image(); } Q_INVOKABLE QRect virtualGeometry() const { return m_snapshot.virtualGeometry(); } + QPoint mapLogicalPointToPhysical(const QPoint &point) const + { + return m_snapshot.mapLogicalPointToPhysical(point); + } + Q_INVOKABLE QRect mapLogicalRectToPhysicalRect(const QRect &rect) const + { + return captureRectForDisplay(rect); + } - // ── Q_INVOKABLE:供 QML 调用 ───────────────────────── - - // 抓取全屏快照(在截图窗口显示之前调用) Q_INVOKABLE void captureDesktop(); - - // 释放快照(截图会话结束后调用) Q_INVOKABLE void releaseDesktopSnapshot(); - - // 取指定逻辑坐标的像素颜色(放大镜取色) Q_INVOKABLE QColor getPixelColor(int x, int y) const; - - // 返回 (x,y) 下最上层窗口矩形(窗口识别) Q_INVOKABLE QRect windowAtPoint(int x, int y) const; - - // 截图选区并写入剪贴板,返回是否成功 Q_INVOKABLE bool captureRectToClipboard(QQuickItem *paintBoard, const QRect &rect); - - // 截图选区并返回 Base64 字符串(供保存文件使用,不写剪贴板) Q_INVOKABLE QString captureRectToBase64(QQuickItem *paintBoard, const QRect &rect); - - // 截图选区并存入 StickyImageStore,返回 image://sticky/ URL Q_INVOKABLE QString captureRectToStickyUrl(QQuickItem *paintBoard, const QRect &rect); - - // 仅将选区写入 PaintBoard 背景(马赛克工具使用) Q_INVOKABLE void grabToPaintBoard(QQuickItem *paintBoard, const QRect &rect); - - // 截图选区并直接返回 QImage(OCR 使用) Q_INVOKABLE QImage captureRectToImage(const QRect &rect); - // 实时抓取选区(每次都会刷新桌面快照),用于长截图连续采样。 QImage captureRectToImageLive(const QRect &rect); - - // 复制任意图像到剪贴板。 bool copyImageToClipboard(const QImage &image); private: - // 内部:从快照裁剪选区并叠加 PaintBoard 标注层 - // setBackground=true 时将结果写回 PaintBoard 背景(仅马赛克路径) - QImage captureScreen(QQuickItem *paintBoard, const QRect &rect = QRect(), + QRect captureRectForDisplay(const QRect &rect) const; + QImage captureScreen(QQuickItem *paintBoard, + const QRect &rect = QRect(), bool setBackground = false); - bool writeImageToClipboard(const QImage &image); - DesktopSnapshot &m_snapshot; + DesktopSnapshot &m_snapshot; StickyImageStore &m_stickyStore; }; diff --git a/src/viewmodel/sticky_view_model.cpp b/src/viewmodel/sticky_view_model.cpp index f65ff59..5223db1 100644 --- a/src/viewmodel/sticky_view_model.cpp +++ b/src/viewmodel/sticky_view_model.cpp @@ -1,6 +1,14 @@ -#include "sticky_view_model.h" +#include "sticky_view_model.h" #include #include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include +#endif StickyViewModel::StickyViewModel(StickyImageStore &store, QObject *parent) : QObject(parent) @@ -10,8 +18,34 @@ StickyViewModel::StickyViewModel(StickyImageStore &store, QObject *parent) void StickyViewModel::requestSticky(const QString &imageUrl, const QRect &imgRect) { - if (imageUrl.isEmpty()) return; - emit stickyReady(imageUrl, imgRect); + if (imageUrl.isEmpty()) { + return; + } + + QRect targetRect = imgRect.normalized(); + QImage image = m_store.getImageByUrl(imageUrl); + if (!image.isNull()) { + QScreen *screen = QGuiApplication::screenAt(targetRect.topLeft()); + if (!screen) { + screen = QGuiApplication::screenAt(targetRect.center()); + } + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + + const qreal dpr = screen ? screen->devicePixelRatio() : 1.0; + if (dpr > 0.0) { + if (!qFuzzyCompare(image.devicePixelRatio(), dpr)) { + image.setDevicePixelRatio(dpr); + m_store.replaceImage(imageUrl, image); + } + const QSize logicalSize(qMax(1, qRound(image.width() / dpr)), + qMax(1, qRound(image.height() / dpr))); + targetRect.setSize(logicalSize); + } + } + + emit stickyReady(imageUrl, targetRect); } void StickyViewModel::releaseImage(const QString &imageUrl) @@ -19,6 +53,34 @@ void StickyViewModel::releaseImage(const QString &imageUrl) m_store.releaseImage(imageUrl); } +void StickyViewModel::positionStickyWindow(QObject *windowObject, const QRect &imgRect) const +{ + auto *window = qobject_cast(windowObject); + if (!window) { + return; + } + + const QRect targetRect = imgRect.normalized(); + const QPoint topLeft = targetRect.topLeft(); + + window->setPosition(topLeft); + +#ifdef Q_OS_WIN + if (HWND hwnd = reinterpret_cast(window->winId())) { + RECT rect{}; + GetWindowRect(hwnd, &rect); + qDebug() << "[StickyHWND] qt-pos:" + << topLeft + << "hwnd:" + << QRect(rect.left, + rect.top, + rect.right - rect.left, + rect.bottom - rect.top) + << "qmlPos:" << window->x() << window->y(); + } +#endif +} + bool StickyViewModel::saveImage(const QString &imageUrl, const QUrl &targetUrl) { return m_store.saveImage(imageUrl, targetUrl); diff --git a/src/viewmodel/sticky_view_model.h b/src/viewmodel/sticky_view_model.h index eaaa904..be3d79c 100644 --- a/src/viewmodel/sticky_view_model.h +++ b/src/viewmodel/sticky_view_model.h @@ -29,6 +29,7 @@ public: Q_INVOKABLE bool saveImage(const QString &imageUrl, const QUrl &targetUrl); Q_INVOKABLE bool copyImageToClipboard(const QString &imageUrl); Q_INVOKABLE QImage getImageByUrl(const QString &imageUrl) const; + Q_INVOKABLE void positionStickyWindow(QObject *windowObject, const QRect &imgRect) const; Q_INVOKABLE bool overwriteWithAnnotations(const QString &imageUrl, const QImage &annotationLayer); Q_INVOKABLE bool saveMergedImage(const QString &imageUrl, const QImage &annotationLayer, const QUrl &targetUrl); Q_INVOKABLE bool rotateImage(const QString &imageUrl, int degreesClockwise);