Merge pull request #1490 from KamilDev/feature/improve-markdown-viewer

Improved MarkdownViewer
This commit is contained in:
ema
2024-12-06 10:02:20 +08:00
committed by GitHub
13 changed files with 3414 additions and 105 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.
//
@@ -17,8 +17,11 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using Microsoft.Web.WebView2.Core;
@@ -44,10 +47,12 @@ namespace QuickLook.Plugin.HtmlViewer
{
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.NavigationCompleted += WebView_NavigationCompleted;
Content = _webView;
}
}
@@ -80,7 +85,134 @@ namespace QuickLook.Plugin.HtmlViewer
return;
var newUri = new Uri(e.Uri);
if (newUri != _currentUri) e.Cancel = true;
if (newUri == _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)
{
try
{
Process.Start(uri.AbsoluteUri);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to open URL: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
return;
}
// Ask user for unsafe schemes. Use dispatcher to avoid blocking thread.
string 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)
{
try
{
Process.Start(e.Uri);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to open URL: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}));
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open URL: {ex.Message}");
}
}
#region Get Associated App For Scheme
[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;
}
}
#endregion
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()

View File

@@ -0,0 +1,54 @@
param(
[Parameter(Mandatory=$true)]
[string]$ResourcesDir,
[Parameter(Mandatory=$true)]
[string]$OutputFile
)
# Get all files in the Resources directory
$files = Get-ChildItem -Path $ResourcesDir -Recurse -File | Sort-Object -Property FullName
# Create SHA256 hasher
$sha256 = [System.Security.Cryptography.SHA256]::Create()
# Create memory stream to combine all file contents
$memoryStream = New-Object System.IO.MemoryStream
# Add each file's path and contents to the hash
foreach ($file in $files) {
# Get relative path and convert to lowercase for consistent hashing
$relativePath = $file.FullName.Substring($ResourcesDir.Length + 1).ToLowerInvariant()
$pathBytes = [System.Text.Encoding]::UTF8.GetBytes("QuickLook.Plugin.MarkdownViewer.Resources.$relativePath".Replace("\", "/"))
$memoryStream.Write($pathBytes, 0, $pathBytes.Length)
# Add file contents
$fileBytes = [System.IO.File]::ReadAllBytes($file.FullName)
$memoryStream.Write($fileBytes, 0, $fileBytes.Length)
}
# Calculate final hash
$hashBytes = $sha256.ComputeHash($memoryStream.ToArray())
$hash = [BitConverter]::ToString($hashBytes).Replace("-", "").ToLowerInvariant()
# Generate C# code
$code = @"
// <auto-generated>
// This file was generated during build to indicate changes to embedded resources.
// </auto-generated>
namespace QuickLook.Plugin.MarkdownViewer
{
internal static class EmbeddedResourcesHash
{
internal const string Hash = "$hash";
}
}
"@
# Write to output file
[System.IO.File]::WriteAllText($OutputFile, $code)
# Cleanup
$memoryStream.Dispose()
$sha256.Dispose()

View File

@@ -1,4 +1,4 @@
// Copyright © 2017 Paddy Xu
// Copyright © 2017 Paddy Xu
//
// This file is part of QuickLook program.
//
@@ -16,12 +16,14 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Text;
using System.Windows;
using System.Windows.Threading;
using QuickLook.Common.Helpers;
using QuickLook.Common.Plugin;
using QuickLook.Plugin.HtmlViewer;
using UtfUnknown;
@@ -30,17 +32,27 @@ namespace QuickLook.Plugin.MarkdownViewer
{
public class Plugin : IViewer
{
private WebpagePanel _panel;
private WebpagePanel? _panel;
private string? _currentHtmlPath;
private static readonly string _resourcePath = Path.Combine(SettingHelper.LocalDataPath, "QuickLook.Plugin.MarkdownViewer");
private static readonly string _resourcePrefix = "QuickLook.Plugin.MarkdownViewer.Resources.";
private static readonly ResourceManager _resourceManager = new ResourceManager(_resourcePath, _resourcePrefix);
public int Priority => 0;
public void Init()
{
// Initialize resources and handle versioning
_resourceManager.InitializeResources();
// Clean up any temporary HTML files if QuickLook was forcibly terminated
CleanupTempFiles();
}
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)
@@ -54,29 +66,85 @@ namespace QuickLook.Plugin.MarkdownViewer
context.ViewerContent = _panel;
context.Title = Path.GetFileName(path);
_panel.NavigateToHtml(GenerateMarkdownHtml(path));
var htmlPath = GenerateMarkdownHtml(path);
_panel.NavigateToFile(htmlPath);
_panel.Dispatcher.Invoke(() => { context.IsBusy = false; }, DispatcherPriority.Loaded);
}
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;
}
#region Cleanup
private void CleanupTempHtmlFile()
{
if (!string.IsNullOrEmpty(_currentHtmlPath) && File.Exists(_currentHtmlPath))
{
try
{
File.Delete(_currentHtmlPath);
}
catch (IOException) { } // Ignore deletion errors
}
}
private void CleanupTempFiles()
{
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}");
}
}
public void Cleanup()
{
GC.SuppressFinalize(this);
CleanupTempHtmlFile();
_panel?.Dispose();
_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;
}
#endregion
}
}

View File

@@ -73,12 +73,28 @@
</ItemGroup>
<ItemGroup>
<None Remove="Resources\**" />
<EmbeddedResource Include="Resources\**\*">
<LogicalName>QuickLook.Plugin.MarkdownViewer.Resources.%(RecursiveDir)%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Resource Include="Resources\**" />
</ItemGroup>
<Target Name="GenerateEmbeddedResourcesHash" BeforeTargets="CoreCompile">
<PropertyGroup>
<HashFileDir>$(IntermediateOutputPath)Generated</HashFileDir>
<HashFilePath>$(HashFileDir)\EmbeddedResourcesHash.cs</HashFilePath>
</PropertyGroup>
<!-- Create the output directory -->
<MakeDir Directories="$(HashFileDir)" />
<!-- Run a script to calculate hash and generate code file -->
<Exec Command="powershell.exe -NoProfile -ExecutionPolicy Bypass -File &quot;$(MSBuildThisFileDirectory)GenerateEmbeddedResourcesHash.ps1&quot; -ResourcesDir &quot;$(MSBuildThisFileDirectory)Resources&quot; -OutputFile &quot;$(HashFilePath)&quot;" />
<!-- Include the generated file in compilation -->
<ItemGroup>
<Compile Include="$(HashFilePath)" />
</ItemGroup>
</Target>
<ItemGroup>
<Compile Include="..\..\GitVersion.cs">

View File

@@ -0,0 +1,247 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Windows;
using QuickLook.Common.Helpers;
namespace QuickLook.Plugin.MarkdownViewer
{
internal class ResourceManager
{
private readonly string _resourcePath;
private readonly string _resourcePrefix;
private readonly string _versionFilePath;
private readonly string _noUpdateFilePath;
private readonly string _embeddedHash;
public ResourceManager(string resourcePath, string resourcePrefix)
{
_resourcePath = resourcePath;
_resourcePrefix = resourcePrefix;
_versionFilePath = Path.Combine(_resourcePath, ".version");
_noUpdateFilePath = Path.Combine(_resourcePath, ".noupdate");
_embeddedHash = GetEmbeddedResourcesHash();
}
public void InitializeResources()
{
// Extract resources for the first time
if (!Directory.Exists(_resourcePath))
{
ExtractResources();
return;
}
// Check if updates are disabled
if (File.Exists(_noUpdateFilePath))
return;
// Check if resources need updating by comparing hashes
var versionInfo = ReadVersionFile();
if (versionInfo == null)
{
// No version file exists, create it and extract resources
ExtractResources();
return;
}
// If embedded hash matches stored hash, no update needed
if (_embeddedHash == versionInfo.EmbeddedHash) return;
// Calculate current directory hash
var currentDirectoryHash = CalculateDirectoryHash(_resourcePath);
// If current directory matches the stored extracted hash, user hasn't modified files
if (currentDirectoryHash == versionInfo.ExtractedHash)
{
// Safe to update
ExtractResources();
return;
}
// User has modified files, ask for permission to update
var result = MessageBox.Show(
"The MarkdownViewer resources have been updated. Would you like to update to the newest version?\n\n" +
"Note: Your current resources appear to have been modified. Updating will overwrite your modifications.",
"MarkdownViewer Update Available",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result == MessageBoxResult.Yes)
{
ExtractResources();
}
else
{
// Update the embedded hash in the version file to prevent any more prompts for this version
UpdateVersionFileEmbeddedHash();
}
}
private void ExtractResources()
{
// Delete and recreate directory to ensure clean state
if (Directory.Exists(_resourcePath))
{
try
{
Directory.Delete(_resourcePath, true);
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to delete directory {_resourcePath}: {ex.Message}");
// If we can't delete the directory, we'll try to continue with existing one
}
}
Directory.CreateDirectory(_resourcePath);
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
using (var stream = assembly.GetManifestResourceStream(resourceName))
using (var fileStream = File.Create(targetPath))
{
if (stream == null) continue;
stream.CopyTo(fileStream);
}
}
// Generate version file after extracting all resources
GenerateVersionFile();
// 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)}");
}
}
private class VersionInfo
{
public string EmbeddedHash { get; set; }
public string ExtractedHash { get; set; }
public VersionInfo(string embeddedHash, string extractedHash)
{
EmbeddedHash = embeddedHash;
ExtractedHash = extractedHash;
}
}
private static string CalculateDirectoryHash(string directory)
{
var files = Directory.GetFiles(directory, "*.*", SearchOption.AllDirectories)
.Where(f => !f.EndsWith(".version"))
.OrderBy(f => f)
.ToList();
using (var sha256 = SHA256.Create())
{
var combinedBytes = new List<byte>();
foreach (var file in files)
{
var relativePath = file.Substring(directory.Length + 1);
var pathBytes = Encoding.UTF8.GetBytes(relativePath.ToLowerInvariant());
combinedBytes.AddRange(pathBytes);
var contentBytes = File.ReadAllBytes(file);
combinedBytes.AddRange(contentBytes);
}
var hash = sha256.ComputeHash(combinedBytes.ToArray());
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private string GetEmbeddedResourcesHash()
{
try
{
return EmbeddedResourcesHash.Hash;
}
catch (Exception)
{
Debug.WriteLine("QuickLook.Plugin.MarkdownViewer: Embedded resources hash file not found.");
return CalculateEmbeddedResourcesHash();
}
}
private string CalculateEmbeddedResourcesHash()
{
var assembly = Assembly.GetExecutingAssembly();
using (var sha256 = SHA256.Create())
{
var combinedBytes = new List<byte>();
var resourceNames = assembly.GetManifestResourceNames()
.Where(name => name.StartsWith(_resourcePrefix) &&
!name.EndsWith(".version"))
.OrderBy(name => name)
.ToList();
foreach (var resourceName in resourceNames)
{
var nameBytes = Encoding.UTF8.GetBytes(resourceName.ToLowerInvariant());
combinedBytes.AddRange(nameBytes);
using (var stream = assembly.GetManifestResourceStream(resourceName))
{
if (stream == null) continue;
var buffer = new byte[stream.Length];
stream.Read(buffer, 0, buffer.Length);
combinedBytes.AddRange(buffer);
}
}
var hash = sha256.ComputeHash(combinedBytes.ToArray());
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
private void GenerateVersionFile()
{
var extractedHash = CalculateDirectoryHash(_resourcePath);
var newVersionContent = $"{_embeddedHash}{Environment.NewLine}{extractedHash}";
File.WriteAllText(_versionFilePath, newVersionContent);
}
private void UpdateVersionFileEmbeddedHash()
{
var versionInfo = ReadVersionFile() ?? throw new InvalidOperationException("Cannot update version file: no existing version file found");
var newVersionContent = $"{_embeddedHash}{Environment.NewLine}{versionInfo.ExtractedHash}";
File.WriteAllText(_versionFilePath, newVersionContent);
}
private VersionInfo? ReadVersionFile()
{
if (!File.Exists(_versionFilePath)) return null;
var lines = File.ReadAllLines(_versionFilePath);
if (lines.Length < 2) return null;
return new VersionInfo(lines[0], lines[1]);
}
}
}

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