import os import sys import sublime # Add the package archive path itself to sys.path package_path = os.path.dirname(__file__) if package_path not in sys.path: sys.path.insert(0, package_path) # --- Add lib to sys.path --- # Get the directory containing this file (MarkdownLivePreview.py) plugin_dir = os.path.dirname(__file__) # Construct the absolute path to the 'lib' directory lib_path = os.path.join(plugin_dir, 'lib') # Add it to the beginning of sys.path if it's not already there if lib_path not in sys.path: sys.path.insert(0, lib_path) # --- End sys.path modification --- """ Terminology original_view: the view in the regular editor, without it's own window markdown_view: the markdown view, in the special window preview_view: the preview view, in the special window original_window: the regular window preview_window: the window with the markdown file and the preview """ import time import struct import sublime_plugin from functools import partial from .markdown2html import markdown2html MARKDOWN_VIEW_INFOS = "markdown_view_infos" PREVIEW_VIEW_INFOS = "preview_view_infos" SETTING_DELAY_BETWEEN_UPDATES = "delay_between_updates" SETTING_FONT_SCALE = "font_scale" resources = {} def plugin_loaded(): global DELAY resources["base64_404_image"] = parse_image_resource(get_resource("404.base64")) resources["base64_loading_image"] = parse_image_resource( get_resource("loading.base64") ) resources["stylesheet"] = get_resource("stylesheet.css") # FIXME: how could we make this setting update without restarting sublime text # and not loading it every update as well DELAY = get_settings().get(SETTING_DELAY_BETWEEN_UPDATES, 100) # Provide default class MdlpInsertCommand(sublime_plugin.TextCommand): def run(self, edit, point, string): self.view.insert(edit, point, string) class OpenMarkdownPreviewCommand(sublime_plugin.TextCommand): def run(self, edit): print("--- MarkdownLivePreview: OpenMarkdownPreviewCommand running ---") """ If the file is saved exists on disk, we close it, and reopen it in a new window. Otherwise, we copy the content, erase it all (to close the file without a dialog) and re-insert it into a new view into a new window """ original_view = self.view original_window_id = original_view.window().id() file_name = original_view.file_name() print("--- MarkdownLivePreview: Original view ID: {}, File: {}, Window ID: {} ---".format(original_view.id(), file_name, original_window_id)) syntax_file = original_view.settings().get("syntax") # don't close the original view; keep it in your main window: # if file_name: # original_view.close() # else: # # the file isn't saved, we need to restore the content manually # total_region = sublime.Region(0, original_view.size()) # content = original_view.substr(total_region) # original_view.erase(edit, total_region) # original_view.close() # # FIXME: save the document to a temporary file, so that if we crash, # # the user doesn't lose what he wrote if not file_name: # If the file isn't saved, we still need the content for the new view total_region = sublime.Region(0, original_view.size()) content = original_view.substr(total_region) print("--- MarkdownLivePreview: Unsaved file, content length: {} ---".format(len(content))) # instead of making a new window, grab your existing one: preview_window = original_view.window() print("--- MarkdownLivePreview: Using existing window ID: {} ---".format(preview_window.id())) preview_window.run_command( "set_layout", { "cols": [0.0, 0.5, 1.0], "rows": [0.0, 1.0], "cells": [[0, 0, 1, 1], [1, 0, 2, 1]], }, ) preview_window.focus_group(1) preview_view = preview_window.new_file() preview_view.set_scratch(True) preview_view.settings().set(PREVIEW_VIEW_INFOS, {}) preview_view.set_name("Preview") print("--- MarkdownLivePreview: Created preview_view ID: {} ---".format(preview_view.id())) # FIXME: hide number lines on preview preview_window.focus_group(0) if file_name: markdown_view = preview_window.open_file(file_name) print("--- MarkdownLivePreview: Opened markdown_view ID: {} for file: {} ---".format(markdown_view.id(), file_name)) else: markdown_view = preview_window.new_file() markdown_view.run_command("mdlp_insert", {"point": 0, "string": content}) markdown_view.set_scratch(True) print("--- MarkdownLivePreview: Created new markdown_view ID: {} for unsaved content ---".format(markdown_view.id())) markdown_view.set_syntax_file(syntax_file) markdown_view.settings().set( MARKDOWN_VIEW_INFOS, {"original_window_id": original_window_id, "preview_view_id": preview_view.id(),}, ) infos_to_log = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) print("--- MarkdownLivePreview: Stored infos on markdown_view {}: {} ---".format(markdown_view.id(), infos_to_log)) # Manually trigger the initial setup and render via the listener # Use set_timeout_async to run it after the command finishes print("--- MarkdownLivePreview: Scheduling setup_and_update_preview for md_view: {}, pv_view: {} ---".format(markdown_view.id(), preview_view.id())) sublime.set_timeout_async(lambda: self.trigger_listener_setup(markdown_view.id(), preview_view.id()), 0) # Helper method to find and call the listener instance method # This is needed because the listener isn't easily accessible directly from the command instance def trigger_listener_setup(self, md_view_id, pv_view_id): print("--- MarkdownLivePreview: trigger_listener_setup running for md_view: {}, pv_view: {} ---".format(md_view_id, pv_view_id)) # Access the listener instance directly via its class variable listener_instance = MarkdownLivePreviewListener.instance if listener_instance: print("--- MarkdownLivePreview: Found listener instance via class variable, calling setup_and_update_preview ---") listener_instance.setup_and_update_preview(md_view_id, pv_view_id) else: # This should ideally not happen if the listener loaded correctly before the command ran print("--- MarkdownLivePreview: ERROR: MarkdownLivePreviewListener.instance is None! Cannot trigger setup. ---") def is_enabled(self): # FIXME: is this the best way there is to check if the current syntax is markdown? # should we only support default markdown? # what about "md"? # FIXME: what about other languages, where markdown preview roughly works? return "markdown" in self.view.settings().get("syntax").lower() class MarkdownLivePreviewListener(sublime_plugin.EventListener): instance = None # Class variable to hold the single instance phantom_sets = { # markdown_view.id(): phantom set } # we schedule an update for every key stroke, with a delay of DELAY # then, we update only if now() - last_update > DELAY last_update = 0 def __init__(self): super().__init__() # Good practice to call super MarkdownLivePreviewListener.instance = self print("--- MarkdownLivePreview: Listener instance created and registered. ---") # FIXME: maybe we shouldn't restore the file in the original window... def on_pre_close(self, markdown_view): """ Close the view in the preview window, and store information for the on_close listener (see doc there) """ if not markdown_view.settings().get(MARKDOWN_VIEW_INFOS): return self.markdown_view = markdown_view self.preview_window = markdown_view.window() self.file_name = markdown_view.file_name() if self.file_name is None: total_region = sublime.Region(0, markdown_view.size()) self.content = markdown_view.substr(total_region) else: self.content = None def on_load_async(self, markdown_view): infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) if not infos: # print("--- MarkdownLivePreview: on_load_async ignored for view {} - no infos ---".format(markdown_view.id())) # Optional: very verbose return print("--- MarkdownLivePreview: on_load_async triggered for markdown_view {} ---".format(markdown_view.id())) preview_view_id = infos.get("preview_view_id") if not preview_view_id: print("--- MarkdownLivePreview: ERROR in on_load_async: No preview_view_id found in infos: {} ---".format(infos)) return # Should not happen if setup was correct preview_view = sublime.View(preview_view_id) if not preview_view.is_valid(): print("--- MarkdownLivePreview: ERROR in on_load_async: Preview view {} is no longer valid ---".format(preview_view_id)) return # Preview view was closed before loading finished print("--- MarkdownLivePreview: on_load_async found valid preview_view {} ---".format(preview_view.id())) # PhantomSet creation is now handled by setup_and_update_preview triggered from the command # self.phantom_sets[markdown_view.id()] = sublime.PhantomSet(preview_view) # print("--- MarkdownLivePreview: PhantomSet created in on_load_async for preview_view {} ---".format(preview_view.id())) # Keep log commented self._update_preview(markdown_view) def on_close(self, markdown_view): """ Use the information saved to restore the markdown_view as an original_view """ infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) if not infos: return if markdown_view.id() in self.phantom_sets: del self.phantom_sets[markdown_view.id()] # don't close the entire window—just let the user close the preview tab: # self.preview_window.run_command("close_window") # find the window with the right id original_window = next( window for window in sublime.windows() if window.id() == infos["original_window_id"] ) if self.file_name: original_window.open_file(self.file_name) else: assert markdown_view.is_scratch(), ( "markdown view of an unsaved file should " "be a scratch" ) # note here that this is called original_view, because it's what semantically # makes sense, but this original_view.id() will be different than the one # that we closed first to reopen in the preview window # shouldn't cause any trouble though original_view = original_window.new_file() original_view.run_command( "mdlp_insert", {"point": 0, "string": self.content} ) original_view.set_syntax_file(markdown_view.settings().get("syntax")) # here, views are NOT treated independently, which is theoretically wrong # but in practice, you can only edit one markdown file at a time, so it doesn't really # matter. # @min_time_between_call(.5) def on_modified_async(self, markdown_view): # print("--- MarkdownLivePreview: on_modified_async triggered for view {} ---".format(markdown_view.id())) # Optional: very verbose infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) if not infos: return print("--- MarkdownLivePreview: Scheduling update for markdown_view {} ---".format(markdown_view.id())) # we schedule an update, which won't run if an sublime.set_timeout(partial(self._update_preview, markdown_view), DELAY) def _update_preview(self, markdown_view): print("--- MarkdownLivePreview: _update_preview called for markdown_view {} ---".format(markdown_view.id())) # if the buffer id is 0, that means that the markdown_view has been closed # This check is needed since a this function is used as a callback for when images # are loaded from the internet (ie. it could finish loading *after* the user # closes the markdown_view) # Reload settings each time to catch changes settings = get_settings() delay = settings.get(SETTING_DELAY_BETWEEN_UPDATES, 100) # Provide default font_scale = settings.get(SETTING_FONT_SCALE, 1.0) # Provide default print("--- MarkdownLivePreview: Using font_scale: {} ---".format(font_scale)) if time.time() - self.last_update < delay / 1000: print("--- MarkdownLivePreview: Update skipped for view {} due to time delay ---".format(markdown_view.id())) return if markdown_view.buffer_id() == 0: print("--- MarkdownLivePreview: Update skipped for view {}: buffer_id is 0 (view closed) ---".format(markdown_view.id())) return # Check if the phantom set still exists for this view ID if markdown_view.id() not in self.phantom_sets: print("--- MarkdownLivePreview: Update skipped for view {}: No phantom set found ---".format(markdown_view.id())) # View might have been closed between modification and update return print("--- MarkdownLivePreview: Update proceeding for view {} ---".format(markdown_view.id())) self.last_update = time.time() total_region = sublime.Region(0, markdown_view.size()) markdown = markdown_view.substr(total_region) print("--- MarkdownLivePreview: Read markdown content (length: {}) ---".format(len(markdown))) infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) if not infos: print("--- MarkdownLivePreview: ERROR in _update_preview: No infos found for view {} ---".format(markdown_view.id())) return # Should not happen preview_view_id = infos.get("preview_view_id") if not preview_view_id: print("--- MarkdownLivePreview: ERROR in _update_preview: No preview_view_id found in infos: {} ---".format(infos)) return # Should not happen preview_view = sublime.View(preview_view_id) if not preview_view.is_valid(): print("--- MarkdownLivePreview: ERROR in _update_preview: Preview view {} is no longer valid ---".format(preview_view_id)) return # Preview view was closed print("--- MarkdownLivePreview: Found valid preview_view {} for update ---".format(preview_view.id())) # Get viewport_width, default to a large value if view isn't ready viewport_extent = preview_view.viewport_extent() viewport_width = viewport_extent[0] if viewport_extent else 1024 print("--- MarkdownLivePreview: Viewport width: {} ---".format(viewport_width)) basepath = os.path.dirname(markdown_view.file_name()) if markdown_view.file_name() else '.' # Handle unsaved files print("--- MarkdownLivePreview: Calling markdown2html with basepath: {} ---".format(basepath)) html = markdown2html( markdown, basepath, partial(self._update_preview, markdown_view), resources, viewport_width, font_scale, ) # Truncate HTML safely for logging html_preview = html[:100].replace('\n', ' ') # Avoid breaking log lines print("--- MarkdownLivePreview: Generated HTML (starts with): {}... ---".format(html_preview)) self.phantom_sets[markdown_view.id()].update( [ sublime.Phantom( sublime.Region(0), html, sublime.LAYOUT_BLOCK, lambda href: sublime.run_command("open_url", {"url": href}), ) ] ) print("--- MarkdownLivePreview: Updated phantoms in preview_view {} ---".format(preview_view.id())) def setup_and_update_preview(self, markdown_view_id, preview_view_id): print("--- MarkdownLivePreview: setup_and_update_preview called for md_view: {}, pv_view: {} ---".format(markdown_view_id, preview_view_id)) markdown_view = sublime.View(markdown_view_id) preview_view = sublime.View(preview_view_id) if not markdown_view.is_valid(): print("--- MarkdownLivePreview: ERROR in setup_and_update: markdown_view {} is not valid ---".format(markdown_view_id)) return if not preview_view.is_valid(): print("--- MarkdownLivePreview: ERROR in setup_and_update: preview_view {} is not valid ---".format(preview_view_id)) return # Create PhantomSet if it doesn't exist (shouldn't at this point) if markdown_view_id not in self.phantom_sets: print("--- MarkdownLivePreview: Creating PhantomSet for preview_view {} in setup ---".format(preview_view.id())) self.phantom_sets[markdown_view_id] = sublime.PhantomSet(preview_view) else: # This case might occur if the command is run multiple times rapidly? Ensure it's associated correctly. print("--- MarkdownLivePreview: Warning: PhantomSet already existed for markdown_view {} in setup. Re-associating with preview_view {}. ---".format(markdown_view_id, preview_view.id())) self.phantom_sets[markdown_view_id].view = preview_view # Ensure it points to the correct view # Trigger the first update print("--- MarkdownLivePreview: Triggering initial _update_preview from setup ---") self._update_preview(markdown_view) def get_settings(): return sublime.load_settings("MarkdownLivePreview.sublime-settings") def get_resource(resource): package_name = __package__ # Get the current package name dynamically path = "Packages/{}/resources/{}".format(package_name, resource) # Original logic: check absolute path first (useful for unpacked development) # Adjusted abs_path to use dynamic package_name abs_path = os.path.join(sublime.packages_path(), package_name, "resources", resource) if os.path.isfile(abs_path): with open(abs_path, "r") as fp: return fp.read() # Fallback to sublime.load_resource (works for packed and unpacked) return sublime.load_resource(path) def parse_image_resource(text): width, height, base64_image = text.splitlines() return base64_image, (int(width), int(height)) # try to reload the resources if we save this file try: plugin_loaded() except OSError: pass