Shipping & Building

cocozq

Indie Developer

cocozq

In a Tauri v2 + React desktop app, right-clicking anywhere pops up the native Chromium context menu — Back, Forward, Reload, View Source, Inspect, and friends.

That shatters the native feel of the app, leaks implementation details (it’s a WebView under the hood), and conflicts with the app’s own custom context menus — transfer panel actions, terminal menus, and the like.

Read more »

While building a screenshot tool, I ran into a series of nasty problems: pressing ESC to cancel a capture yanked the main window to the foreground, popup combo boxes from apps like PowerToys couldn’t be detected or captured, and the image shown on the capture overlay didn’t match the image actually captured. These issues span Windows window management, GDI screen capture, Tauri’s event system, and browser image rendering. This post records the full debugging and fixing process — hopefully it helps fellow developers hitting similar walls.


Issue 1: Main Window Jumps to the Foreground After ESC-Cancel

Symptom

Pressing ESC to cancel a screenshot unexpectedly brought the app’s main window to the foreground and gave it focus.

Root cause

The cancel logic explicitly called show() and set_focus() on the main window:

1
2
3
// inside cancel_capture
let _ = main_window.show();
let _ = main_window.set_focus();

The intent was to restore the main window — but users expect a cancel to quietly return the app to the tray, not to steal focus.

Fix

Remove the show/focus calls from the cancel path; only destroy the capture overlay window:

1
2
3
4
5
6
fn cancel_capture() {
// only destroy the capture window, don't show/focus the main window
if let Some(window) = app.get_webview_window("capture") {
let _ = window.destroy();
}
}

Takeaways

  • Desktop utility apps should respect the user’s focus state — never steal focus on a cancel action
  • Tray-resident apps should stay silent after a cancel

Issue 2: PowerToys Combo Box Can’t Be Detected or Captured

Symptom

PowerToys’ “Image Resizer” window has a dropdown list box. Other screenshot tools (QQ Screenshot, ShareX) detect and capture it just fine — ours couldn’t see it at all.

Debugging

Step 1: Window enumeration diagnostics

First, verify whether window enumeration can even see the combo box. Write a diagnostic script that enumerates all visible windows with EnumWindows:

1
2
3
Window title: ""  Class: "ComboBox"  Style: 0x96000000  ExStyle: 0x00000088
Position: (993, 422) Size: 575x489
Owner PID: 12345

Key findings:

  1. The combo box is a standalone window — not a child control of the main window, but an independent top-level window
  2. Its title is empty — it was being filtered out by the existing title.is_empty() check
  3. Its extended style contains WS_EX_TOOLWINDOW and WS_EX_NOACTIVATE — excluded by the style filter

Step 2: Drop the filters, add a fallback

1
2
3
4
5
6
7
8
9
10
// Before: filtering out empty-title / special-style windows
if title.is_empty() { continue; }
if ex_style & WS_EX_TOOLWINDOW != 0 { continue; }

// After: fall back to class name for empty titles, no style filtering
let display_title = if title.trim().is_empty() {
class_name.clone() // use the window class as the title
} else {
title.clone()
};

Step 3: Merge same-process windows

The combo box window and the main window belong to the same process but enumerate as two separate windows. Users expect clicking to highlight the whole region (main window + combo box), not each piece separately.

Implement same-process window merging:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Use GetAncestor to find the root window; merge overlapping or
// adjacent windows from the same process
for i in 0..raw_windows.len() {
if used[i] { continue; }
let root_i = GetAncestor(hwnd_i, GA_ROOT);
let mut merge_rect = Rect::from(hwnd_i);

for j in (i+1)..raw_windows.len() {
if used[j] { continue; }
if pid_j == pid_i && (root_j == root_i) {
let rect_j = Rect::from(hwnd_j);
if merge_rect.overlaps_or_near(&rect_j, threshold=50) {
merge_rect = merge_rect.union(&rect_j);
used[j] = true;
}
}
}
// build the window entry from the merged rect
}

Merge result: main window (1014,562 535x437) + combo box (993,422 575x489) → merged (993,422 575x577)

Takeaways

  • On Windows, many popup controls (combo boxes, popup menus, tooltips) are standalone windows, not child controls of their parent
  • Don’t over-filter during window enumeration — empty titles and special styles must not be exclusion criteria
  • Consider merging multiple windows from the same process, or the UX suffers
  • Use GetAncestor(hwnd, GA_ROOT) to tell whether windows belong to the same top-level tree

Issue 3: The Combo Box Collapses When Capturing

Symptom

With the combo box expanded, pressing the screenshot hotkey instantly collapsed it — the captured image never showed the dropdown contents.

Root cause

In the capture flow, hiding the main window changed focus, which made the combo box lose focus and close:

1
Hotkey fired → hide main window (focus change) → combo box loses focus, closes → capture → combo box missing

Fix: reorder the operations

Capture first, hide windows after:

1
Hotkey fired → capture immediately (synchronous) → hide main window → show overlay

Also add the WS_EX_NOACTIVATE style to the main window so showing/hiding it never steals focus:

1
2
3
4
5
6
7
8
9
10
fn hide_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let hwnd = window.hwnd().unwrap().0 as *mut std::ffi::c_void;
unsafe {
let ex_style = GetWindowLongPtrW(hwnd, GWL_EXSTYLE);
SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex_style | WS_EX_NOACTIVATE.0);
}
let _ = window.hide();
}
}

Takeaways

  • A screenshot tool must capture before any window state changes
  • The WS_EX_NOACTIVATE style lets windows show/hide without grabbing focus
  • Global hotkey handling should capture synchronously — avoid async delays that allow focus to shift

Issue 4: GDI Capture Misses Layered Windows

Symptom

Capturing with the xcap library dropped layered-window content such as the combo box — while ShareX captured it fine.

Root cause

xcap’s underlying screen-capture call doesn’t set the CAPTUREBLT flag. Windows’ BitBlt excludes layered windows by default; you must pass CAPTUREBLT to include them.

Fix: implement GDI capture with CAPTUREBLT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn gdi_capture_all_monitors() -> Result<RgbaImage, String> {
unsafe {
let vx = GetSystemMetrics(SM_XVIRTUALSCREEN);
let vy = GetSystemMetrics(SM_YVIRTUALSCREEN);
let vw = GetSystemMetrics(SM_CXVIRTUALSCREEN);
let vh = GetSystemMetrics(SM_CYVIRTUALSCREEN);

let hdc_screen = GetWindowDC(GetDesktopWindow());
let hdc_mem = CreateCompatibleDC(hdc_screen);
let h_bitmap = CreateCompatibleBitmap(hdc_screen, vw, vh);
SelectObject(hdc_mem, h_bitmap as *mut c_void);

// Key: SRCCOPY | CAPTUREBLT ensures layered windows are captured
let result = BitBlt(
hdc_mem, 0, 0, vw, vh,
hdc_screen, vx, vy,
SRCCOPY | CAPTUREBLT, // 0x00CC0020 | 0x40000000
);

// ... GetDIBits to fetch pixel data ...
}
}

The alpha channel trap

The 32-bit pixel data returned by GetDIBits has an undefined alpha channel (usually 0). Save it straight to a 32-bit BMP and render it in a browser, and every pixel turns transparent!

1
2
3
4
5
// Must force alpha to 255 (opaque)
for chunk in buffer.chunks_exact_mut(4) {
chunk.swap(0, 2); // BGRA → RGBA
chunk[3] = 255; // force alpha = opaque
}

Takeaways

  • Windows BitBlt needs the CAPTUREBLT flag to capture layered windows
  • GetDIBits alpha is untrustworthy — set it to 255 manually
  • Prefer PNG over BMP for saved screenshots to avoid the browser’s 32-bit BMP alpha rendering quirks

Issue 5: Overlay Shows a Different Image Than What Was Captured (the Nastiest One)

Symptom

The image saved to disk contained the combo box, but the image displayed on the capture overlay had a blank hole where the combo box should be. The highlight rectangle correctly covered the combo box region — the pixels were just missing.

Debugging

The most time-consuming issue, spanning several links of the frontend-backend data sync chain.

Initially the screenshot data traveled over Tauri events:

1
2
3
4
5
6
7
// Backend: fire the event
app.emit("capture-ready", &result)?;

// Frontend: listen for it
listen<CaptureResult>("capture-ready", (event) => {
setImageSrc(convertFileSrc(event.payload.path));
});

Problem: the capture-ready event fired right after the capture window’s show() — but the webview might not have finished loading and the listener wasn’t registered yet, so the event was lost.

Switched to letting the frontend pull the data via a Tauri command — but start_capture used take_pending_capture(), which consumed the pre-cached data:

1
2
3
4
5
6
7
// inside start_capture
let result = take_pending_capture(); // consumed the data!

// when the frontend later calls get_cached_capture
pub fn get_cached_capture() -> Option<CaptureResult> {
pending.take() // already consumed — returns None!
}

The frontend got nothing, fell back to the possibly-missed event, and ended up loading the wrong image (probably a stale cached screenshot).

Even with the correct file path in hand, 32-bit BMP alpha can render wrong regions in the browser.

Final fix

1. Backend: peek instead of take — keep the data available for the frontend

1
2
3
4
5
6
7
8
// in start_capture: peek, don't consume
let result = peek_pending_capture();

// frontend command: peek as well
#[tauri::command]
pub fn get_cached_capture() -> Option<CaptureResult> {
PENDING_CAPTURE.lock().map(|p| p.clone()).unwrap_or(None)
}

2. Frontend: polling + event listening as a double safety net

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
useEffect(() => {
let gotData = false;
let pollInterval: number | null = null;

const tryGetData = () => {
if (gotData) return;
invoke<CaptureResult | null>("get_cached_capture")
.then((result) => {
if (result) {
gotData = true;
setCaptureResult(result);
setImageSrc(convertFileSrc(result.path));
if (pollInterval) clearInterval(pollInterval);
}
});
};

// try immediately + poll
tryGetData();
pollInterval = setInterval(tryGetData, 50);
setTimeout(() => { if (pollInterval) clearInterval(pollInterval); }, 2000);

// also listen for the event as backup
listen<CaptureResult>("capture-ready", (event) => {
if (!gotData) { /* ... */ }
});
}, []);

3. Save screenshots as PNG

1
2
3
// Before: save_bmp_fast(&full_image, &path)?;
// After:
full_image.save(&path).map_err(|e| format!("PNG save failed: {}", e))?;

Takeaways

  • Tauri events are not guaranteed delivery: if the webview hasn’t finished loading, events get dropped. Critical data needs a command-based pull path too
  • Be careful with data consumption: take (consume) vs peek (inspect) have different semantics — use peek in multi-consumer scenarios
  • Polling + events as a double safety net: for critical data, the frontend should use both polling and event listeners so nothing gets lost
  • BMP is treacherous in browsers: 32-bit BMP alpha can be misinterpreted — use PNG

Overall Architecture After the Fixes

The capture flow now looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. Hotkey fired (synchronous)
├── GDI capture (with CAPTUREBLT)
├── Enumerate windows (merge same-process windows)
├── Save as PNG
└── Cache the result (without consuming)

2. start_capture (async)
├── Peek the cached result
├── Hide the main window
├── Create the capture overlay window
├── Emit the capture-ready event
└── Frontend polls get_cached_capture for the data

3. Frontend overlay
├── Poll for the screenshot path and window list
├── Load the PNG image
├── Match the smallest containing window on mouse move
└── Highlight the window region

Quick-Reference Cheat Sheet

Problem Root cause Fix
Main window pops up after ESC-cancel Cancel logic explicitly shows + focuses it Remove the show/focus calls
Combo box not detected Empty titles and special styles filtered out Fall back to class name, drop style filters
Combo box collapses on capture Hiding windows shifts focus Capture first, hide after; add WS_EX_NOACTIVATE
Layered windows missing BitBlt without CAPTUREBLT GDI capture with CAPTUREBLT
Overlay image ≠ captured image Data consumed early + BMP alpha Peek instead of take + polling + PNG

Closing Thoughts

A screenshot tool looks simple but touches multiple low-level mechanisms: the Windows window system, graphics capture APIs, and cross-process data sync. The deepest lessons from this build:

  1. The Windows window system is far more complex than it looks: a humble combo box can involve multiple standalone windows, layered windows, and focus management
  2. Verify data flow end to end: correct data in backend logs doesn’t mean the frontend receives it — verify every link
  3. Never assume API behavior: GetDIBits alpha, BitBlt layered capture, browser BMP rendering — all need real-world verification
  4. Have a fallback when events are unreliable: polling is inelegant, but far better than lost data

Someone recently messaged me that their Sharp MD player won’t power on. I’ve previously written two posts about dead Sharp MD units, so here’s a reorganized write-up to give anyone hitting the same problem a few ideas.

Sharp MD players have two notorious common faults: shattered gears and dead-no-power. They tend to split by model — the MT66 and MT77/877 rarely suffer shattered gears, but these two “sleep to death”: even a brand-new unit, left sitting unused for a long time, will refuse to power on when picked up again, which tanks its value. I’ve previously revived several units like this, including MT66/MT77 models — but if you ask me for the exact root cause, I honestly don’t know. Really! Every single one of them just… came back to life on its own while I was probing it on the bench.

Sharp MT77 in blue

The first unit I ever revived: I was probing around IC401, casually poking a few voltages with the multimeter, and it just powered on — as if some capacitor was the culprit. One unit proves nothing, so I found another one with the same symptom. My guess was a stuck power IC state or an aged capacitor, and my earlier probing had effectively injected some impedance that pulled a level back into range or changed a load capacitance. So I pulled out the Service Manual, ready to check the RESET/VCC signals and capture the power-up sequence on the scope. Given how badly I slept through digital/analog electronics and signals & systems back in college — and how little attention I paid the oscilloscope in lab class — it’s no surprise the scope was probably misconfigured: nothing I measured looked right, nothing matched expectations. Then after another round of random poking, the unit came back to life. A later MT66 with the same symptom went down the exact same path.

Things worth trying

  1. Set the bench supply to 1.3V. Don’t ask me why — the first unit mysteriously recovered while powered at exactly that voltage.
  2. Find a ~100kΩ resistor and try discharging the RESET / VCC / XTAL / PLVBAT pins of IC401 to ground.

IC401 probe points on the MT77

For those who know their stuff: help pin down the real cause

Grab the MT77 Service Manual, power it up (or rather, fail to), and record the static voltages on these pins with a multimeter, then follow up with a scope:

  • RESET (pin 59) — is it stuck low?
  • VCC (pin 62) — is there a ramp-up at all?
  • XTAL (pin 63) — is the crystal oscillating?
  • PLVBAT (pin 52) — compare against a working unit

A revived MT77, powered on again

Using the CocoMarkdown project as a worked example, this post summarizes complete implementations for two drag-and-drop scenarios in Tauri (v2) apps, for other projects to reference.


Contents

  1. Scenario Overview
  2. Scenario 1: In-App Drag & Drop
  3. Scenario 2: External Files Dropped Into the App
  4. Common Pitfalls
  5. Full Code Reference

1. Scenario Overview

Scenario Drag source Drop target Recommended approach
In-app DnD DOM element inside the app DOM element inside the app Mouse events (mousedown/mousemove/mouseup)
External files OS file manager Region inside the app Tauri native events + HTML5 DnD assist

Core principle: never use the HTML5 Drag and Drop API for in-app dragging, because Tauri’s WebView2 (Windows) intercepts native drag events — the cursor turns into a “not allowed” icon and no amount of preventDefault() fixes it.


2. Scenario 1: In-App Drag & Drop

2.1 Why not the HTML5 DnD API

On Windows WebView2, HTML5 Drag and Drop events get intercepted at the native layer:

  • Calling e.preventDefault() in dragover cannot stop the cursor from turning into the forbidden icon
  • dataTransfer.dropEffect settings have no effect
  • Custom MIME types don’t help either
  • Even intercepting the DragDrop event in Rust via on_window_event and calling prevent_default() doesn’t work (Tauri v2’s DragDropEvent has no prevent_default method)

Conclusion: the HTML5 DnD API does not work inside Tauri WebView2 — use Mouse events instead.

2.2 Mouse-Event Implementation

Overall approach

  1. mousedown: record the drag source element and start coordinates
  2. mousemove: once movement passes a threshold, activate the drag, show a ghost element following the cursor, highlight the target region
  3. mouseup: complete the drop, run the business logic
  4. Extras: ESC to cancel, prevent accidental click after a drag

JavaScript implementation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// ── State ──
let dragSrcPath = null; // drag source path
let dragOverItem = null; // currently hovered target element
let dragSrcElement = null; // drag source DOM element
let isDragging = false; // drag in progress?
let wasDragging = false; // a drag just finished (to swallow click)
let dragStartX = 0; // drag start X
let dragStartY = 0; // drag start Y
let dragGhost = null; // ghost element following the cursor

// ── Ghost element management ──
function createDragGhost(label) {
dragGhost = document.createElement('div');
dragGhost.className = 'tree-drag-ghost';
dragGhost.textContent = label;
document.body.appendChild(dragGhost);
}

function moveDragGhost(x, y) {
if (!dragGhost) return;
dragGhost.style.left = (x + 12) + 'px';
dragGhost.style.top = (y + 12) + 'px';
}

function removeDragGhost() {
if (dragGhost) { dragGhost.remove(); dragGhost = null; }
}

// ── Reset drag state ──
function clearDragState() {
if (dragSrcElement) dragSrcElement.classList.remove('dragging');
if (dragOverItem) dragOverItem.classList.remove('drag-over');
dragSrcPath = null;
dragOverItem = null;
dragSrcElement = null;
isDragging = false;
removeDragGhost();
document.body.classList.remove('explorer-dragging');
}

// ── mousedown: record the source ──
container.addEventListener('mousedown', (e) => {
const item = e.target.closest('.tree-item[data-path]');
if (!item) return;
if (e.button !== 0) return; // left button only
dragSrcPath = item.dataset.path;
dragSrcElement = item;
dragStartX = e.clientX;
dragStartY = e.clientY;
isDragging = false;
wasDragging = false;
});

// ── mousemove: drag in progress ──
document.addEventListener('mousemove', (e) => {
if (!dragSrcPath || !dragSrcElement) return;

// Movement threshold (prevents clicks from triggering drags)
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (!isDragging) {
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
isDragging = true;
dragSrcElement.classList.add('dragging');
document.body.classList.add('explorer-dragging');
const label = dragSrcElement.querySelector('.tree-label')?.textContent || '';
createDragGhost(label);
}

// move the ghost
moveDragGhost(e.clientX, e.clientY);

// hit-test the drop target (elementFromPoint sees through the ghost)
const target = document.elementFromPoint(e.clientX, e.clientY)
?.closest('.tree-item[data-is-dir="true"]');

if (dragOverItem && dragOverItem !== target) {
dragOverItem.classList.remove('drag-over');
}
if (target && target.dataset.path !== dragSrcPath) {
target.classList.add('drag-over');
dragOverItem = target;
} else if (dragOverItem) {
dragOverItem.classList.remove('drag-over');
dragOverItem = null;
}
});

// ── mouseup: complete the drop ──
document.addEventListener('mouseup', async (e) => {
if (!dragSrcPath || !dragSrcElement) {
dragSrcPath = null;
dragSrcElement = null;
return;
}
if (!isDragging) {
dragSrcPath = null;
dragSrcElement = null;
return;
}

wasDragging = true;
const destDir = dragOverItem?.dataset.path;
const srcPath = dragSrcPath;
clearDragState();

if (!srcPath || !destDir) return;
if (srcPath === destDir) return;

// guard against dropping a folder into its own subdirectory
if (destDir.startsWith(srcPath + '\\') || destDir.startsWith(srcPath + '/')) {
showToast('Cannot move a folder into its own subfolder');
return;
}

// perform the move
await handleExplorerDrop(srcPath, destDir);
});

// ── swallow the click that fires after a drag ──
container.addEventListener('click', (e) => {
if (wasDragging) {
e.stopPropagation();
e.preventDefault();
wasDragging = false;
}
}, true);

// ── ESC cancels the drag ──
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isDragging) {
clearDragState();
wasDragging = false;
}
});

CSS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/* drag source: translucent */
.tree-item.dragging {
opacity: 0.4;
}

/* drop target: highlighted border */
.tree-item.drag-over {
background: rgba(108, 99, 255, 0.2);
outline: 1px dashed var(--accent);
outline-offset: -1px;
}

/* while dragging: cursor */
body.explorer-dragging,
body.explorer-dragging .tree-item {
cursor: grabbing !important;
}

/* ghost label following the cursor */
.tree-drag-ghost {
position: fixed;
z-index: 9999;
pointer-events: none; /* key: lets elementFromPoint see through it */
background: var(--accent);
color: #fff;
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
opacity: 0.9;
}

Rust backend: the move command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#[tauri::command]
fn move_item(src_path: String, dest_dir: String) -> Result<String, String> {
let src = PathBuf::from(&src_path);
let dest = PathBuf::from(&dest_dir);

if !src.exists() {
return Err("Source does not exist".to_string());
}
if !dest.is_dir() {
return Err("Destination is not a directory".to_string());
}

let file_name = src.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let dest_full = dest.join(&file_name);

if dest_full.exists() {
return Err("A file/folder with that name already exists at destination".to_string());
}

fs::rename(&src, &dest_full)
.map_err(|e| format!("Move failed: {}", e))?;

Ok(dest_full.to_string_lossy().to_string())
}

2.3 Key Design Points

Point Detail
Movement threshold Drag activates only after moving 4px+ from mousedown, so clicks never trigger drags
pointer-events: none The ghost must set this, otherwise elementFromPoint hits the ghost instead of the target
wasDragging flag A click still fires after mouseup — intercept it in the capture phase
Body-level cursor Add a class to body during drags for cursor: grabbing, so the pointer is correct everywhere
Subdirectory guard Prevent dropping a folder into its own subdirectory (infinite loop)
Name-collision check Backend verifies no same-named file/folder exists at the destination

3. Scenario 2: External Files Dropped Into the App

3.1 Dual-strategy architecture

Dropping external files needs both strategies at once:

  • Strategy A (primary): Tauri native events — real file paths via tauri://drag-enter / tauri://drag-leave / tauri://drag-drop
  • Strategy B (supporting): HTML5 DnD events — UI visual feedback (highlight borders) backing up strategy A

Why both? Tauri’s native events only provide drag-enter / drag-leave / drag-drop — no dragover — but the UI highlight needs continuous updates during dragover.

3.2 Rust: forwarding native drag events

Register an on_window_event handler in the Tauri builder to forward native drag events to the frontend:

1
2
3
4
5
6
7
8
tauri::Builder::default()
.on_window_event(|window, event| {
if let tauri::WindowEvent::DragDrop(drop_event) = event {
if let tauri::DragDropEvent::Drop { paths, position: _ } = drop_event {
let _ = window.emit("tauri://drag-drop", paths);
}
}
})

Note: Tauri v2’s DragDropEvent has no prevent_default() method — don’t try to call it.

3.3 Frontend: Tauri events + HTML5 DnD assist

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
function setupExternalDragDrop() {
const targetPanel = document.querySelector('.explorer-panel');
const editorPanel = document.querySelector('.editor-panel');

// ── Strategy A: Tauri native events ──
if (window.__TAURI__?.event?.listen) {
window.__TAURI__.event.listen('tauri://drag-enter', () => {
targetPanel?.classList.add('explorer-drag-over');
});

window.__TAURI__.event.listen('tauri://drag-leave', () => {
targetPanel?.classList.remove('explorer-drag-over');
});

window.__TAURI__.event.listen('tauri://drag-drop', (event) => {
targetPanel?.classList.remove('explorer-drag-over');
const paths = Array.isArray(event.payload)
? event.payload
: (event.payload?.paths || []);
if (paths.length > 0) {
handleDroppedFiles(paths);
}
});
}

// ── Strategy B: HTML5 DnD (visual feedback + fallback paths) ──
if (targetPanel) {
['dragenter', 'dragover'].forEach((name) =>
targetPanel.addEventListener(name, (e) => {
e.preventDefault();
e.stopPropagation();
targetPanel.classList.add('explorer-drag-over');
})
);
['dragleave', 'drop'].forEach((name) =>
targetPanel.addEventListener(name, (e) => {
e.preventDefault();
e.stopPropagation();
targetPanel.classList.remove('explorer-drag-over');
})
);
targetPanel.addEventListener('drop', async (e) => {
const paths = [];
if (e.dataTransfer?.files) {
for (const f of e.dataTransfer.files) {
if (f.path) paths.push(f.path);
}
}
if (paths.length > 0) {
await handleDroppedFiles(paths);
}
});
}

// ── Editor area: only .md files accepted ──
if (editorPanel) {
const MD_RE = /\.(md|markdown|mdown|mkd|mkdn|mdwn|mdtxt|text|mdtext|Rmd)$/i;
['dragenter', 'dragover'].forEach((ev) =>
editorPanel.addEventListener(ev, (e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
e.stopPropagation();
editorPanel.classList.add('editor-drag-over');
})
);
['dragleave', 'drop'].forEach((ev) =>
editorPanel.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
editorPanel.classList.remove('editor-drag-over');
})
);
editorPanel.addEventListener('drop', async (e) => {
if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) return;
for (const f of e.dataTransfer.files) {
if (MD_RE.test(f.name) && f.path) {
await openFile(f.path);
break;
}
}
});
}
}

3.4 CSS: drop-zone visual feedback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/* explorer drop highlight */
.panel-content.explorer-drag-over {
position: relative;
}

.panel-content.explorer-drag-over::after {
content: '';
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
border: 2px dashed var(--accent);
border-radius: var(--radius-sm);
background: rgba(108, 99, 255, 0.06);
animation: dragPulse 1.5s ease-in-out infinite;
}

/* editor drop highlight */
.editor-panel.editor-drag-over::after {
content: '';
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
border: 2px dashed var(--accent);
border-radius: var(--radius-sm);
background: rgba(108, 99, 255, 0.06);
animation: dragPulse 1.5s ease-in-out infinite;
}

@keyframes dragPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}

3.5 Tauri capability configuration

Make sure capabilities includes the event-listening permissions:

1
2
3
4
5
6
{
"permissions": [
"core:event:default",
"core:event:allow-listen"
]
}

4. Common Pitfalls

4.1 WebView2 intercepts HTML5 DnD events

Symptom Cause Fix
Cursor turns into the forbidden icon WebView2 intercepts drags at the native layer; preventDefault() is useless Use Mouse events for in-app dragging
drop never fires WebView2 treats the drag as files being dropped into the app Get paths from Tauri native events
dataTransfer.getData() returns empty WebView2 security restrictions Get paths from the Tauri event payload

4.2 elementFromPoint hits the ghost element

While the ghost follows the cursor, document.elementFromPoint(x, y) returns the ghost itself instead of the target underneath.

Fix: the ghost must set pointer-events: none.

4.3 Stray click after a drag

The browser still fires click after mouseup, accidentally opening the file/folder you just dropped.

Fix: use the wasDragging flag to intercept the click in the capture phase.

4.4 File.path may be undefined

In WebView2, the .path property of HTML5 DnD File objects may be undefined — no filesystem path available.

Fix: prefer Tauri native events (strategy A) for paths; HTML5 DnD is only visual feedback plus a fallback.

4.5 Tauri v2’s DragDropEvent has no prevent_default

Tauri v1’s DragDropEvent had prevent_default(); it was removed in v2. Don’t call it inside on_window_event.

4.6 External drops vs in-app drags conflicting

Mouse-event in-app dragging and HTML5 DnD / Tauri-native external drops don’t interfere, but note:

  • Mouse mousemove does not fire during an HTML5 DnD operation (the browser suppresses mouse events mid-DnD)
  • So both mechanisms coexist safely

5. Full Code Reference

Files involved in the CocoMarkdown project:

File Contents
src/app.js (L1211-L1315) In-app drag: Mouse-event implementation
src/app.js (L2225-L2320) External file drops: dual-strategy implementation
src/app.js (L2435-L2474) Post-drop file move handling
src/styles.css (L694-L793) Drag-related CSS
src-tauri/src/lib.rs (L808-L837) Rust move_item command
src-tauri/src/lib.rs (L871-L878) Rust on_window_event forwarding
src-tauri/capabilities/default.json Event-listening permissions

Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌─────────────────────────────────────────────────────┐
│ Tauri Drag & Drop Decision Tree │
├─────────────────────────────────────────────────────┤
│ │
│ What is the drag source? │
│ │ │
│ ├── DOM element inside the app │
│ │ └── Use Mouse events (mousedown/move/up) │
│ │ - No HTML5 DnD (WebView2 intercepts it) │
│ │ - ghost element: pointer-events: none │
│ │ - wasDragging swallows stray clicks │
│ │ │
│ └── External OS file │
│ └── Tauri native events + HTML5 DnD assist │
│ - Tauri events give real file paths │
│ - HTML5 DnD gives dragover feedback │
│ - on_window_event forwards drag-drop │
│ │
└─────────────────────────────────────────────────────┘

When developing a Tauri + Rust desktop app, teams often hit a bizarre situation: the app runs silky smooth under npm run tauri dev, but the packaged Release build (exe) mysteriously crashes when performing certain file I/O or async operations.

If your logs caught thread 'main' has overflowed its stack, this post may help you locate and fix the problem fast.

Read more »

Coco Bluetooth Remote Icon

Coco Bluetooth Remote

Get it on Google Play

Lost your TV remote again? Typing a password with arrow keys letter by letter? Coco Bluetooth Remote turns your phone into a Bluetooth remote control, a full wireless keyboard, and a touchpad — using the standard Bluetooth HID protocol.

No app to install on your TV. No Wi-Fi needed. No cloud, no account. Just pair over Bluetooth and start controlling.

Read more »

可可注音輸入法圖示

可可注音輸入法

實體鍵盤專用 • BlackBerry 台灣注音鍵位 • 五鍵選字

可可注音輸入法是一款專為實體全鍵盤手機打造的注音輸入法(ㄅㄆㄇㄈ),將 37 個注音符號與聲調符號完整映射到 QWERTY 鍵盤上,沿用 BlackBerry 台灣版注音鍵位習慣,並保留廣受好評的五鍵選字體系,讓您在實體鍵盤上重現流暢極速的台灣注音輸入體驗。

Read more »
0%