Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Markdown Reader is a clean and intuitive Markdown editor/reader with real-time p
* Dark mode toggle.
* **Advanced Table Editor**: Interactive table insertion with customizable rows and columns.
* **Dual PDF Conversion**: Fast PyMuPDF mode or advanced [Docling](https://github.com/docling-project/docling) mode for complex documents.
* **Drag and Drop File Open**: Drag supported files directly into the app window to open them in a new tab.
* Built with pure Python, Tkinter, and ttkbootstrap — cross-platform.
* Can be bundled as a macOS app using `py2app`.
* Opens preview automatically and avoids multiple browser tabs for a smoother experience.
Expand Down Expand Up @@ -74,6 +75,7 @@ python app.py
### How to Use

* **Open File**: Choose `.md`, `.markdown`, `.html`, `.htm`, or `.pdf` from the "File → Open File" menu.
* **Drag and Drop**: Drag and drop files into the application window. Supported file types: `.md`, `.markdown`, `.html`, `.htm`, and `.pdf`.
* **Open with Double-Click**: Double-clicking a `.md` file opens it directly with the app and displays the document with a real-time preview.
* **Editor-Only Mode**: To open a `.md` file in the editor without automatically generating a web preview, configure the double-click behavior via "Settings → Open Behavior → Editor Only".
* **Dark Mode**: Toggle via "View → Toggle Dark Mode".
Expand Down
34 changes: 32 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
import ttkbootstrap as ttkb
from ttkbootstrap.constants import *

try:
from tkinterdnd2 import DND_FILES, TkinterDnD
except Exception:
DND_FILES = None
TkinterDnD = None

from markdown_reader.ui import MarkdownReader


Expand Down Expand Up @@ -185,9 +191,33 @@ def _handle_sigint(signum, frame):
return previous_handler


if __name__ == "__main__":
# Use ttkbootstrap window directly for stable styling across Python/Tk versions.
def _create_root_window():
"""
Create the application root window and attach drag-and-drop capability metadata.

When `tkinterdnd2` is available, this function creates a `TkinterDnD.Tk` root and
stores the DnD file token on the root object. Otherwise, it creates a
`ttkbootstrap.Window` root and disables DnD metadata.

:return: A Tk-compatible root window instance configured with the darkly theme and a `_dnd_files_type` attribute.

:raises tk.TclError: If the underlying Tk root window or theme initialization fails.
"""

if TkinterDnD is not None:
root = TkinterDnD.Tk()
ttkb.Style(theme="darkly")
root._dnd_files_type = DND_FILES
return root

# Use ttkbootstrap window directly for stable styling when TkDnD is unavailable.
root = ttkb.Window(themename="darkly")
root._dnd_files_type = None
return root


if __name__ == "__main__":
root = _create_root_window()
_set_macos_app_icon(root)

# Ensure window is resizable
Expand Down
116 changes: 71 additions & 45 deletions markdown_reader/file_handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
from tkinter import messagebox


Expand Down Expand Up @@ -42,67 +43,92 @@ def drop_file(event, app):
"""

try:
# Parse the event data - handle various formats
raw_data = str(event.data)

print(f"🔍 Parsing drop data: {raw_data[:100]}")

# Remove curly braces if present (common in tkinterdnd2)
raw_data = raw_data.strip('{}')

# Split by whitespace to handle multiple files
# But be careful with paths that have spaces
file_paths = []

if '{' in raw_data or '}' in raw_data:
# macOS format might use braces for each file
import re
paths = re.findall(r'\{([^}]+)\}', raw_data)
file_paths = paths if paths else [raw_data]
else:
# Try to split by common path separators
# This is a simplified approach - works for most cases
file_paths = [raw_data]

print(f" Extracted {len(file_paths)} file path(s)")

raw_data = str(getattr(event, "data", "") or "").strip()
if not raw_data:
messagebox.showwarning("Warning", "No files were dropped")
return

splitlist = None
tk_app = getattr(getattr(app, "root", None), "tk", None)
if tk_app is not None and hasattr(tk_app, "splitlist"):
splitlist = tk_app.splitlist

file_paths = _extract_paths_from_drop_data(raw_data, splitlist)

# Process each file
processed = False
processed_count = 0
skipped_count = 0
for file_path in file_paths:
file_path = file_path.strip()
file_path = os.path.abspath(file_path.strip())
if not file_path:
continue

print(f" Processing: {file_path}")


# Check if file exists
if not os.path.isfile(file_path):
print(f" ⚠️ File not found: {file_path}")
print(f"Skipping missing dropped path: {file_path}")
skipped_count += 1
continue

# Check file extension
if not file_path.lower().endswith(('.md', '.markdown', '.html', '.htm', '.pdf')):
print(f" ⚠️ Unsupported file type: {file_path}")
messagebox.showwarning("Warning", f"Only .md, .html, and .pdf files are supported")
print(f"Skipping unsupported dropped file type: {file_path}")
skipped_count += 1
continue

# Create a new tab and load the file
print(f" ✅ Loading file: {file_path}")
app.new_file()
idx = app.notebook.index(app.notebook.select())


# Use app.load_file() which handles HTML to Markdown conversion
app.load_file(file_path)
processed = True

# Only process the first valid file
break

if not processed:
processed_count += 1

if skipped_count > 0:
messagebox.showwarning(
"Warning",
"Only .md, .markdown, .html, .htm, and .pdf files are supported",
)

if processed_count == 0:
messagebox.showwarning("Warning", "No valid files found in drop data")

except Exception as e:
print(f"❌ Error in drop_file: {e}")
import traceback
traceback.print_exc()
messagebox.showerror("Error", f"Failed to process dropped file: {e}")
messagebox.showerror("Error", f"Failed to process dropped file: {e}")


def _extract_paths_from_drop_data(raw_data, splitlist=None):
"""
Extract one or more file-system paths from a TkDnD drop payload.

:param str raw_data: The raw drop payload string received from the drag-and-drop event.
:param callable splitlist: Optional Tk splitlist callable used to parse Tcl-style list payloads.

:return: A list of parsed path strings. If parsing yields no tokens, returns a single-item list containing the original payload.

:raises TypeError: If raw_data is not a string and cannot be processed by the regex fallback parser.
"""

if callable(splitlist):
try:
parsed = [part.strip() for part in splitlist(raw_data) if str(part).strip()]
if parsed:
return parsed
except Exception:
pass

# Fallback parser for raw payloads with braces, quotes, or plain whitespace.
pattern = r"\{([^}]*)\}|\"([^\"]*)\"|(\S+)"
matches = re.findall(pattern, raw_data)
paths = []
for brace_path, quoted_path, plain_path in matches:
candidate = brace_path or quoted_path or plain_path
candidate = candidate.strip()
if candidate:
paths.append(candidate)

if paths:
return paths

return [raw_data]
40 changes: 36 additions & 4 deletions markdown_reader/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ def __init__(self, root):
self._chat_busy = False
self._untitled_counter = 0
self.tab_document_ids = []
self._registered_drop_targets = set()
self._translation_session_counter = 0
self._translation_cancel_requested = False
self._last_search_query = ""
Expand Down Expand Up @@ -692,6 +693,8 @@ def create_widgets(self):
self.show_tab_context_menu,
)
self.notebook.bind("<<NotebookTabChanged>>", self._on_notebook_tab_changed)
self._register_drop_target(self.root)
self._register_drop_target(self.notebook)

self._set_ai_chat_panel_visible(self.ai_chat_panel_visible_var.get())

Expand Down Expand Up @@ -1530,6 +1533,36 @@ def open_ai_data_folder(self):
except Exception as exc:
dialogs.Messagebox.show_error("AI Data Folder", f"Failed to open folder: {exc}")

def _register_drop_target(self, widget):
"""
Register a widget as a file drop target when TkDnD support is enabled.

:param tk.Widget widget: The Tk-compatible widget to register for `<<Drop>>` events.

:return: A None value after attempting registration. Returns early when DnD is unavailable, unsupported on the widget, or already registered.

:raises AttributeError: If the MarkdownReader instance does not have an initialized `root` attribute.
"""

dnd_files_type = getattr(self.root, "_dnd_files_type", None)
if not dnd_files_type:
return

if not hasattr(widget, "drop_target_register") or not hasattr(widget, "dnd_bind"):
return

widget_id = str(widget)
if widget_id in self._registered_drop_targets:
return

try:
widget.drop_target_register(dnd_files_type)
widget.dnd_bind("<<Drop>>", self._on_drop_files)
self._registered_drop_targets.add(widget_id)
except Exception:
# Keep app usable even if drop target registration fails on a platform.
pass

def _on_drop_files(self, event):
"""
Handles file drop events.
Expand All @@ -1539,10 +1572,6 @@ def _on_drop_files(self, event):
:raises RuntimeError: If the error handling drop fails.
"""

print(f"🔍 Drop event triggered")
print(f" Event data type: {type(event.data)}")
print(f" Event data: {event.data}")

try:
drop_file(event, self)
except Exception as e:
Expand All @@ -1551,6 +1580,8 @@ def _on_drop_files(self, event):

traceback.print_exc()

return "break"

def new_file(self):
"""
Create a new editor tab and register editor-scoped behavior.
Expand Down Expand Up @@ -1662,6 +1693,7 @@ def delete_ime_char():

# Bind KeyPress DIRECTLY with priority
text_area.bind("<KeyPress>", intercept_key, add=False)
self._register_drop_target(text_area)

# Other bindings come after
self.notebook.add(frame, text="")
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ PyMuPDF>=1.23.0
weasyprint>=62.0
requests>=2.28.0
keyring>=25.0.0
tkinterdnd2>=0.4.3
docling
pyobjc-framework-Cocoa; sys_platform == "darwin"
mkdocs>=1.6.1
Expand Down