diff --git a/README.MD b/README.MD index b692e8d..1ea7294 100644 --- a/README.MD +++ b/README.MD @@ -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. @@ -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". diff --git a/app.py b/app.py index d8417a5..8b7bf10 100644 --- a/app.py +++ b/app.py @@ -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 @@ -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 diff --git a/markdown_reader/file_handler.py b/markdown_reader/file_handler.py index f23a559..ed15002 100644 --- a/markdown_reader/file_handler.py +++ b/markdown_reader/file_handler.py @@ -1,4 +1,5 @@ import os +import re from tkinter import messagebox @@ -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}") \ No newline at end of file + 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] \ No newline at end of file diff --git a/markdown_reader/ui.py b/markdown_reader/ui.py index e12322d..dcc2701 100644 --- a/markdown_reader/ui.py +++ b/markdown_reader/ui.py @@ -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 = "" @@ -692,6 +693,8 @@ def create_widgets(self): self.show_tab_context_menu, ) self.notebook.bind("<>", 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()) @@ -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 `<>` 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("<>", 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. @@ -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: @@ -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. @@ -1662,6 +1693,7 @@ def delete_ime_char(): # Bind KeyPress DIRECTLY with priority text_area.bind("", intercept_key, add=False) + self._register_drop_target(text_area) # Other bindings come after self.notebook.add(frame, text="") diff --git a/requirements.txt b/requirements.txt index 8dc548e..2b41a86 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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