Improved MarkdownViewer

#### Features
- Uses the latest [github-markdown.css](https://github.com/sindresorhus/github-markdown-css/blob/main/github-markdown.css) file that contains styling support for both dark and light mode.
- Table of Contents has an improved design, and:
	- The width can be resized.
	- TOC headings are automatically highlighted to help track your position in the file.
- Improved fullscreen layout based on Github.com behavior.
- External links clicked will open in default browser instead of doing nothing.
- Uses [markdownItAnchor](https://github.com/valeriangalliat/markdown-it-anchor) to allow heading anchor links in the file to work.
- Uses [highlight.js](https://github.com/highlightjs/highlight.js) to provide syntax highlighting to codeblocks.
#### Changes
- Made changes to allow the `md2html.html` file to use relative file imports for better maintainability.
- MarkdownViewer can now easily be customized by users by modifying files in `<Quicklook data folder>/QuickLook.Plugin.MarkdownViewer/`
- Caching and `localStorage` is now supported thanks to these changes.
- Prevent default behavior of spacebar scrolling the page, while we use spacebar to dismiss the preview.
- Sets `WebView` `DefaultBackgroundColor` to prevent white flash in dark mode. After the page has loaded, sets `DefaultBackgroundColor` back to white to have the expected default behavior on HTML pages that don't specify any background color.
#### Clean up
- Removed the need for `jQuery`.
- Removed old polyfill code.
This commit is contained in:
KamilDev
2024-12-04 22:16:21 +11:00
parent 9edf99fe88
commit cf7b6ad46f
11 changed files with 3134 additions and 107 deletions

View File

@@ -1,4 +1,4 @@
// Copyright © 2021 Paddy Xu and Frank Becker // Copyright © 2021 Paddy Xu and Frank Becker
// //
// This file is part of QuickLook program. // This file is part of QuickLook program.
// //
@@ -17,6 +17,7 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.Drawing;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Windows; using System.Windows;
@@ -29,8 +30,8 @@ namespace QuickLook.Plugin.HtmlViewer
{ {
public class WebpagePanel : UserControl public class WebpagePanel : UserControl
{ {
private Uri _currentUri; public Uri _currentUri;
private WebView2 _webView; public WebView2 _webView;
public WebpagePanel() public WebpagePanel()
{ {
@@ -44,10 +45,12 @@ namespace QuickLook.Plugin.HtmlViewer
{ {
CreationProperties = new CoreWebView2CreationProperties CreationProperties = new CoreWebView2CreationProperties
{ {
UserDataFolder = Path.Combine(SettingHelper.LocalDataPath, @"WebView2_Data\\") UserDataFolder = Path.Combine(SettingHelper.LocalDataPath, @"WebView2_Data\\"),
} },
DefaultBackgroundColor = OSThemeHelper.AppsUseDarkTheme() ? Color.FromArgb(255, 32, 32, 32) : Color.White, // Prevent white flash in dark mode
}; };
_webView.NavigationStarting += NavigationStarting_CancelNavigation; _webView.NavigationStarting += NavigationStarting_CancelNavigation;
_webView.NavigationCompleted += WebView_NavigationCompleted;
Content = _webView; Content = _webView;
} }
} }
@@ -83,6 +86,11 @@ namespace QuickLook.Plugin.HtmlViewer
if (newUri != _currentUri) e.Cancel = true; if (newUri != _currentUri) e.Cancel = true;
} }
private void WebView_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
{
_webView.DefaultBackgroundColor = Color.White; // Reset to white after page load to match expected default behavior
}
public void Dispose() public void Dispose()
{ {
_webView?.Dispose(); _webView?.Dispose();

View File

@@ -1,4 +1,4 @@
// Copyright © 2017 Paddy Xu // Copyright © 2017 Paddy Xu
// //
// This file is part of QuickLook program. // This file is part of QuickLook program.
// //
@@ -16,12 +16,17 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>. // along with this program. If not, see <http://www.gnu.org/licenses/>.
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
using System.Windows; using System.Windows;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using QuickLook.Common.Helpers;
using QuickLook.Common.Plugin; using QuickLook.Common.Plugin;
using QuickLook.Plugin.HtmlViewer; using QuickLook.Plugin.HtmlViewer;
using UtfUnknown; using UtfUnknown;
@@ -30,17 +35,90 @@ namespace QuickLook.Plugin.MarkdownViewer
{ {
public class Plugin : IViewer public class Plugin : IViewer
{ {
private WebpagePanel _panel; private WebpagePanel? _panel;
private static string _resourcePath;
private static readonly string ResourcePrefix = "QuickLook.Plugin.MarkdownViewer.Resources.";
private string? _currentHtmlPath;
private static bool OverrideFilesInDevelopment => true && Debugger.IsAttached; // Debug setting
static Plugin()
{
// Set up resource path in AppData
_resourcePath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Plugin.MarkdownViewer");
}
public int Priority => 0; public int Priority => 0;
public void Init() public void Init()
{ {
// Create directory if it doesn't exist
if (!Directory.Exists(_resourcePath) || OverrideFilesInDevelopment)
{
Directory.CreateDirectory(_resourcePath);
ExtractResources();
}
// Clean up any temporary HTML files if QuickLook was forcibly terminated
try
{
var tempFiles = Directory.GetFiles(_resourcePath, "temp_*.html");
foreach (var file in tempFiles)
{
try
{
File.Delete(file);
}
catch (IOException) { } // Ignore deletion errors
}
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to clean up temporary HTML files: {ex.Message}");
}
}
private void ExtractResources()
{
var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames();
foreach (var resourceName in resourceNames)
{
if (!resourceName.StartsWith(ResourcePrefix)) continue;
var relativePath = resourceName.Substring(ResourcePrefix.Length);
if (relativePath.Equals("resources", StringComparison.OrdinalIgnoreCase)) continue; // Skip 'resources' binary file
var targetPath = Path.Combine(_resourcePath, relativePath.Replace('/', Path.DirectorySeparatorChar));
// Create directory if it doesn't exist
var directory = Path.GetDirectoryName(targetPath);
if (directory != null)
Directory.CreateDirectory(directory);
// Extract the resource (skip if it already exists, unless in debug mode)
if (File.Exists(targetPath) && !OverrideFilesInDevelopment)
continue;
using (var resourceStream = assembly.GetManifestResourceStream(resourceName))
using (var fileStream = File.Create(targetPath))
{
resourceStream?.CopyTo(fileStream);
}
}
// Verify that md2html.html was extracted
var htmlPath = Path.Combine(_resourcePath, "md2html.html");
if (!File.Exists(htmlPath))
{
throw new FileNotFoundException($"Required template file md2html.html not found in resources. Available resources: {string.Join(", ", resourceNames)}");
}
} }
public bool CanHandle(string path) public bool CanHandle(string path)
{ {
return !Directory.Exists(path) && new[] {".md", ".rmd", ".markdown"}.Any(path.ToLower().EndsWith); return !Directory.Exists(path) && new[] { ".md", ".rmd", ".markdown" }.Any(path.ToLower().EndsWith);
} }
public void Prepare(string path, ContextObject context) public void Prepare(string path, ContextObject context)
@@ -54,29 +132,180 @@ namespace QuickLook.Plugin.MarkdownViewer
context.ViewerContent = _panel; context.ViewerContent = _panel;
context.Title = Path.GetFileName(path); context.Title = Path.GetFileName(path);
_panel.NavigateToHtml(GenerateMarkdownHtml(path)); var htmlPath = GenerateMarkdownHtml(path);
_panel.NavigateToFile(htmlPath);
_panel.Dispatcher.Invoke(() => { context.IsBusy = false; }, DispatcherPriority.Loaded); _panel.Dispatcher.Invoke(() => { context.IsBusy = false; }, DispatcherPriority.Loaded);
_panel._webView.NavigationStarting += NavigationStarting_CancelNavigation;
}
private void NavigationStarting_CancelNavigation(object sender, CoreWebView2NavigationStartingEventArgs e)
{
if (e.Uri.StartsWith("data:")) // when using NavigateToString
return;
var newUri = new Uri(e.Uri);
if (newUri == _panel?._currentUri) return;
e.Cancel = true;
// Open in default browser
try
{
if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri))
{
Debug.WriteLine($"Invalid URI format: {e.Uri}");
return;
}
// Safe schemes can open directly
if (uri.Scheme == Uri.UriSchemeHttp ||
uri.Scheme == Uri.UriSchemeHttps ||
uri.Scheme == Uri.UriSchemeMailto)
{
Process.Start(uri.AbsoluteUri);
return;
}
// Ask user for unsafe schemes. Use dispatcher to avoid blocking thread.
var associatedApp = GetAssociatedAppForScheme(uri.Scheme);
Application.Current.Dispatcher.BeginInvoke(new Action(() =>
{
var result = MessageBox.Show(
!string.IsNullOrEmpty(associatedApp) ?
$"The following link will open in {associatedApp}:\n{e.Uri}" : $"The following link will open:\n{e.Uri}",
!string.IsNullOrEmpty(associatedApp) ?
$"Open {associatedApp}?" : "Open custom URI?",
MessageBoxButton.YesNo,
MessageBoxImage.Information);
if (result == MessageBoxResult.Yes)
{
Process.Start(e.Uri);
}
}));
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open URL in browser: {ex.Message}");
}
}
[DllImport("Shlwapi.dll", CharSet = CharSet.Unicode)]
private static extern uint AssocQueryString(
AssocF flags,
AssocStr str,
string pszAssoc,
string? pszExtra,
[Out] StringBuilder? pszOut,
ref uint pcchOut);
[Flags]
private enum AssocF
{
None = 0,
VerifyExists = 0x1
}
private enum AssocStr
{
Command = 1,
Executable = 2,
FriendlyAppName = 4
}
private string? GetAssociatedAppForScheme(string scheme)
{
try
{
// Try to get friendly app name first
uint pcchOut = 0;
AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, null, ref pcchOut);
if (pcchOut > 0)
{
StringBuilder pszOut = new StringBuilder((int)pcchOut);
AssocQueryString(AssocF.None, AssocStr.FriendlyAppName, scheme, null, pszOut, ref pcchOut);
var appName = pszOut.ToString().Trim();
if (!string.IsNullOrEmpty(appName))
return appName;
}
// Fall back to executable name if friendly name is not available
pcchOut = 0;
AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, null, ref pcchOut);
if (pcchOut > 0)
{
StringBuilder pszOut = new StringBuilder((int)pcchOut);
AssocQueryString(AssocF.None, AssocStr.Executable, scheme, null, pszOut, ref pcchOut);
var exeName = pszOut.ToString().Trim();
if (!string.IsNullOrEmpty(exeName))
return Path.GetFileName(exeName);
}
return null;
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to get associated app: {ex.Message}");
return null;
}
}
private string GenerateMarkdownHtml(string path)
{
var templatePath = Path.Combine(_resourcePath, "md2html.html");
if (!File.Exists(templatePath))
throw new FileNotFoundException($"Required template file md2html.html not found in extracted resources at {templatePath}");
var bytes = File.ReadAllBytes(path);
var encoding = CharsetDetector.DetectFromBytes(bytes).Detected?.Encoding ?? Encoding.Default;
var content = encoding.GetString(bytes);
var template = File.ReadAllText(templatePath);
var html = template.Replace("{{content}}", content);
// Generate unique filename and ensure it doesn't exist
string outputPath;
do
{
var uniqueId = Guid.NewGuid().ToString("N").Substring(0, 8);
var outputFileName = $"temp_{uniqueId}.html";
outputPath = Path.Combine(_resourcePath, outputFileName);
} while (File.Exists(outputPath));
// Clean up previous file if it exists
CleanupTempHtmlFile();
File.WriteAllText(outputPath, html);
_currentHtmlPath = outputPath;
return outputPath;
}
private void CleanupTempHtmlFile()
{
if (!string.IsNullOrEmpty(_currentHtmlPath) && File.Exists(_currentHtmlPath))
{
try
{
File.Delete(_currentHtmlPath);
}
catch (IOException) { } // Ignore deletion errors
}
} }
public void Cleanup() public void Cleanup()
{ {
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
CleanupTempHtmlFile();
_panel?.Dispose(); _panel?.Dispose();
_panel = null; _panel = null;
} }
private string GenerateMarkdownHtml(string path)
{
var bytes = File.ReadAllBytes(path);
var encoding = CharsetDetector.DetectFromBytes(bytes).Detected?.Encoding ?? Encoding.Default;
var md = encoding.GetString(bytes);
md = WebUtility.HtmlEncode(md);
var html = Resources.md2html.Replace("{{content}}", md);
return html;
}
} }
} }

View File

@@ -70,11 +70,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Remove="Resources\**" /> <EmbeddedResource Include="Resources\**\*">
</ItemGroup> <LogicalName>QuickLook.Plugin.MarkdownViewer.Resources.%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
<ItemGroup>
<Resource Include="Resources\**" />
</ItemGroup> </ItemGroup>
</Project> </Project>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

View File

@@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long