From 0b0db09d334ddcc1b4b5ff9350923adf9ebafb5e Mon Sep 17 00:00:00 2001 From: Tiago Craft Date: Sun, 10 May 2020 11:33:33 +0100 Subject: [PATCH] Previews open in the same window, on a different pane. Multiple files can be previewed and a file can have multiple previews. 'code-friendly' appended to the Markdown extras. edit_settings command moved from the palette to the settings menu. --- Main.sublime-menu | 23 ++ MarkdownLivePreview.py | 308 ++++++++------------------- MarkdownLivePreview.sublime-commands | 8 - markdown2html.py | 142 +++++------- 4 files changed, 165 insertions(+), 316 deletions(-) create mode 100644 Main.sublime-menu diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..b285445 --- /dev/null +++ b/Main.sublime-menu @@ -0,0 +1,23 @@ +[ + { + + "id": "preferences", + "children": [ + { + "caption": "Package Settings", + "id": "package-settings", + "children": [ + { + "caption": "MarkdownLivePreview settings", + "command": "edit_settings", + "args": + { + "base_file": "${packages}/MarkdownLivePreview/MarkdownLivePreview.sublime-settings", + "default": "{\n\t$0\n}\n" + } + } + ] + } + ] + } +] \ No newline at end of file diff --git a/MarkdownLivePreview.py b/MarkdownLivePreview.py index c23f276..ac5d469 100644 --- a/MarkdownLivePreview.py +++ b/MarkdownLivePreview.py @@ -6,247 +6,111 @@ original_window: the regular window preview_window: the window with the markdown file and the preview """ - -import time import os.path -import struct +import time +from functools import partial + import sublime 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" - +PREVIEW_VIEW_INFO = "preview_view_info" 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) - - -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): - - """ 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() - - syntax_file = original_view.settings().get("syntax") - - 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 - - sublime.run_command("new_window") - preview_window = sublime.active_window() - - 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]], - }, - ) +def find_preview(view): + """find previews for input view.""" + view_id = view.id() + for x in view.window().views(): + d = x.settings().get(PREVIEW_VIEW_INFO) + if d and d.get('id') == view_id: + yield x - 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") - # FIXME: hide number lines on preview - preview_window.focus_group(0) - if file_name: - markdown_view = preview_window.open_file(file_name) - else: - markdown_view = preview_window.new_file() - markdown_view.run_command("mdlp_insert", {"point": 0, "string": content}) - markdown_view.set_scratch(True) - - markdown_view.set_syntax_file(syntax_file) - markdown_view.settings().set( - MARKDOWN_VIEW_INFOS, {"original_window_id": original_window_id,}, - ) - - 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() +def get_resource(resource): + path = "Packages/MarkdownLivePreview/resources/" + resource + abs_path = os.path.join(sublime.packages_path(), "..", path) + if os.path.isfile(abs_path): + with open(abs_path, "r") as fp: + return fp.read() + return sublime.load_resource(path) class MarkdownLivePreviewListener(sublime_plugin.EventListener): - - 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 - - # 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) - markdown_view.erase(edit, total_region) - else: - self.content = None - - def on_load_async(self, markdown_view): - infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) - if not infos: - return - - preview_view = markdown_view.window().active_view_in_group(1) - - self.phantom_sets[markdown_view.id()] = sublime.PhantomSet(preview_view) - 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 - - assert ( - markdown_view.id() == self.markdown_view.id() - ), "pre_close view.id() != close view.id()" - - del self.phantom_sets[markdown_view.id()] - - 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) + last_update = 0 # update only if now() - last_update > DELAY + phantom_sets = {} # {preview.id(): PhantomSet} + + def on_pre_close(self, view): + """Closing markdown files closes any associated previews.""" + if "markdown" in view.settings().get("syntax").lower(): + previews = list(find_preview(view)) + if previews: + window = view.window() + for preview in previews: + window.focus_view(preview) + window.run_command('close_file') 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): - - infos = markdown_view.settings().get(MARKDOWN_VIEW_INFOS) - if not infos: - return - - # 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): - # 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) + d = view.settings().get(PREVIEW_VIEW_INFO) + if d: + view_id = view.id() + if view_id in self.phantom_sets: + del self.phantom_sets[view_id] + + def on_modified_async(self, view): + """Schedule an update when changing markdown files""" + if "markdown" in view.settings().get("syntax").lower(): + sublime.set_timeout(partial(self.update_preview, view), DELAY) + + def update_preview(self, view): + # 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) if time.time() - self.last_update < DELAY / 1000: return - - if markdown_view.buffer_id() == 0: + if view.buffer_id() == 0: + return + previews = list(find_preview(view)) + if not previews: return - self.last_update = time.time() - - total_region = sublime.Region(0, markdown_view.size()) - markdown = markdown_view.substr(total_region) - - preview_view = markdown_view.window().active_view_in_group(1) - viewport_width = preview_view.viewport_extent()[0] - - basepath = os.path.dirname(markdown_view.file_name()) - html = markdown2html( - markdown, - basepath, - partial(self._update_preview, markdown_view), - resources, - viewport_width, - ) - - self.phantom_sets[markdown_view.id()].update( - [ + for preview in previews: + html = markdown2html( + view.substr(sublime.Region(0, view.size())), + os.path.dirname(view.file_name()), + partial(self.update_preview, view), + resources, + preview.viewport_extent()[0]) + self.phantom_sets[preview.id()].update([ sublime.Phantom( sublime.Region(0), html, sublime.LAYOUT_BLOCK, - lambda href: sublime.run_command("open_url", {"url": href}), - ) - ] - ) - + lambda x: sublime.run_command("open_url", {"url": x}))]) -def get_settings(): - return sublime.load_settings("MarkdownLivePreview.sublime-settings") +class OpenMarkdownPreviewCommand(sublime_plugin.TextCommand): + def run(self, edit): + '''Set to multi-pane layout. Open markdown preview in another pane.''' + window = sublime.active_window() + if window.num_groups() < 2: + window.set_layout({ + "cols": [0.0, 0.5, 1.0], + "rows": [0.0, 1.0], + "cells": [[0, 0, 1, 1], [1, 0, 2, 1]]}) + window.focus_group(0 if window.active_group() else 1) + view = window.new_file() + view.set_scratch(True) + view.set_name("Preview") + view.settings().set(PREVIEW_VIEW_INFO, {'id': self.view.id()}) + ps = MarkdownLivePreviewListener.phantom_sets + ps[view.id()] = sublime.PhantomSet(view) + MarkdownLivePreviewListener().update_preview(self.view) + window.focus_view(self.view) -def get_resource(resource): - path = "Packages/MarkdownLivePreview/resources/" + resource - abs_path = os.path.join(sublime.packages_path(), "..", path) - if os.path.isfile(abs_path): - with open(abs_path, "r") as fp: - return fp.read() - return sublime.load_resource(path) + def is_enabled(self): + return "markdown" in self.view.settings().get("syntax").lower() def parse_image_resource(text): @@ -254,8 +118,12 @@ def parse_image_resource(text): return base64_image, (int(width), int(height)) -# try to reload the resources if we save this file -try: - plugin_loaded() -except OSError: - pass +def plugin_loaded(): + global DELAY + DELAY = sublime.load_settings("MarkdownLivePreview.sublime-settings").get( + "delay_between_updates") + 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") diff --git a/MarkdownLivePreview.sublime-commands b/MarkdownLivePreview.sublime-commands index f670328..cdca348 100644 --- a/MarkdownLivePreview.sublime-commands +++ b/MarkdownLivePreview.sublime-commands @@ -3,13 +3,5 @@ { "caption": "MarkdownLivePreview: Open Preview", "command": "open_markdown_preview" - }, - { - "caption": "MarkdownLivePreview: Open Settings", - "command": "edit_settings", "args": - { - "base_file": "${packages}/MarkdownLivePreview/MarkdownLivePreview.sublime-settings", - "default": "{\n\t$0\n}\n" - }, } ] \ No newline at end of file diff --git a/markdown2html.py b/markdown2html.py index 4e70cbd..e992680 100644 --- a/markdown2html.py +++ b/markdown2html.py @@ -1,35 +1,27 @@ -""" Notice how this file is completely independent of sublime text - -I think it should be kept this way, just because it gives a bit more organisation, -and makes it a lot easier to think about, and for anyone who would want to, test since -markdown2html is just a pure function -""" - +import base64 +import concurrent.futures import io -import struct import os.path -import concurrent.futures +import struct import urllib.request -import base64 -import bs4 - from functools import partial -from .lib.markdown2 import Markdown - -__all__ = ("markdown2html",) +import bs4 -markdowner = Markdown(extras=["fenced-code-blocks", "cuddled-lists"]) +from .lib.markdown2 import Markdown -# FIXME: how do I choose how many workers I want? Does thread pool reuse threads or -# does it stupidly throw them out? (we could implement something of our own) +markdowner = Markdown( + extras=["code-friendly", "fenced-code-blocks", "cuddled-lists"]) executor = concurrent.futures.ThreadPoolExecutor(max_workers=5) + def markdown2html(markdown, basepath, re_render, resources, viewport_width): - """ converts the markdown to html, loads the images and puts in base64 for sublime - to understand them correctly. That means that we are responsible for loading the - images from the internet. Hence, we take in re_render, which is just a function we - call when an image has finished loading to retrigger a render (see #90) + """converts the markdown to html. + + Loads the images and puts in base64 for sublime to understand them + correctly. That means that we are responsible for loading the images from + the internet. Hence, we take in re_render, which is just a function we call + when an image has finished loading to retrigger a render (see #90) """ html = markdowner.convert(markdown) @@ -38,19 +30,16 @@ def markdown2html(markdown, basepath, re_render, resources, viewport_width): src = img_element["src"] # already in base64, or something of the like - # FIXME: what other types are possible? Are they handled by ST? If not, could we - # convert it into base64? is it worth the effort? if src.startswith("data:image/"): continue if src.startswith("http://") or src.startswith("https://"): path = src elif src.startswith("file://"): - path = src[len("file://") :] + path = src[len("file://"):] else: - # expanduser: ~ -> /home/math2001 - # realpath: simplify that paths so that we don't have duplicated caches - path = os.path.realpath(os.path.expanduser(os.path.join(basepath, src))) + path = os.path.realpath( + os.path.expanduser(os.path.join(basepath, src))) base64, (width, height) = get_base64_image(path, re_render, resources) @@ -60,51 +49,48 @@ def markdown2html(markdown, basepath, re_render, resources, viewport_width): img_element["height"] = viewport_width * (height / width) # remove comments, because they pollute the console with error messages - for comment_element in soup.find_all( - text=lambda text: isinstance(text, bs4.Comment) - ): - comment_element.extract() - - # FIXME: how do tables look? should we use ascii tables? + [x.extract() + for x in soup.find_all(text=lambda text: isinstance(text, bs4.Comment))] - # pre aren't handled by ST3. The require manual adjustment + # pre aren't handled by ST3 and require manual adjustment for pre_element in soup.find_all("pre"): # select the first child, code_element = next(pre_element.children) - - # FIXME: this method sucks, but can we do better? fixed_pre = ( str(code_element) .replace(" ", '.') .replace("\n", "
") ) - code_element.replace_with(bs4.BeautifulSoup(fixed_pre, "html.parser")) - # FIXME: highlight the code using Sublime's syntax + # FIXME: + # - report that ST doesn't support
but does work with
... WTF? + return ("\n\n{}" + .format(resources["stylesheet"], soup) + .replace("
", "
")) - # FIXME: report that ST doesn't support
but does work with
... WTF? - return "\n\n{}".format(resources["stylesheet"], soup).replace( - "
", "
" - ) images_cache = {} images_loading = [] + def get_base64_image(path, re_render, resources): - """ Gets the base64 for the image (local and remote images). re_render is a - callback which is called when we finish loading an image from the internet - to trigger an update of the preview (the image will then be loaded from the cache) + """Gets the base64 for the image (local and remote images). + + re_render is a callback which is called when we finish loading an image + from the internet to trigger an update of the preview (the image will then + be loaded from the cache) return base64_data, (width, height) """ def callback(path, resources, future): - # altering images_cache is "safe" to do because callback is called in the same - # thread as add_done_callback: - # > Added callables are called in the order that they were added and are always - # > called in a thread belonging to the process that added them - # > --- Python docs + """Altering images_cache is "safe" to do because callback is called in + the same thread as add_done_callback: + > Added callables are called in the order that they were added and are + > always called in a thread belonging to the process that added them + (Python docs) + """ try: images_cache[path] = future.result() except urllib.error.HTTPError as e: @@ -113,37 +99,33 @@ def callback(path, resources, future): images_loading.remove(path) - # we render, which means this function will be called again, but this time, we - # will read from the cache + # we render, which means this function will be called again, but this + # time, we will read from the cache re_render() if path in images_cache: return images_cache[path] if path.startswith("http://") or path.startswith("https://"): - # FIXME: submiting a load of loaders, we should only have one if path not in images_loading: - executor.submit(load_image, path).add_done_callback(partial(callback, path, resources)) + executor.submit(load_image, path).add_done_callback( + partial(callback, path, resources)) images_loading.append(path) return resources['base64_loading_image'] with open(path, "rb") as fhandle: image_content = fhandle.read() width, height = get_image_size(io.BytesIO(image_content), path) - - image = "data:image/png;base64," + base64.b64encode(image_content).decode( - "utf-8" - ) + image = "data:image/png;base64," + base64.b64encode( + image_content).decode("utf-8") images_cache[path] = image, (width, height) return images_cache[path] def load_image(url): with urllib.request.urlopen(url, timeout=60) as conn: - image_content = conn.read() width, height = get_image_size(io.BytesIO(image_content), url) - content_type = conn.info().get_content_type() if "image" not in content_type: raise ValueError( @@ -151,21 +133,19 @@ def load_image(url): url, content_type ) ) - return ( - "data:image/png;base64," + base64.b64encode(image_content).decode("utf-8"), - (width, height), - ) + image_content = base64.b64encode(image_content).decode("utf-8") + return "data:image/png;base64," + image_content, (width, height) def get_image_size(fhandle, pathlike): - """ Thanks to https://stackoverflow.com/a/20380514/6164984 for providing the basis - of a working solution. - - fhandle should be a seekable stream. It's not the best for non-seekable streams, - but in our case, we have to load the whole stream into memory anyway because base64 - library only accepts bytes-like objects, and not streams. - - pathlike is the filename/path/url of the image so that we can guess the file format + """ Thanks to https://stackoverflow.com/a/20380514/6164984 + + fhandle should be a seekable stream. It's not the best for non-seekable + streams, but in our case, we have to load the whole stream into memory + anyway because base64 library only accepts bytes-like objects, and not + streams. + pathlike is the filename/path/url of the image so that we can guess the + file format """ format_ = os.path.splitext(os.path.basename(pathlike))[1][1:] @@ -190,7 +170,7 @@ def get_image_size(fhandle, pathlike): fhandle.seek(size, 1) byte = fhandle.read(1) if byte == b"": - fhandle = end + fhandle = end # noqa byte = fhandle.read(1) while ord(byte) == 0xFF: @@ -205,17 +185,3 @@ def get_image_size(fhandle, pathlike): else: return "unknown format {!r}".format(format_) return width, height - - -def independent_markdown2html(markdown): - return markdown2html( - markdown, - ".", - lambda: None, - { - "base64_404_image": ("", (0, 0)), - "base64_loading_image": ("", (0, 0)), - "stylesheet": "", - }, - 960, - )