diff --git a/CMakeLists.txt b/CMakeLists.txt
index f735dd7..17bcefe 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -69,6 +69,8 @@ set(SOURCES
src/viewmodel/storage_view_model.h
src/viewmodel/gif_record_view_model.cpp
src/viewmodel/gif_record_view_model.h
+ src/viewmodel/scroll_capture_view_model.cpp
+ src/viewmodel/scroll_capture_view_model.h
src/viewmodel/ocr_view_model.cpp
src/viewmodel/ocr_view_model.h
@@ -78,6 +80,8 @@ set(SOURCES
src/gif_encoder/gif_encoder.h
src/gif_encoder/gif_frame_grabber.cpp
src/gif_encoder/gif_frame_grabber.h
+ src/scroll/scroll_stitcher.cpp
+ src/scroll/scroll_stitcher.h
# OCR
src/ocr/ocr_engine.cpp
@@ -154,6 +158,7 @@ target_include_directories(TZshot PRIVATE
src/paint_board/shape
src/model
src/viewmodel
+ src/scroll
src/shortcut_key
)
@@ -243,6 +248,11 @@ if(WIN32)
set(OCR_THIRDPART_DIR "${CMAKE_CURRENT_SOURCE_DIR}/thirdpart")
set(OCR_INSTALL_DIR "${OCR_THIRDPART_DIR}/ocr-install")
set(OCR_TESSDATA_DIR "${OCR_INSTALL_DIR}/share/tessdata")
+ # ExternalProject build trees must be generator-specific.
+ # Otherwise switching between Ninja/VS reuses incompatible CMake caches.
+ set(OCR_EP_KEY "${CMAKE_GENERATOR}-${CMAKE_GENERATOR_PLATFORM}")
+ string(REGEX REPLACE "[^A-Za-z0-9_.-]" "_" OCR_EP_KEY "${OCR_EP_KEY}")
+ set(OCR_EP_ROOT_DIR "${OCR_THIRDPART_DIR}/_ep_${OCR_EP_KEY}")
set(OCR_ZLIB_VERSION "v1.3.1")
set(OCR_LIBPNG_VERSION "v1.6.43")
set(OCR_LIBJPEG_TURBO_VERSION "3.0.3")
@@ -270,7 +280,7 @@ if(WIN32)
endif()
ExternalProject_Add(zlib_ep
- PREFIX "${OCR_THIRDPART_DIR}/zlib"
+ PREFIX "${OCR_EP_ROOT_DIR}/zlib"
GIT_REPOSITORY "https://github.com/madler/zlib.git"
GIT_TAG "${OCR_ZLIB_VERSION}"
UPDATE_DISCONNECTED 1
@@ -282,7 +292,7 @@ if(WIN32)
)
ExternalProject_Add(libpng_ep
- PREFIX "${OCR_THIRDPART_DIR}/libpng"
+ PREFIX "${OCR_EP_ROOT_DIR}/libpng"
GIT_REPOSITORY "https://github.com/pnggroup/libpng.git"
GIT_TAG "${OCR_LIBPNG_VERSION}"
UPDATE_DISCONNECTED 1
@@ -299,7 +309,7 @@ if(WIN32)
)
ExternalProject_Add(libjpeg_ep
- PREFIX "${OCR_THIRDPART_DIR}/libjpeg"
+ PREFIX "${OCR_EP_ROOT_DIR}/libjpeg"
GIT_REPOSITORY "https://github.com/libjpeg-turbo/libjpeg-turbo.git"
GIT_TAG "${OCR_LIBJPEG_TURBO_VERSION}"
UPDATE_DISCONNECTED 1
@@ -314,7 +324,7 @@ if(WIN32)
)
ExternalProject_Add(libtiff_ep
- PREFIX "${OCR_THIRDPART_DIR}/libtiff"
+ PREFIX "${OCR_EP_ROOT_DIR}/libtiff"
GIT_REPOSITORY "https://github.com/libsdl-org/libtiff.git"
GIT_TAG "${OCR_LIBTIFF_VERSION}"
UPDATE_DISCONNECTED 1
@@ -338,7 +348,7 @@ if(WIN32)
)
ExternalProject_Add(leptonica_ep
- PREFIX "${OCR_THIRDPART_DIR}/leptonica"
+ PREFIX "${OCR_EP_ROOT_DIR}/leptonica"
GIT_REPOSITORY "https://github.com/DanBloomberg/leptonica.git"
GIT_TAG "${OCR_LEPTONICA_VERSION}"
UPDATE_DISCONNECTED 1
@@ -361,7 +371,7 @@ if(WIN32)
)
ExternalProject_Add(tesseract_ep
- PREFIX "${OCR_THIRDPART_DIR}/tesseract"
+ PREFIX "${OCR_EP_ROOT_DIR}/tesseract"
GIT_REPOSITORY "https://github.com/tesseract-ocr/tesseract.git"
GIT_TAG "${OCR_TESSERACT_VERSION}"
UPDATE_DISCONNECTED 1
diff --git a/qml.qrc b/qml.qrc
index ebf2bed..da81f24 100644
--- a/qml.qrc
+++ b/qml.qrc
@@ -40,8 +40,12 @@
qml/TZLoadingSpinner.qml
qml/TZGifRecordBar.qml
qml/TZOcrResult.qml
+ qml/TZLongCaptureBar.qml
+ qml/TZLongCapturePreview.qml
+ qml/TZLongCaptureFrame.qml
resource/img/lc_gif_record.svg
resource/img/lc_gif_stop.svg
resource/img/lc_ocr.svg
+ resource/img/lc_longshot.svg
diff --git a/qml/TZLongCaptureBar.qml b/qml/TZLongCaptureBar.qml
new file mode 100644
index 0000000..d77bc11
--- /dev/null
+++ b/qml/TZLongCaptureBar.qml
@@ -0,0 +1,109 @@
+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/TZLongCaptureFrame.qml b/qml/TZLongCaptureFrame.qml
new file mode 100644
index 0000000..24e2739
--- /dev/null
+++ b/qml/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/TZLongCapturePreview.qml b/qml/TZLongCapturePreview.qml
new file mode 100644
index 0000000..dcead86
--- /dev/null
+++ b/qml/TZLongCapturePreview.qml
@@ -0,0 +1,82 @@
+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/TZTool.qml b/qml/TZTool.qml
index fbcd9ad..6482025 100644
--- a/qml/TZTool.qml
+++ b/qml/TZTool.qml
@@ -17,6 +17,7 @@ Rectangle {
signal signalSticky
signal signalUndo
signal signalGifRecord
+ signal signalLongScreenshot
signal signalOcr
property string activeTool: ""
@@ -100,6 +101,13 @@ Rectangle {
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 {
+ 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 { width: 1; height: 20; color: "#E2E8F0"; Layout.alignment: Qt.AlignVCenter }
Rectangle {
diff --git a/qml/TZWindow.qml b/qml/TZWindow.qml
index ebcc2d0..acd06d0 100644
--- a/qml/TZWindow.qml
+++ b/qml/TZWindow.qml
@@ -592,7 +592,7 @@ Window {
TZTool {
id: tool
- width: 620; height: 44
+ width: 656; height: 44
z: 30
// Toolbar position (local coordinates)
@@ -660,6 +660,11 @@ Window {
})
}
+ onSignalLongScreenshot: {
+ var captureRect = screenshotArea
+ root.beginLongCapture(captureRect)
+ }
+
onSignalOcr: {
var captureRect = screenshotArea
Qt.callLater(function() {
diff --git a/qml/main.qml b/qml/main.qml
index 8402ecb..cabf921 100644
--- a/qml/main.qml
+++ b/qml/main.qml
@@ -13,6 +13,13 @@ TZWindow {
property var settingWinObj: null
property var aboutWinObj: null
+ property var longCaptureBarObj: null
+ property var longCaptureBarComponent: null
+ property var longCapturePreviewObj: null
+ property var longCapturePreviewComponent: null
+ property var longCaptureFrameObj: null
+ property var longCaptureFrameComponent: null
+ property rect pendingLongCaptureRect: Qt.rect(0, 0, 0, 0)
property var overlayList: []
property var overlayComponent: null
@@ -130,6 +137,115 @@ TZWindow {
overlayList = []
}
+ function showLongCaptureBar() {
+ if (longCaptureBarObj !== null) {
+ longCaptureBarObj.visible = true
+ longCaptureBarObj.raise()
+ return
+ }
+ if (longCaptureBarComponent === null)
+ longCaptureBarComponent = Qt.createComponent("qrc:/qml/TZLongCaptureBar.qml")
+ if (longCaptureBarComponent.status === Component.Ready) {
+ longCaptureBarObj = longCaptureBarComponent.createObject(null)
+ if (longCaptureBarObj) {
+ longCaptureBarObj.x = Math.round((Screen.width - longCaptureBarObj.width) / 2)
+ longCaptureBarObj.y = 26
+ longCaptureBarObj.visible = true
+ longCaptureBarObj.raise()
+ longCaptureBarObj.startRequested.connect(function() {
+ if (pendingLongCaptureRect.width === 0 || pendingLongCaptureRect.height === 0)
+ return
+ longCaptureBarObj.running = true
+ longCaptureBarObj.message = qsTr("滚动检测中,滑动内容会自动拼接")
+ O_ScrollCaptureVM.start(pendingLongCaptureRect)
+ })
+ longCaptureBarObj.stopRequested.connect(function() {
+ if (longCaptureBarObj.running) {
+ O_ScrollCaptureVM.stop()
+ } else {
+ hideLongCaptureBar()
+ root.resetArea()
+ }
+ })
+ }
+ } else if (longCaptureBarComponent.status === Component.Error) {
+ console.error("LongCaptureBar load failed:", longCaptureBarComponent.errorString())
+ }
+ }
+
+ function showLongCapturePreview() {
+ if (longCapturePreviewObj !== null) {
+ longCapturePreviewObj.visible = true
+ longCapturePreviewObj.raise()
+ return
+ }
+ if (longCapturePreviewComponent === null)
+ longCapturePreviewComponent = Qt.createComponent("qrc:/qml/TZLongCapturePreview.qml")
+ if (longCapturePreviewComponent.status === Component.Ready) {
+ longCapturePreviewObj = longCapturePreviewComponent.createObject(null)
+ if (longCapturePreviewObj) {
+ longCapturePreviewObj.x = Math.max(0, Screen.width - longCapturePreviewObj.width - 26)
+ longCapturePreviewObj.y = Math.max(20, Math.round((Screen.height - longCapturePreviewObj.height) / 2))
+ longCapturePreviewObj.visible = true
+ longCapturePreviewObj.raise()
+ }
+ } else if (longCapturePreviewComponent.status === Component.Error) {
+ console.error("LongCapturePreview load failed:", longCapturePreviewComponent.errorString())
+ }
+ }
+
+ function showLongCaptureFrame(captureRect) {
+ if (longCaptureFrameComponent === null)
+ longCaptureFrameComponent = Qt.createComponent("qrc:/qml/TZLongCaptureFrame.qml")
+ if (longCaptureFrameObj === null && longCaptureFrameComponent.status === Component.Ready) {
+ longCaptureFrameObj = longCaptureFrameComponent.createObject(null)
+ } else if (longCaptureFrameComponent.status === Component.Error) {
+ console.error("LongCaptureFrame load failed:", longCaptureFrameComponent.errorString())
+ }
+ if (longCaptureFrameObj) {
+ longCaptureFrameObj.captureRect = captureRect
+ longCaptureFrameObj.visible = true
+ longCaptureFrameObj.raise()
+ }
+ }
+
+ function hideLongCaptureBar() {
+ if (longCaptureBarObj !== null) {
+ longCaptureBarObj.close()
+ longCaptureBarObj.destroy(100)
+ longCaptureBarObj = null
+ }
+ if (longCapturePreviewObj !== null) {
+ longCapturePreviewObj.close()
+ longCapturePreviewObj.destroy(100)
+ longCapturePreviewObj = null
+ }
+ if (longCaptureFrameObj !== null) {
+ longCaptureFrameObj.close()
+ longCaptureFrameObj.destroy(100)
+ longCaptureFrameObj = null
+ }
+ pendingLongCaptureRect = Qt.rect(0, 0, 0, 0)
+ }
+
+ function beginLongCapture(captureRect) {
+ pendingLongCaptureRect = captureRect
+ root.visibility = Window.Hidden
+ showLongCaptureBar()
+ showLongCapturePreview()
+ showLongCaptureFrame(captureRect)
+ if (longCaptureBarObj) {
+ longCaptureBarObj.running = false
+ longCaptureBarObj.frameCount = 0
+ longCaptureBarObj.message = qsTr("点击开始后,滑动内容自动拼接")
+ }
+ if (longCapturePreviewObj) {
+ longCapturePreviewObj.previewUrl = ""
+ longCapturePreviewObj.frameCount = 0
+ longCapturePreviewObj.statusText = qsTr("等待开始")
+ }
+ }
+
function resetArea() {
_destroyOverlays()
_prepareSessionState()
@@ -204,6 +320,51 @@ TZWindow {
}
}
+ Connections {
+ target: O_ScrollCaptureVM
+ function onCaptureStarted() {
+ if (longCaptureBarObj) {
+ longCaptureBarObj.running = true
+ longCaptureBarObj.message = qsTr("滚动检测中,滑动内容会自动拼接")
+ }
+ trayHelper.showMessage(qsTr("长截图开始"), qsTr("请直接滚动目标内容,系统会在滚动变化时自动拼接"))
+ }
+ function onCaptureSucceeded(savedPath) {
+ if (longCaptureBarObj) {
+ longCaptureBarObj.running = false
+ longCaptureBarObj.message = qsTr("长截图完成")
+ }
+ root.hideLongCaptureBar()
+ root.resetArea()
+ trayHelper.showMessage(qsTr("长截图完成"), qsTr("已复制到剪贴板并保存到:") + savedPath)
+ }
+ function onCaptureFailed(errorMessage) {
+ if (longCaptureBarObj) {
+ longCaptureBarObj.running = false
+ longCaptureBarObj.message = qsTr("长截图失败")
+ }
+ root.hideLongCaptureBar()
+ root.resetArea()
+ trayHelper.showMessage(qsTr("长截图失败"), errorMessage)
+ }
+ function onCapturedFramesChanged() {
+ if (longCaptureBarObj)
+ longCaptureBarObj.frameCount = O_ScrollCaptureVM.capturedFrames
+ if (longCapturePreviewObj)
+ longCapturePreviewObj.frameCount = O_ScrollCaptureVM.capturedFrames
+ }
+ function onStatusTextChanged() {
+ if (longCaptureBarObj)
+ longCaptureBarObj.message = O_ScrollCaptureVM.statusText
+ if (longCapturePreviewObj)
+ longCapturePreviewObj.statusText = O_ScrollCaptureVM.statusText
+ }
+ function onPreviewImageUrlChanged() {
+ if (longCapturePreviewObj)
+ longCapturePreviewObj.previewUrl = O_ScrollCaptureVM.previewImageUrl
+ }
+ }
+
Connections {
target: O_InstanceBridge
function onActivationRequested() {
diff --git a/resource/img/lc_longshot.svg b/resource/img/lc_longshot.svg
new file mode 100644
index 0000000..246104a
--- /dev/null
+++ b/resource/img/lc_longshot.svg
@@ -0,0 +1,6 @@
+
diff --git a/resource/img/tray_icon.svg b/resource/img/tray_icon.svg
index 3f0fb4b..c6e9120 100644
--- a/resource/img/tray_icon.svg
+++ b/resource/img/tray_icon.svg
@@ -1,14 +1,4 @@
diff --git a/src/main.cpp b/src/main.cpp
index 674d9ac..22acacb 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -21,6 +21,7 @@
#include "viewmodel/ai_view_model.h"
#include "viewmodel/storage_view_model.h"
#include "viewmodel/gif_record_view_model.h"
+#include "viewmodel/scroll_capture_view_model.h"
#include "viewmodel/ocr_view_model.h"
int main(int argc, char *argv[])
@@ -51,6 +52,7 @@ int main(int argc, char *argv[])
StorageViewModel storageViewModel(appSettings);
LanguageManager languageManager(appSettings);
GifRecordViewModel gifRecordViewModel(appSettings);
+ ScrollCaptureViewModel scrollCaptureViewModel(screenshotViewModel, appSettings);
OcrViewModel ocrViewModel;
@@ -78,6 +80,7 @@ int main(int argc, char *argv[])
engine.rootContext()->setContextProperty("O_GlobalShortcut", &globalShortcut);
engine.rootContext()->setContextProperty("O_LanguageManager", &languageManager);
engine.rootContext()->setContextProperty("O_GifRecordVM", &gifRecordViewModel);
+ engine.rootContext()->setContextProperty("O_ScrollCaptureVM", &scrollCaptureViewModel);
engine.rootContext()->setContextProperty("O_OcrVM", &ocrViewModel);
engine.rootContext()->setContextProperty("O_InstanceBridge", &instanceActivation);
diff --git a/src/scroll/scroll_stitcher.cpp b/src/scroll/scroll_stitcher.cpp
new file mode 100644
index 0000000..bd55495
--- /dev/null
+++ b/src/scroll/scroll_stitcher.cpp
@@ -0,0 +1,120 @@
+#include "scroll_stitcher.h"
+
+#include
+#include
+
+void ScrollStitcher::reset()
+{
+ m_lastFrame = QImage();
+ m_result = QImage();
+}
+
+bool ScrollStitcher::hasBase() const
+{
+ return !m_lastFrame.isNull();
+}
+
+void ScrollStitcher::setBaseFrame(const QImage &frame)
+{
+ m_lastFrame = frame;
+ m_result = frame;
+}
+
+int ScrollStitcher::appendFrame(const QImage &frame)
+{
+ if (frame.isNull()) {
+ return 0;
+ }
+ if (m_lastFrame.isNull() || m_result.isNull()) {
+ setBaseFrame(frame);
+ return frame.height();
+ }
+
+ QImage curr = frame;
+ if (curr.size() != m_lastFrame.size()) {
+ curr = curr.scaled(m_lastFrame.size(), Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
+ }
+
+ const int overlap = findBestOverlap(m_lastFrame, curr);
+ if (overlap <= 0 || overlap >= curr.height()) {
+ m_lastFrame = curr;
+ return 0;
+ }
+
+ const int appendHeight = curr.height() - overlap;
+ if (appendHeight < 8) {
+ m_lastFrame = curr;
+ return 0;
+ }
+
+ QImage merged(m_result.width(), m_result.height() + appendHeight, QImage::Format_ARGB32_Premultiplied);
+ merged.fill(Qt::transparent);
+
+ QPainter painter(&merged);
+ painter.drawImage(0, 0, m_result);
+ painter.drawImage(
+ QRect(0, m_result.height(), curr.width(), appendHeight),
+ curr,
+ QRect(0, overlap, curr.width(), appendHeight));
+ painter.end();
+
+ m_result = merged;
+ m_lastFrame = curr;
+ return appendHeight;
+}
+
+int ScrollStitcher::findBestOverlap(const QImage &prev, const QImage &curr) const
+{
+ const int w = qMin(prev.width(), curr.width());
+ const int h = qMin(prev.height(), curr.height());
+ if (w < 80 || h < 80) {
+ return -1;
+ }
+
+ const int minOverlap = qMax(20, h * 20 / 100);
+ const int maxOverlap = qMax(minOverlap + 1, h * 85 / 100);
+ const int step = 3;
+
+ int bestOverlap = -1;
+ qint64 bestScore = std::numeric_limits::max();
+
+ const int left = w * 10 / 100;
+ const int right = w * 90 / 100;
+
+ for (int overlap = minOverlap; overlap <= maxOverlap; overlap += 2) {
+ qint64 score = 0;
+ int samples = 0;
+
+ for (int y = 0; y < overlap; y += step) {
+ const int py = prev.height() - overlap + y;
+ const int cy = y;
+ for (int x = left; x < right; x += step) {
+ const int g1 = grayAt(prev, x, py);
+ const int g2 = grayAt(curr, x, cy);
+ score += qAbs(g1 - g2);
+ ++samples;
+ }
+ }
+
+ if (samples == 0) {
+ continue;
+ }
+ const qint64 norm = score / samples;
+ if (norm < bestScore) {
+ bestScore = norm;
+ bestOverlap = overlap;
+ }
+ }
+
+ // Empirical threshold for noisy desktop content.
+ if (bestScore > 22) {
+ return -1;
+ }
+ return bestOverlap;
+}
+
+int ScrollStitcher::grayAt(const QImage &img, int x, int y)
+{
+ const QRgb p = img.pixel(x, y);
+ return (qRed(p) * 30 + qGreen(p) * 59 + qBlue(p) * 11) / 100;
+}
diff --git a/src/scroll/scroll_stitcher.h b/src/scroll/scroll_stitcher.h
new file mode 100644
index 0000000..d597ad5
--- /dev/null
+++ b/src/scroll/scroll_stitcher.h
@@ -0,0 +1,27 @@
+#ifndef SCROLL_STITCHER_H
+#define SCROLL_STITCHER_H
+
+#include
+
+class ScrollStitcher
+{
+public:
+ void reset();
+ bool hasBase() const;
+
+ void setBaseFrame(const QImage &frame);
+
+ // Returns appended pixel height. 0 means no new area appended.
+ int appendFrame(const QImage &frame);
+
+ QImage result() const { return m_result; }
+
+private:
+ int findBestOverlap(const QImage &prev, const QImage &curr) const;
+ static int grayAt(const QImage &img, int x, int y);
+
+ QImage m_lastFrame;
+ QImage m_result;
+};
+
+#endif // SCROLL_STITCHER_H
diff --git a/src/viewmodel/screenshot_view_model.cpp b/src/viewmodel/screenshot_view_model.cpp
index af401ab..0a36172 100644
--- a/src/viewmodel/screenshot_view_model.cpp
+++ b/src/viewmodel/screenshot_view_model.cpp
@@ -133,6 +133,22 @@ QImage ScreenshotViewModel::captureRectToImage(const QRect &rect)
return screen->grabWindow(0, r.x(), r.y(), r.width(), r.height()).toImage();
}
+QImage ScreenshotViewModel::captureRectToImageLive(const QRect &rect)
+{
+ const QRect r = rect.normalized();
+ if (r.isEmpty()) {
+ return {};
+ }
+
+ m_snapshot.grab();
+ return captureRectToImage(r);
+}
+
+bool ScreenshotViewModel::copyImageToClipboard(const QImage &image)
+{
+ return writeImageToClipboard(image);
+}
+
// ── captureScreen (private) ───────────────────────────────
QImage ScreenshotViewModel::captureScreen(QQuickItem *paintBoard,
const QRect &rect,
diff --git a/src/viewmodel/screenshot_view_model.h b/src/viewmodel/screenshot_view_model.h
index 3cd3453..32889a8 100644
--- a/src/viewmodel/screenshot_view_model.h
+++ b/src/viewmodel/screenshot_view_model.h
@@ -59,6 +59,12 @@ public:
// 截图选区并直接返回 QImage(OCR 使用)
Q_INVOKABLE QImage captureRectToImage(const QRect &rect);
+ // 实时抓取选区(每次都会刷新桌面快照),用于长截图连续采样。
+ QImage captureRectToImageLive(const QRect &rect);
+
+ // 复制任意图像到剪贴板。
+ bool copyImageToClipboard(const QImage &image);
+
private:
// 内部:从快照裁剪选区并叠加 PaintBoard 标注层
// setBackground=true 时将结果写回 PaintBoard 背景(仅马赛克路径)
diff --git a/src/viewmodel/scroll_capture_view_model.cpp b/src/viewmodel/scroll_capture_view_model.cpp
new file mode 100644
index 0000000..c7b5443
--- /dev/null
+++ b/src/viewmodel/scroll_capture_view_model.cpp
@@ -0,0 +1,221 @@
+#include "scroll_capture_view_model.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+constexpr int kMaxManualFrames = 200;
+constexpr int kAutoSampleMs = 90;
+constexpr int kProbeWidth = 72;
+constexpr int kProbeHeight = 72;
+constexpr int kAvgDiffThreshold = 3;
+}
+
+ScrollCaptureViewModel::ScrollCaptureViewModel(ScreenshotViewModel &screenCapture,
+ AppSettings &settings,
+ QObject *parent)
+ : QObject(parent)
+ , m_screenCapture(screenCapture)
+ , m_settings(settings)
+{
+ m_timer.setInterval(kAutoSampleMs);
+ connect(&m_timer, &QTimer::timeout, this, [this]() { onTick(); });
+}
+
+void ScrollCaptureViewModel::start(const QRect &captureRect)
+{
+ if (m_isCapturing) {
+ return;
+ }
+
+ const QRect rect = captureRect.normalized();
+ if (rect.width() < 20 || rect.height() < 20) {
+ emit captureFailed(tr("长截图区域无效"));
+ return;
+ }
+
+ m_captureRect = rect;
+ m_stitcher.reset();
+ m_capturedFrames = 0;
+ emit capturedFramesChanged();
+
+ const QImage first = m_screenCapture.captureRectToImageLive(m_captureRect);
+ if (first.isNull()) {
+ emit captureFailed(tr("首帧采样失败"));
+ return;
+ }
+
+ m_stitcher.setBaseFrame(first);
+ m_capturedFrames = 1;
+ emit capturedFramesChanged();
+ setPreviewImage(m_stitcher.result());
+ m_lastProbe = makeProbe(first);
+
+ m_isCapturing = true;
+ emit isCapturingChanged();
+ emit captureStarted();
+ setStatus(tr("滚动检测已开启,滑动内容会自动拼接"));
+ m_timer.start();
+}
+
+void ScrollCaptureViewModel::stop()
+{
+ if (!m_isCapturing) {
+ return;
+ }
+
+ const QImage current = m_stitcher.result();
+ if (!current.isNull()) {
+ finishSuccess(current);
+ } else {
+ finishWithError(tr("长截图已停止"));
+ }
+}
+
+void ScrollCaptureViewModel::onTick()
+{
+ if (!m_isCapturing) {
+ return;
+ }
+
+ const QImage frame = m_screenCapture.captureRectToImageLive(m_captureRect);
+ if (frame.isNull()) {
+ finishWithError(tr("采样失败,已中断"));
+ return;
+ }
+
+ if (!hasMeaningfulChange(frame)) {
+ setStatus(tr("等待滚动变化..."));
+ return;
+ }
+
+ ++m_capturedFrames;
+ emit capturedFramesChanged();
+ const int added = m_stitcher.appendFrame(frame);
+ setPreviewImage(m_stitcher.result());
+ if (added > 0) {
+ setStatus(tr("滚动已捕捉,已采样 %1 帧").arg(m_capturedFrames));
+ } else {
+ setStatus(tr("检测到变化但拼接增量为 0,请继续滚动"));
+ }
+
+ if (m_capturedFrames >= kMaxManualFrames) {
+ finishWithError(tr("采样帧数超过上限,请先结束并保存"));
+ }
+}
+
+void ScrollCaptureViewModel::setStatus(const QString &text)
+{
+ if (m_statusText == text) {
+ return;
+ }
+ m_statusText = text;
+ emit statusTextChanged();
+}
+
+QImage ScrollCaptureViewModel::makeProbe(const QImage &image) const
+{
+ if (image.isNull()) {
+ return QImage();
+ }
+ QImage gray = image.convertToFormat(QImage::Format_Grayscale8);
+ return gray.scaled(kProbeWidth, kProbeHeight, Qt::IgnoreAspectRatio, Qt::FastTransformation);
+}
+
+bool ScrollCaptureViewModel::hasMeaningfulChange(const QImage &image)
+{
+ const QImage currentProbe = makeProbe(image);
+ if (currentProbe.isNull()) {
+ return false;
+ }
+ if (m_lastProbe.isNull() || m_lastProbe.size() != currentProbe.size()) {
+ m_lastProbe = currentProbe;
+ return true;
+ }
+
+ qint64 sum = 0;
+ const int w = currentProbe.width();
+ const int h = currentProbe.height();
+ for (int y = 0; y < h; ++y) {
+ const uchar *a = currentProbe.constScanLine(y);
+ const uchar *b = m_lastProbe.constScanLine(y);
+ for (int x = 0; x < w; ++x) {
+ sum += qAbs(int(a[x]) - int(b[x]));
+ }
+ }
+
+ const int avg = int(sum / qMax(1, w * h));
+ const bool changed = avg >= kAvgDiffThreshold;
+ if (changed) {
+ m_lastProbe = currentProbe;
+ }
+ return changed;
+}
+
+void ScrollCaptureViewModel::setPreviewImage(const QImage &image)
+{
+ QString next;
+ if (!image.isNull()) {
+ QByteArray bytes;
+ QBuffer buffer(&bytes);
+ buffer.open(QIODevice::WriteOnly);
+ image.save(&buffer, "PNG");
+ next = QStringLiteral("data:image/png;base64,%1")
+ .arg(QString::fromLatin1(bytes.toBase64()));
+ }
+
+ if (m_previewImageUrl == next) {
+ return;
+ }
+ m_previewImageUrl = next;
+ emit previewImageUrlChanged();
+}
+
+void ScrollCaptureViewModel::finishWithError(const QString &text)
+{
+ m_timer.stop();
+ m_isCapturing = false;
+ emit isCapturingChanged();
+ setStatus(text);
+ emit captureFailed(text);
+}
+
+void ScrollCaptureViewModel::finishSuccess(const QImage &result)
+{
+ m_timer.stop();
+ m_isCapturing = false;
+ emit isCapturingChanged();
+
+ if (result.isNull()) {
+ emit captureFailed(tr("拼接失败,结果为空"));
+ return;
+ }
+ if (result.height() <= m_captureRect.height() + 20) {
+ emit captureFailed(tr("未检测到有效滚动,请先激活目标窗口后重试"));
+ return;
+ }
+
+ m_screenCapture.copyImageToClipboard(result);
+
+ QString dirPath = m_settings.savePath();
+ if (dirPath.isEmpty()) {
+ dirPath = QDir::homePath();
+ }
+ QDir().mkpath(dirPath);
+ const QString fileName = QStringLiteral("long_%1.png")
+ .arg(QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"));
+ const QString savePath = QDir(dirPath).filePath(fileName);
+ result.save(savePath, "PNG");
+
+ const QString saveDirPath = QFileInfo(savePath).absolutePath();
+ if (!saveDirPath.isEmpty()) {
+ QDesktopServices::openUrl(QUrl::fromLocalFile(saveDirPath));
+ }
+
+ setStatus(tr("长截图完成"));
+ emit captureSucceeded(QFileInfo(savePath).absoluteFilePath());
+}
diff --git a/src/viewmodel/scroll_capture_view_model.h b/src/viewmodel/scroll_capture_view_model.h
new file mode 100644
index 0000000..51b4e20
--- /dev/null
+++ b/src/viewmodel/scroll_capture_view_model.h
@@ -0,0 +1,68 @@
+#ifndef SCROLL_CAPTURE_VIEW_MODEL_H
+#define SCROLL_CAPTURE_VIEW_MODEL_H
+
+#include
+#include
+#include
+#include
+#include
+
+#include "model/app_settings.h"
+#include "viewmodel/screenshot_view_model.h"
+#include "scroll/scroll_stitcher.h"
+
+class ScrollCaptureViewModel : public QObject
+{
+ Q_OBJECT
+
+ Q_PROPERTY(bool isCapturing READ isCapturing NOTIFY isCapturingChanged)
+ Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged)
+ Q_PROPERTY(int capturedFrames READ capturedFrames NOTIFY capturedFramesChanged)
+ Q_PROPERTY(QString previewImageUrl READ previewImageUrl NOTIFY previewImageUrlChanged)
+
+public:
+ explicit ScrollCaptureViewModel(ScreenshotViewModel &screenCapture,
+ AppSettings &settings,
+ QObject *parent = nullptr);
+
+ bool isCapturing() const { return m_isCapturing; }
+ QString statusText() const { return m_statusText; }
+ int capturedFrames() const { return m_capturedFrames; }
+ QString previewImageUrl() const { return m_previewImageUrl; }
+
+ Q_INVOKABLE void start(const QRect &captureRect);
+ Q_INVOKABLE void stop();
+
+signals:
+ void captureStarted();
+ void isCapturingChanged();
+ void statusTextChanged();
+ void capturedFramesChanged();
+ void previewImageUrlChanged();
+
+ void captureSucceeded(const QString &savedPath);
+ void captureFailed(const QString &errorMessage);
+
+private:
+ void onTick();
+ void setStatus(const QString &text);
+ void setPreviewImage(const QImage &image);
+ QImage makeProbe(const QImage &image) const;
+ bool hasMeaningfulChange(const QImage &image);
+ void finishWithError(const QString &text);
+ void finishSuccess(const QImage &result);
+
+ ScreenshotViewModel &m_screenCapture;
+ AppSettings &m_settings;
+ ScrollStitcher m_stitcher;
+ QTimer m_timer;
+
+ QRect m_captureRect;
+ bool m_isCapturing = false;
+ int m_capturedFrames = 0;
+ QString m_statusText;
+ QString m_previewImageUrl;
+ QImage m_lastProbe;
+};
+
+#endif // SCROLL_CAPTURE_VIEW_MODEL_H