fix: Implement drag-and-drop functionality with QWebChannel integration
This commit is contained in:
parent
b2681a9cbd
commit
f701247fab
4 changed files with 165 additions and 59 deletions
73
src/webdrop_bridge/ui/bridge_script.js
Normal file
73
src/webdrop_bridge/ui/bridge_script.js
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// WebDrop Bridge - Injected Script
|
||||||
|
// Automatically converts Z:\ path drags to native file drags via QWebChannel bridge
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
if (window.__webdrop_bridge_injected) return;
|
||||||
|
window.__webdrop_bridge_injected = true;
|
||||||
|
|
||||||
|
function ensureChannel(cb) {
|
||||||
|
if (window.bridge) { cb(); return; }
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (window.QWebChannel && window.qt && window.qt.webChannelTransport) {
|
||||||
|
new QWebChannel(window.qt.webChannelTransport, function(channel) {
|
||||||
|
window.bridge = channel.objects.bridge;
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.QWebChannel) {
|
||||||
|
init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var s = document.createElement('script');
|
||||||
|
s.src = 'qrc:///qtwebchannel/qwebchannel.js';
|
||||||
|
s.onload = init;
|
||||||
|
document.documentElement.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hook() {
|
||||||
|
document.addEventListener('dragstart', function(e) {
|
||||||
|
var dt = e.dataTransfer;
|
||||||
|
if (!dt) return;
|
||||||
|
|
||||||
|
// Get path from existing payload or from the card markup.
|
||||||
|
var path = dt.getData('text/plain');
|
||||||
|
if (!path) {
|
||||||
|
var card = e.target.closest && e.target.closest('.drag-item');
|
||||||
|
if (card) {
|
||||||
|
var pathEl = card.querySelector('p');
|
||||||
|
if (pathEl) {
|
||||||
|
path = (pathEl.textContent || '').trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!path) return;
|
||||||
|
|
||||||
|
// Ensure text payload exists for non-file drags and downstream targets.
|
||||||
|
if (!dt.getData('text/plain')) {
|
||||||
|
dt.setData('text/plain', path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is Z:\ — if yes, trigger native file drag. Otherwise, stay as text.
|
||||||
|
var isZDrive = /^z:/i.test(path);
|
||||||
|
if (!isZDrive) return;
|
||||||
|
|
||||||
|
// Z:\ detected — prevent default browser drag and convert to native file drag
|
||||||
|
e.preventDefault();
|
||||||
|
ensureChannel(function() {
|
||||||
|
if (window.bridge && typeof window.bridge.start_file_drag === 'function') {
|
||||||
|
window.bridge.start_file_drag(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', hook);
|
||||||
|
} else {
|
||||||
|
hook();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -5,7 +5,9 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QSize, Qt, QThread, QUrl, Signal
|
from PySide6.QtCore import QObject, QPoint, QSize, Qt, QThread, QTimer, QUrl, Signal, Slot
|
||||||
|
from PySide6.QtWebChannel import QWebChannel
|
||||||
|
from PySide6.QtWebEngineCore import QWebEngineScript
|
||||||
from PySide6.QtWidgets import QLabel, QMainWindow, QStatusBar, QToolBar, QVBoxLayout, QWidget
|
from PySide6.QtWidgets import QLabel, QMainWindow, QStatusBar, QToolBar, QVBoxLayout, QWidget
|
||||||
|
|
||||||
from webdrop_bridge.config import Config
|
from webdrop_bridge.config import Config
|
||||||
|
|
@ -170,6 +172,39 @@ DEFAULT_WELCOME_PAGE = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _DragBridge(QObject):
|
||||||
|
"""JavaScript bridge for drag operations via QWebChannel.
|
||||||
|
|
||||||
|
Exposed to JavaScript as 'bridge' object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, window: 'MainWindow', parent: Optional[QObject] = None):
|
||||||
|
"""Initialize the drag bridge.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
window: MainWindow instance
|
||||||
|
parent: Parent QObject
|
||||||
|
"""
|
||||||
|
super().__init__(parent)
|
||||||
|
self.window = window
|
||||||
|
|
||||||
|
@Slot(str)
|
||||||
|
def start_file_drag(self, path_text: str) -> None:
|
||||||
|
"""Start a native file drag for the given path.
|
||||||
|
|
||||||
|
Called from JavaScript when user drags a Z:\ path item.
|
||||||
|
Defers execution to avoid Qt drag manager state issues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path_text: File path string to drag
|
||||||
|
"""
|
||||||
|
logger.debug(f"Bridge: start_file_drag called for {path_text}")
|
||||||
|
|
||||||
|
# Defer to avoid drag manager state issues
|
||||||
|
# initiate_drag() handles validation internally
|
||||||
|
QTimer.singleShot(0, lambda: self.window.drag_interceptor.initiate_drag([path_text]))
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main application window for WebDrop Bridge.
|
"""Main application window for WebDrop Bridge.
|
||||||
|
|
||||||
|
|
@ -221,7 +256,6 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Create drag interceptor
|
# Create drag interceptor
|
||||||
self.drag_interceptor = DragInterceptor()
|
self.drag_interceptor = DragInterceptor()
|
||||||
|
|
||||||
# Set up path validator
|
# Set up path validator
|
||||||
validator = PathValidator(config.allowed_roots)
|
validator = PathValidator(config.allowed_roots)
|
||||||
self.drag_interceptor.set_validator(validator)
|
self.drag_interceptor.set_validator(validator)
|
||||||
|
|
@ -230,6 +264,15 @@ class MainWindow(QMainWindow):
|
||||||
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
self.drag_interceptor.drag_started.connect(self._on_drag_started)
|
||||||
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
|
self.drag_interceptor.drag_failed.connect(self._on_drag_failed)
|
||||||
|
|
||||||
|
# Set up JavaScript bridge with QWebChannel
|
||||||
|
self._drag_bridge = _DragBridge(self)
|
||||||
|
web_channel = QWebChannel(self)
|
||||||
|
web_channel.registerObject("bridge", self._drag_bridge)
|
||||||
|
self.web_view.page().setWebChannel(web_channel)
|
||||||
|
|
||||||
|
# Install the drag bridge script
|
||||||
|
self._install_bridge_script()
|
||||||
|
|
||||||
# Set up central widget with layout
|
# Set up central widget with layout
|
||||||
central_widget = QWidget()
|
central_widget = QWidget()
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
@ -248,6 +291,7 @@ class MainWindow(QMainWindow):
|
||||||
"""Load the web application.
|
"""Load the web application.
|
||||||
|
|
||||||
Loads HTML from the configured webapp URL or from local file.
|
Loads HTML from the configured webapp URL or from local file.
|
||||||
|
Injects the WebChannel bridge JavaScript for drag-and-drop.
|
||||||
Supports both bundled apps (PyInstaller) and development mode.
|
Supports both bundled apps (PyInstaller) and development mode.
|
||||||
Falls back to default welcome page if webapp not found.
|
Falls back to default welcome page if webapp not found.
|
||||||
"""
|
"""
|
||||||
|
|
@ -282,15 +326,55 @@ class MainWindow(QMainWindow):
|
||||||
self.web_view.setHtml(welcome_html)
|
self.web_view.setHtml(welcome_html)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Load local file as file:// URL
|
# Load local file
|
||||||
file_url = file_path.as_uri()
|
html_content = file_path.read_text(encoding='utf-8')
|
||||||
self.web_view.load(QUrl(file_url))
|
|
||||||
|
# Inject WebChannel bridge JavaScript
|
||||||
|
injected_html = self._inject_drag_bridge(html_content)
|
||||||
|
|
||||||
|
# Load the modified HTML
|
||||||
|
self.web_view.setHtml(injected_html, QUrl.fromLocalFile(file_path.parent))
|
||||||
|
|
||||||
except (OSError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
# Show welcome page on error
|
# Show welcome page on error
|
||||||
welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version)
|
welcome_html = DEFAULT_WELCOME_PAGE.format(version=self.config.app_version)
|
||||||
self.web_view.setHtml(welcome_html)
|
self.web_view.setHtml(welcome_html)
|
||||||
|
|
||||||
|
def _install_bridge_script(self) -> None:
|
||||||
|
"""Install the drag bridge JavaScript via QWebEngineScript.
|
||||||
|
|
||||||
|
Follows the POC pattern for proper script injection and QWebChannel setup.
|
||||||
|
"""
|
||||||
|
script = QWebEngineScript()
|
||||||
|
script.setName("webdrop-bridge")
|
||||||
|
script.setInjectionPoint(QWebEngineScript.InjectionPoint.DocumentReady)
|
||||||
|
script.setWorldId(QWebEngineScript.ScriptWorldId.MainWorld)
|
||||||
|
script.setRunsOnSubFrames(False)
|
||||||
|
|
||||||
|
# Load bridge script from file
|
||||||
|
script_path = Path(__file__).parent / "bridge_script.js"
|
||||||
|
try:
|
||||||
|
with open(script_path, 'r', encoding='utf-8') as f:
|
||||||
|
script.setSourceCode(f.read())
|
||||||
|
self.web_view.page().scripts().insert(script)
|
||||||
|
logger.debug(f"Installed bridge script from {script_path}")
|
||||||
|
except (OSError, IOError) as e:
|
||||||
|
logger.warning(f"Failed to load bridge script: {e}")
|
||||||
|
|
||||||
|
def _inject_drag_bridge(self, html_content: str) -> str:
|
||||||
|
"""Return HTML content unmodified.
|
||||||
|
|
||||||
|
The drag bridge script is now injected via QWebEngineScript in _install_bridge_script().
|
||||||
|
This method is kept for compatibility but does nothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: Original HTML content
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML unchanged
|
||||||
|
"""
|
||||||
|
return html_content
|
||||||
|
|
||||||
def _apply_stylesheet(self) -> None:
|
def _apply_stylesheet(self) -> None:
|
||||||
"""Apply application stylesheet if available."""
|
"""Apply application stylesheet if available."""
|
||||||
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
stylesheet_path = Path(__file__).parent.parent.parent.parent / \
|
||||||
|
|
|
||||||
|
|
@ -98,3 +98,4 @@ class RestrictedWebEngineView(QWebEngineView):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,13 +163,13 @@
|
||||||
<div class="drag-item" draggable="true" id="dragItem1">
|
<div class="drag-item" draggable="true" id="dragItem1">
|
||||||
<div class="icon">🖼️</div>
|
<div class="icon">🖼️</div>
|
||||||
<h3>Sample Image</h3>
|
<h3>Sample Image</h3>
|
||||||
<p id="path1">Z:\samples\image.psd</p>
|
<p id="path1">Z:\data\test-image.jpg</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drag-item" draggable="true" id="dragItem2">
|
<div class="drag-item" draggable="true" id="dragItem2">
|
||||||
<div class="icon">📄</div>
|
<div class="icon">📄</div>
|
||||||
<h3>Sample Document</h3>
|
<h3>Sample Document</h3>
|
||||||
<p id="path2">Z:\samples\document.indd</p>
|
<p id="path2">Z:\data\API_DOCUMENTATION.pdf</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="drag-item" draggable="true" id="dragItem3">
|
<div class="drag-item" draggable="true" id="dragItem3">
|
||||||
|
|
@ -193,57 +193,5 @@
|
||||||
<p>WebDrop Bridge v1.0.0 | Built with Qt and PySide6</p>
|
<p>WebDrop Bridge v1.0.0 | Built with Qt and PySide6</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
const items = document.querySelectorAll('.drag-item');
|
|
||||||
const statusMessage = document.getElementById('statusMessage');
|
|
||||||
|
|
||||||
items.forEach(item => {
|
|
||||||
item.addEventListener('dragstart', (e) => {
|
|
||||||
const pathElement = item.querySelector('p');
|
|
||||||
const path = pathElement.textContent.trim();
|
|
||||||
|
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
|
||||||
e.dataTransfer.setData('text/plain', path);
|
|
||||||
|
|
||||||
statusMessage.textContent = `Dragging: ${path}`;
|
|
||||||
statusMessage.className = 'status-message info';
|
|
||||||
|
|
||||||
console.log('🚀 Drag started:', path);
|
|
||||||
console.log('📋 DataTransfer types:', e.dataTransfer.types);
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragend', (e) => {
|
|
||||||
const pathElement = item.querySelector('p');
|
|
||||||
const path = pathElement.textContent.trim();
|
|
||||||
|
|
||||||
if (e.dataTransfer.dropEffect === 'none') {
|
|
||||||
statusMessage.textContent = `❌ Drop failed or cancelled`;
|
|
||||||
statusMessage.className = 'status-message info';
|
|
||||||
} else {
|
|
||||||
statusMessage.textContent = `✅ Drop completed: ${e.dataTransfer.dropEffect}`;
|
|
||||||
statusMessage.className = 'status-message success';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🏁 Drag ended:', e.dataTransfer.dropEffect);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Visual feedback
|
|
||||||
item.addEventListener('dragstart', () => {
|
|
||||||
item.style.opacity = '0.5';
|
|
||||||
item.style.transform = 'scale(0.95)';
|
|
||||||
});
|
|
||||||
|
|
||||||
item.addEventListener('dragend', () => {
|
|
||||||
item.style.opacity = '1';
|
|
||||||
item.style.transform = 'scale(1)';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Application info
|
|
||||||
console.log('%cWebDrop Bridge', 'font-size: 18px; font-weight: bold; color: #667eea;');
|
|
||||||
console.log('Ready for testing. Drag items to other applications.');
|
|
||||||
console.log('Check the path values in the drag items above.');
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue