From 40e637b0e8749833028efa906f240f2e6d421f33 Mon Sep 17 00:00:00 2001 From: dengzhongyuan365-dev Date: Sun, 2 Aug 2026 08:37:11 +0800 Subject: [PATCH 1/2] feat(tiptap): add scroll reporting channel and wire host interactions Add jsReportScroll entry point and scrollChanged signal to TiptapChannelBridge for receiving scroll position from the frontend editor, connected in VNoteMainManager. Wire tiptapWebView in WebEngineView.qml for context menu, scroll, focus, and findText search. Add Qt5/Qt6 context menu adaptation in web_engine_handler. Add unit tests for scroll reporting. --- src/common/VNoteMainManager.cpp | 1 + src/common/tiptapchannelbridge.cpp | 10 +++ src/common/tiptapchannelbridge.h | 6 ++ src/gui/mainwindow/WebEngineView.qml | 63 ++++++++++++++++++- src/handler/web_engine_handler.cpp | 12 ++-- .../tiptapchannel/ut_tiptapchannelbridge.cpp | 24 +++++++ 6 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/common/VNoteMainManager.cpp b/src/common/VNoteMainManager.cpp index 749ea4c4..225becfa 100644 --- a/src/common/VNoteMainManager.cpp +++ b/src/common/VNoteMainManager.cpp @@ -127,6 +127,7 @@ void VNoteMainManager::initConnections() connect(m_richTextManager, &WebRichTextManager::noteTextChanged, this, &VNoteMainManager::onNoteChanged, Qt::QueuedConnection); connect(m_richTextManager, &WebRichTextManager::updateSearch, this, &VNoteMainManager::updateSearch); connect(m_richTextManager, &WebRichTextManager::scrollChange, this, &VNoteMainManager::scrollChange); + connect(TiptapChannelBridge::instance(), &TiptapChannelBridge::scrollChanged, this, &VNoteMainManager::scrollChange); connect(m_richTextManager, &WebRichTextManager::finishedUpdateNote, this, &VNoteMainManager::onRichTextSaveFinished); connect(VoiceRecoderHandler::instance(), &VoiceRecoderHandler::finishedRecod, this, &VNoteMainManager::insertVoice); qInfo() << "Connections initialized"; diff --git a/src/common/tiptapchannelbridge.cpp b/src/common/tiptapchannelbridge.cpp index d38afadc..11532a37 100644 --- a/src/common/tiptapchannelbridge.cpp +++ b/src/common/tiptapchannelbridge.cpp @@ -306,6 +306,16 @@ void TiptapChannelBridge::jsPasteImage(const QString &dataUrl) emit insertImage(QJsonDocument(info).toJson(QJsonDocument::Compact)); } +// --------------------------------------------------------------------------- +// 滚动位置上报(JS→C++) +// --------------------------------------------------------------------------- + +void TiptapChannelBridge::jsReportScroll(int scrollTop) +{ + const bool isTop = (scrollTop <= 0); + emit scrollChanged(isTop); +} + // --------------------------------------------------------------------------- // Voice 播放/转写入口(JS→C++) diff --git a/src/common/tiptapchannelbridge.h b/src/common/tiptapchannelbridge.h index 74e26a35..79558c9b 100644 --- a/src/common/tiptapchannelbridge.h +++ b/src/common/tiptapchannelbridge.h @@ -94,6 +94,9 @@ class TiptapChannelBridge : public QObject // 前端粘贴剪贴板图片数据(data URL),宿主保存到 images/ 后回插 Q_INVOKABLE void jsPasteImage(const QString &dataUrl); + // 前端上报编辑器滚动位置(scrollTop),宿主据此驱动标题栏阴影状态 + Q_INVOKABLE void jsReportScroll(int scrollTop); + // 宿主侧下发字体列表(未就绪时缓存,就绪后补发) Q_INVOKABLE void sendFontList(const QStringList &fonts, const QString &defaultFont); @@ -172,6 +175,9 @@ class TiptapChannelBridge : public QObject void themeProvided(const QString &theme, const QString &highlightColor, const QString &disableHighlightColor, const QString &backgroundColor); + // JS→C++:滚动位置上报(isTop=true 表示已滚到顶部) + void scrollChanged(bool isTop); + // C++ 内部请求信号(WebEngineHandler 连接处理) void voicePlaybackRequested(const QString &voiceInfoJson, bool isSame); void voicePlaybackStopRequested(); diff --git a/src/gui/mainwindow/WebEngineView.qml b/src/gui/mainwindow/WebEngineView.qml index 52088981..76093387 100644 --- a/src/gui/mainwindow/WebEngineView.qml +++ b/src/gui/mainwindow/WebEngineView.qml @@ -451,7 +451,11 @@ Item { messageDialogLoader.showDialog(type); } onTriggerWebAction: action => { - webView.triggerWebAction(action); + if (TiptapChannel.debugEnabled && tiptapLoader.item) { + tiptapWebView.triggerWebAction(action); + } else { + webView.triggerWebAction(action); + } } onViewPicture: filePath => { viewPictureLoader.path = filePath; @@ -501,6 +505,34 @@ Item { tiptapWebView.webChannel = tiptapWebChannel; tiptapWebView.url = Qt.resolvedUrl(TiptapChannel.tiptapHtmlPath()); } + + onContextMenuRequested: req => { + req.accepted = true; + var x = req.position.x; + var y = req.position.y; + var probeJs = "(function(){" + + "var el = document.elementFromPoint(" + x + "," + y + ");" + + "if (!el) return JSON.stringify({type:2,json:''});" + + "var voiceBox = el.closest ? el.closest('.voiceInfoBox') : null;" + + "if (voiceBox && voiceBox.getAttribute('data-type') === 'voice-block') {" + + " var meta = voiceBox.getAttribute('data-voice-meta') || '';" + + " return JSON.stringify({type:1,json:meta});" + + "}" + + "var img = el.closest ? el.closest('img[data-rel-path]') : null;" + + "if (img) return JSON.stringify({type:0,json:''});" + + "return JSON.stringify({type:2,json:''});" + + "})()"; + tiptapWebView.runJavaScript(probeJs, function(result) { + var info = null; + try { info = JSON.parse(result); } catch(e) {} + if (!info) return; + if (info.type === 0) { + return; + } + handler.onSaveMenuParam(info.type, info.json); + handler.onContextMenuRequested(req); + }); + } } DropArea { @@ -807,7 +839,11 @@ Item { hasScroll = !isTop; } onUpdateRichTextSearch: key => { - webView.findText(key); + if (TiptapChannel.debugEnabled && tiptapLoader.item) { + tiptapWebView.findText(key); + } else { + webView.findText(key); + } } } @@ -871,4 +907,27 @@ Item { } } } + + Connections { + target: Webobj + + onCallJsSelectAll: { + if (TiptapChannel.debugEnabled && tiptapLoader.item) { + tiptapWebView.runJavaScript( + "if(window.__dvnTiptapEditor)window.__dvnTiptapEditor.chain().selectAll().run()"); + } + } + onCallJsDeleteSelection: { + if (TiptapChannel.debugEnabled && tiptapLoader.item) { + tiptapWebView.runJavaScript( + "if(window.__dvnTiptapEditor)window.__dvnTiptapEditor.chain().deleteSelection().run()"); + } + } + onCallJsFocusEditor: { + if (TiptapChannel.debugEnabled && tiptapLoader.item) { + tiptapWebView.runJavaScript( + "if(window.__dvnTiptapEditor)window.__dvnTiptapEditor.commands.focus()"); + } + } + } } diff --git a/src/handler/web_engine_handler.cpp b/src/handler/web_engine_handler.cpp index a9994a00..3e2f6b27 100644 --- a/src/handler/web_engine_handler.cpp +++ b/src/handler/web_engine_handler.cpp @@ -741,8 +741,10 @@ void WebEngineHandler::processVoiceMenuRequest(QObject *request) // 异步操作,防止阻塞前端事件 QTimer::singleShot(0, this, [this] { Q_EMIT requestMessageDialog(VNoteMessageDialogHandler::VoicePathNoAvail); - // 调用 js 删除删除语音文本 - Q_EMIT JsContent::instance()->callJsDeleteSelection(); + // 调试态下 Tiptap 编辑器不走 Summernote JS 删除路径 + if (!TiptapChannelBridge::instance()->debugEnabled()) { + Q_EMIT JsContent::instance()->callJsDeleteSelection(); + } }); return; } @@ -777,8 +779,10 @@ void WebEngineHandler::processVoiceMenuRequest(QWebEngineContextMenuRequest *req // 异步操作,防止阻塞前端事件 QTimer::singleShot(0, this, [this] { Q_EMIT requestMessageDialog(VNoteMessageDialogHandler::VoicePathNoAvail); - // 调用 js 删除删除语音文本 - Q_EMIT JsContent::instance()->callJsDeleteSelection(); + // 调试态下 Tiptap 编辑器不走 Summernote JS 删除路径 + if (!TiptapChannelBridge::instance()->debugEnabled()) { + Q_EMIT JsContent::instance()->callJsDeleteSelection(); + } }); return; } diff --git a/tests/src/tiptapchannel/ut_tiptapchannelbridge.cpp b/tests/src/tiptapchannel/ut_tiptapchannelbridge.cpp index db01b711..e211ef35 100644 --- a/tests/src/tiptapchannel/ut_tiptapchannelbridge.cpp +++ b/tests/src/tiptapchannel/ut_tiptapchannelbridge.cpp @@ -515,3 +515,27 @@ TEST_F(UT_TiptapChannelBridge, UT_TiptapChannelBridge_currentVoiceId_001) bridge.setCurrentVoiceId(QStringLiteral("voice-x")); EXPECT_EQ(bridge.currentVoiceId(), QStringLiteral("voice-x")); } + +// --------------------------------------------------------------------------- +// 滚动位置上报(JS→C++):jsReportScroll + scrollChanged 信号 +// --------------------------------------------------------------------------- + +// jsReportScroll(0) → scrollChanged(true)(已到顶部) +TEST_F(UT_TiptapChannelBridge, UT_TiptapChannelBridge_jsReportScroll_top_001) +{ + TiptapChannelBridge bridge; + QSignalSpy spy(&bridge, &TiptapChannelBridge::scrollChanged); + bridge.jsReportScroll(0); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(0).toBool(), true); +} + +// jsReportScroll(正值) → scrollChanged(false)(未到顶部) +TEST_F(UT_TiptapChannelBridge, UT_TiptapChannelBridge_jsReportScroll_scrolled_001) +{ + TiptapChannelBridge bridge; + QSignalSpy spy(&bridge, &TiptapChannelBridge::scrollChanged); + bridge.jsReportScroll(150); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.takeFirst().at(0).toBool(), false); +} From 14087515cac35f5c92641624e3047ecec6d91882 Mon Sep 17 00:00:00 2001 From: deepin Date: Sun, 2 Aug 2026 08:49:33 +0800 Subject: [PATCH 2/2] fix(tiptap): harden context menu probe against injection Coerce req.position.x/y via Number() with isNaN guard to reject non-numeric values early. Use String(Math.round()) to produce pure digit strings before concatenating into the probe script, eliminating JavaScript injection risk from untrusted position data. --- src/gui/mainwindow/WebEngineView.qml | 46 ++++++++++++++-------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/gui/mainwindow/WebEngineView.qml b/src/gui/mainwindow/WebEngineView.qml index 76093387..8166a19a 100644 --- a/src/gui/mainwindow/WebEngineView.qml +++ b/src/gui/mainwindow/WebEngineView.qml @@ -508,30 +508,30 @@ Item { onContextMenuRequested: req => { req.accepted = true; - var x = req.position.x; - var y = req.position.y; - var probeJs = "(function(){" - + "var el = document.elementFromPoint(" + x + "," + y + ");" - + "if (!el) return JSON.stringify({type:2,json:''});" - + "var voiceBox = el.closest ? el.closest('.voiceInfoBox') : null;" - + "if (voiceBox && voiceBox.getAttribute('data-type') === 'voice-block') {" - + " var meta = voiceBox.getAttribute('data-voice-meta') || '';" - + " return JSON.stringify({type:1,json:meta});" - + "}" - + "var img = el.closest ? el.closest('img[data-rel-path]') : null;" - + "if (img) return JSON.stringify({type:0,json:''});" + var rawX = Number(req.position.x); + var rawY = Number(req.position.y); + if (isNaN(rawX) || isNaN(rawY)) return; + var sx = String(Math.round(rawX)); + var sy = String(Math.round(rawY)); + tiptapWebView.runJavaScript( + "(function(){" + + "var el=document.elementFromPoint(" + sx + "," + sy + ");" + + "if(!el) return JSON.stringify({type:2,json:''});" + + "var vb=el.closest?el.closest('.voiceInfoBox'):null;" + + "if(vb&&vb.getAttribute('data-type')==='voice-block'){" + + "return JSON.stringify({type:1,json:vb.getAttribute('data-voice-meta')||''});}" + + "var img=el.closest?el.closest('img[data-rel-path]'):null;" + + "if(img) return JSON.stringify({type:0,json:''});" + "return JSON.stringify({type:2,json:''});" - + "})()"; - tiptapWebView.runJavaScript(probeJs, function(result) { - var info = null; - try { info = JSON.parse(result); } catch(e) {} - if (!info) return; - if (info.type === 0) { - return; - } - handler.onSaveMenuParam(info.type, info.json); - handler.onContextMenuRequested(req); - }); + + "})()", + function(result) { + var info = null; + try { info = JSON.parse(result); } catch(e) {} + if (!info) return; + if (info.type === 0) return; + handler.onSaveMenuParam(info.type, info.json); + handler.onContextMenuRequested(req); + }); } }