diff --git a/README.md b/README.md index 3499691..195ab81 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A professional VS Code extension for visual Markdown comparison. Compare rendere - **Interactive Image Diff**: Compare visual changes in images using "Swipe" and "Onion Skin" comparison modes. - **Quick Edit**: Modify image paths and frontmatter metadata directly within the preview panels. - **Clipboard Compare**: Instantly compare any active Markdown file against your clipboard contents. +- 📤 **HTML Export**: Save the entire diff view as a standalone HTML file. It preserves custom styles and client-side interactions, making it perfect for offline sharing and peer reviews in standard web browsers. ## Supported Extensions @@ -60,6 +61,20 @@ If you enjoy **Rich Markdown Diff**, you might also like: - 🔗 **[Markdown Link Assistant](https://marketplace.visualstudio.com/items?itemName=phine-apps.markdown-link-assistant)**: The ultimate link manager for Visual Studio Code. Effortlessly transform raw URLs into beautiful, structured link previews and Notion-like cards with instant previews and AI summaries. +## Configuration + +You can customize the extension behavior via the following VS Code settings: + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `rich-markdown-diff.defaultViewMode` | `string` | `"side-by-side"` | Default layout view mode when opening a diff panel (`"side-by-side"` or `"inline"`). | +| `rich-markdown-diff.defaultFoldUnchanged` | `boolean` | `false` | Whether to automatically fold unchanged Markdown blocks when opening a diff. | +| `rich-markdown-diff.insertedColor` | `string` | `""` | Custom background color for inserted diff elements (e.g. `#e6ffec` or `rgba(46,160,67,0.15)`). | +| `rich-markdown-diff.deletedColor` | `string` | `""` | Custom background color for deleted diff elements (e.g. `#ffeef0` or `rgba(248,81,73,0.15)`). | +| `rich-markdown-diff.showGutterMarkers` | `boolean` | `true` | Whether to show vertical diff markers in the gutter area. | +| `rich-markdown-diff.showGitBlame` | `boolean` | `true` | Whether to load Git Blame metadata for the compared files. | +| `rich-markdown-diff.lineHoverDelay` | `number` | `500` | The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover. | + ## License Licensed under the [MIT License](LICENSE). diff --git a/package.json b/package.json index daa57b7..ba3cd70 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,13 @@ "category": "%rich-markdown-diff.category%", "icon": "$(arrow-up)", "enablement": "rich-markdown-diff.isDiffActive" + }, + { + "command": "rich-markdown-diff.exportHtml", + "title": "%rich-markdown-diff.exportHtml.title%", + "category": "%rich-markdown-diff.category%", + "icon": "$(export)", + "enablement": "rich-markdown-diff.isDiffActive" } ], "keybindings": [ @@ -117,6 +124,18 @@ "key": "shift+alt+f5", "when": "rich-markdown-diff.isDiffActive" }, + { + "command": "rich-markdown-diff.toggleInlineView", + "key": "alt+f7", + "mac": "alt+f7", + "when": "rich-markdown-diff.isDiffActive" + }, + { + "command": "rich-markdown-diff.toggleFoldUnchanged", + "key": "alt+f8", + "mac": "alt+f8", + "when": "rich-markdown-diff.isDiffActive" + }, { "command": "rich-markdown-diff.diffClipboard", "key": "ctrl+shift+c", @@ -142,6 +161,10 @@ "command": "rich-markdown-diff.prevChange", "when": "rich-markdown-diff.isDiffActive" }, + { + "command": "rich-markdown-diff.exportHtml", + "when": "rich-markdown-diff.isDiffActive" + }, { "command": "rich-markdown-diff.compareWithSelected", "when": "rich-markdown-diff.hasSelectedForCompare" @@ -180,6 +203,11 @@ "command": "rich-markdown-diff.toggleFoldUnchanged", "group": "navigation@4", "when": "rich-markdown-diff.isDiffActive" + }, + { + "command": "rich-markdown-diff.exportHtml", + "group": "navigation@5", + "when": "rich-markdown-diff.isDiffActive" } ], "explorer/context": [ @@ -248,6 +276,30 @@ "type": "number", "default": 500, "description": "%rich-markdown-diff.lineHoverDelay.description%" + }, + "rich-markdown-diff.insertedColor": { + "type": "string", + "default": "", + "description": "%rich-markdown-diff.insertedColor.description%" + }, + "rich-markdown-diff.deletedColor": { + "type": "string", + "default": "", + "description": "%rich-markdown-diff.deletedColor.description%" + }, + "rich-markdown-diff.defaultViewMode": { + "type": "string", + "enum": [ + "side-by-side", + "inline" + ], + "default": "side-by-side", + "description": "%rich-markdown-diff.defaultViewMode.description%" + }, + "rich-markdown-diff.defaultFoldUnchanged": { + "type": "boolean", + "default": false, + "description": "%rich-markdown-diff.defaultFoldUnchanged.description%" } } }, diff --git a/package.nls.ja.json b/package.nls.ja.json index 7a4afdf..7fe2532 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -13,5 +13,10 @@ "rich-markdown-diff.customEditor.displayName": "Markdown Diff プレビュー", "rich-markdown-diff.showGutterMarkers.description": "ガター領域に垂直な差分マーカーを表示するかどうか。", "rich-markdown-diff.showGitBlame.description": "比較対象ファイルの Git Blame メタデータを読み込むかどうか。", - "rich-markdown-diff.lineHoverDelay.description": "ホバー時に Git Blame や Quick Edit のインジケーターを表示するまでの遅延時間(ミリ秒)。" + "rich-markdown-diff.lineHoverDelay.description": "ホバー時に Git Blame や Quick Edit のインジケーターを表示するまでの遅延時間(ミリ秒)。", + "rich-markdown-diff.exportHtml.title": "差分を HTML としてエクスポート", + "rich-markdown-diff.insertedColor.description": "追加された差分要素のカスタム背景色(例: #e6ffec または rgba(46,160,67,0.15))。", + "rich-markdown-diff.deletedColor.description": "削除された差分要素のカスタム背景色(例: #ffeef0 または rgba(248,81,73,0.15))。", + "rich-markdown-diff.defaultViewMode.description": "差分パネルを開いた際のデフォルトレイアウト表示モード(side-by-side または inline)。", + "rich-markdown-diff.defaultFoldUnchanged.description": "差分パネルを開いた際にデフォルトで変更のないブロックを折りたたむかどうか。" } diff --git a/package.nls.json b/package.nls.json index d440999..f077028 100644 --- a/package.nls.json +++ b/package.nls.json @@ -13,5 +13,10 @@ "rich-markdown-diff.customEditor.displayName": "Markdown Diff Preview", "rich-markdown-diff.showGutterMarkers.description": "Whether to show vertical diff markers in the gutter area.", "rich-markdown-diff.showGitBlame.description": "Whether to load Git Blame metadata for the compared files.", - "rich-markdown-diff.lineHoverDelay.description": "The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover." + "rich-markdown-diff.lineHoverDelay.description": "The delay in milliseconds before showing Git Blame and Quick Edit indicators on hover.", + "rich-markdown-diff.exportHtml.title": "Export Diff to HTML", + "rich-markdown-diff.insertedColor.description": "Custom background color for inserted diff elements (e.g. #e6ffec or rgba(46,160,67,0.15)).", + "rich-markdown-diff.deletedColor.description": "Custom background color for deleted diff elements (e.g. #ffeef0 or rgba(248,81,73,0.15)).", + "rich-markdown-diff.defaultViewMode.description": "Default layout view mode when opening a diff panel (side-by-side or inline).", + "rich-markdown-diff.defaultFoldUnchanged.description": "Whether to fold unchanged blocks by default when opening a diff panel." } diff --git a/src/extension.ts b/src/extension.ts index af1516b..eaae151 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -640,6 +640,10 @@ async function renderDiffPanel( const showGutterMarkers = config.get("showGutterMarkers", true); const showGitBlame = config.get("showGitBlame", true); const lineHoverDelay = config.get("lineHoverDelay", 500); + const insertedColor = config.get("insertedColor", ""); + const deletedColor = config.get("deletedColor", ""); + const defaultViewMode = config.get("defaultViewMode", "side-by-side"); + const defaultFoldUnchanged = config.get("defaultFoldUnchanged", false); const { html: diffHtml, @@ -676,6 +680,10 @@ async function renderDiffPanel( showGutterMarkers, showGitBlame, lineHoverDelay, + insertedColor, + deletedColor, + defaultViewMode, + defaultFoldUnchanged, ); panel.webview.html = html; @@ -792,7 +800,11 @@ async function bindDiffPanel( if ( e.affectsConfiguration("rich-markdown-diff.showGutterMarkers") || e.affectsConfiguration("rich-markdown-diff.showGitBlame") || - e.affectsConfiguration("rich-markdown-diff.lineHoverDelay") + e.affectsConfiguration("rich-markdown-diff.lineHoverDelay") || + e.affectsConfiguration("rich-markdown-diff.insertedColor") || + e.affectsConfiguration("rich-markdown-diff.deletedColor") || + e.affectsConfiguration("rich-markdown-diff.defaultViewMode") || + e.affectsConfiguration("rich-markdown-diff.defaultFoldUnchanged") ) { lastContentKey = undefined; // Force re-render scheduleUpdate("document"); @@ -870,12 +882,23 @@ async function bindDiffPanel( if (message.command === "applyEdit" && currentState) { const uri = currentState.modifiedUri ?? currentState.fallbackSourceUri; - if (uri) { + const start = message.lineStart; + const end = message.lineEnd; + const newContent = message.newContent; + + if ( + uri && + uri.scheme !== "git" && + uri.scheme !== "gitlens" && + typeof start === "number" && + typeof end === "number" && + typeof newContent === "string" && + start >= 0 && + end >= start + ) { try { const document = await vscode.workspace.openTextDocument(uri); const edit = new vscode.WorkspaceEdit(); - const start = message.lineStart; - const end = message.lineEnd; // Construct range. end is exclusive line in markdown-it, // but in VS Code Range, (start, 0) to (end, 0) means lines [start, end-1]. @@ -886,7 +909,7 @@ async function bindDiffPanel( ); // Ensure newContent ends with newline if we replaced full lines - let text = message.newContent; + let text = newContent; if (end < document.lineCount && !text.endsWith("\n")) { text += "\n"; } @@ -1116,6 +1139,82 @@ export function activate(context: vscode.ExtensionContext) { return false; }, ), + vscode.commands.registerCommand("rich-markdown-diff.exportHtml", async () => { + if (!activePanel) { + vscode.window.showWarningMessage( + l10n.t("No active diff panel found to export."), + ); + return; + } + + let cleanTitle = activePanel.title + .replace(/[\/\\:\*\?"<>\|]/g, "_") + .trim(); + cleanTitle = cleanTitle.replace(/\.[a-zA-Z0-9]+$/i, ""); + if (!cleanTitle.endsWith("-diff.html")) { + cleanTitle += "-diff.html"; + } + + const defaultUri = vscode.workspace.workspaceFolders?.[0] + ? vscode.Uri.joinPath( + vscode.workspace.workspaceFolders[0].uri, + cleanTitle, + ) + : undefined; + + const saveUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { + "HTML Files": ["html"], + }, + saveLabel: l10n.t("Export HTML"), + }); + + if (!saveUri) { + return; + } + + try { + let htmlContent = activePanel.webview.html; + + const mermaidCdn = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"; + const hljsLightCdn = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github.min.css"; + const hljsDarkCdn = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css"; + + htmlContent = htmlContent + .replace( + /href="[^"]*github\.min\.css[^"]*"/i, + `href="${hljsLightCdn}"` + ) + .replace( + /href="[^"]*github-dark\.min\.css[^"]*"/i, + `href="${hljsDarkCdn}"` + ) + .replace( + /src="[^"]*mermaid\.min\.js[^"]*"/i, + `src="${mermaidCdn}"` + ); + + await vscode.workspace.fs.writeFile( + saveUri, + Buffer.from(htmlContent, "utf8"), + ); + + const openOption = l10n.t("Open File"); + const selected = await vscode.window.showInformationMessage( + l10n.t("Exported Markdown diff to {0}", path.basename(saveUri.fsPath)), + openOption, + ); + + if (selected === openOption) { + await vscode.env.openExternal(saveUri); + } + } catch (error) { + vscode.window.showErrorMessage( + l10n.t("Failed to export HTML: {0}", String(error)), + ); + } + }), ); context.subscriptions.push( diff --git a/src/gitBlameResolver.ts b/src/gitBlameResolver.ts index f390212..96a6848 100644 --- a/src/gitBlameResolver.ts +++ b/src/gitBlameResolver.ts @@ -65,7 +65,7 @@ export async function resolveBlameInfo( // ... // filename // \t - const child = child_process.spawn("git", ["blame", "--porcelain", fileName], { cwd }); + const child = child_process.spawn("git", ["blame", "--porcelain", "--", fileName], { cwd }); child.stderr?.resume(); @@ -118,7 +118,7 @@ export async function resolveBlameInfo( // Check if it's the start of a new line block in the porcelain output const headerMatch = line.match( - /^([0-9a-f]{40})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/, + /^([0-9a-f]{40,64})\s+(\d+)\s+(\d+)(?:\s+(\d+))?$/, ); if (headerMatch) { currentHash = headerMatch[1]; diff --git a/src/markdown/mdxPlugin.ts b/src/markdown/mdxPlugin.ts index fcde649..a74c544 100644 --- a/src/markdown/mdxPlugin.ts +++ b/src/markdown/mdxPlugin.ts @@ -85,18 +85,21 @@ export default function mdxPlugin(md: MarkdownIt) { let nextLine = startLine + 1; let depth = 1; const closeTag = ``; + const escapedTagName = tagName.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); + const openTagRegex = new RegExp(`^<${escapedTagName}(?:[\\s/>]|$)`, "i"); + const closeTagRegex = new RegExp(`^]|$)`, "i"); while (nextLine < endLine) { const nextPos = state.bMarks[nextLine] + state.tShift[nextLine]; const nextMax = state.eMarks[nextLine]; const nextLineText = state.src.slice(nextPos, nextMax).trim(); - if (nextLineText === closeTag) { + if (closeTagRegex.test(nextLineText)) { depth--; if (depth === 0) { break; } - } else if (nextLineText.startsWith(`<${tagName}`)) { + } else if (openTagRegex.test(nextLineText)) { depth++; } nextLine++; diff --git a/src/markdown/renderer.ts b/src/markdown/renderer.ts index 220f581..8c4ddc5 100644 --- a/src/markdown/renderer.ts +++ b/src/markdown/renderer.ts @@ -224,8 +224,8 @@ export function injectLineNumbers(md: MarkdownIt) { // try to inject them into the first tag of the output. if (adjustedStart !== undefined && html && !/data-line="/i.test(html)) { html = html.replace( - /(\/?>)/, - ` data-line="${adjustedStart}" data-line-end="${adjustedEnd}"$1`, + /^(<[a-zA-Z0-9-]+(?:\s+[^"'>=\s]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>\s]+))?)*\s*)(\/?>)/i, + `$1 data-line="${adjustedStart}" data-line-end="${adjustedEnd}"$2`, ); } return html; diff --git a/src/markdown/tableDiff.ts b/src/markdown/tableDiff.ts index d5ce8a2..4284c6d 100644 --- a/src/markdown/tableDiff.ts +++ b/src/markdown/tableDiff.ts @@ -364,17 +364,18 @@ export function renderMergedTable( ? "diff-col-ins" : ""; - if (m.oldIdx !== null && m.newIdx !== null) { - const oldH = oldTable.headers[m.oldIdx]; - const newH = newTable.headers[m.newIdx]; + const oldH = m.oldIdx !== null && m.oldIdx >= 0 && m.oldIdx < oldTable.headers.length ? oldTable.headers[m.oldIdx] : undefined; + const newH = m.newIdx !== null && m.newIdx >= 0 && m.newIdx < newTable.headers.length ? newTable.headers[m.newIdx] : undefined; + + if (oldH && newH) { const diff = execute(oldH.html, newH.html); html += `${diff}`; - } else if (m.oldIdx !== null) { - const oldH = oldTable.headers[m.oldIdx]; + } else if (oldH) { html += `${oldH.html}`; - } else { - const newH = newTable.headers[m.newIdx!]; + } else if (newH) { html += `${newH.html}`; + } else { + html += ``; } }); html += ""; @@ -394,17 +395,18 @@ export function renderMergedTable( ? "diff-col-ins" : ""; - if (cm.oldIdx !== null && cm.newIdx !== null) { - const oldC = oldR.cells[cm.oldIdx]; - const newC = newR.cells[cm.newIdx]; + const oldC = cm.oldIdx !== null && cm.oldIdx >= 0 && cm.oldIdx < oldR.cells.length ? oldR.cells[cm.oldIdx] : undefined; + const newC = cm.newIdx !== null && cm.newIdx >= 0 && cm.newIdx < newR.cells.length ? newR.cells[cm.newIdx] : undefined; + + if (oldC && newC) { const diff = execute(oldC.html, newC.html); html += `${diff}`; - } else if (cm.oldIdx !== null) { - const oldC = oldR.cells[cm.oldIdx]; + } else if (oldC) { html += `${oldC.html}`; - } else { - const newC = newR.cells[cm.newIdx!]; + } else if (newC) { html += `${newC.html}`; + } else { + html += ``; } }); html += ""; @@ -418,8 +420,8 @@ export function renderMergedTable( : cm.oldIdx === null ? "diff-col-ins" : ""; - if (cm.oldIdx !== null) { - const oldC = oldR.cells[cm.oldIdx]; + const oldC = cm.oldIdx !== null && cm.oldIdx >= 0 && cm.oldIdx < oldR.cells.length ? oldR.cells[cm.oldIdx] : undefined; + if (oldC) { html += `${oldC.html}`; } else { html += ``; @@ -436,8 +438,8 @@ export function renderMergedTable( : cm.oldIdx === null ? "diff-col-ins" : ""; - if (cm.newIdx !== null) { - const newC = newR.cells[cm.newIdx]; + const newC = cm.newIdx !== null && cm.newIdx >= 0 && cm.newIdx < newR.cells.length ? newR.cells[cm.newIdx] : undefined; + if (newC) { html += `${newC.html}`; } else { html += ``; diff --git a/src/markdown/webviewTemplate.ts b/src/markdown/webviewTemplate.ts index 934b614..d91f4e5 100644 --- a/src/markdown/webviewTemplate.ts +++ b/src/markdown/webviewTemplate.ts @@ -60,6 +60,10 @@ export function getWebviewContent( showGutterMarkers: boolean = true, showGitBlame: boolean = true, lineHoverDelay: number = 500, + insertedColor: string = "", + deletedColor: string = "", + defaultViewMode: string = "side-by-side", + defaultFoldUnchanged: boolean = false, ): string { const nonce = crypto.randomBytes(16).toString("hex"); @@ -2192,9 +2196,15 @@ export function getWebviewContent( color: var(--vscode-descriptionForeground); margin: 0 2px; } + :root { + ${insertedColor ? `--custom-ins-bg: ${escapeHtml(insertedColor)};` : ""} + ${deletedColor ? `--custom-del-bg: ${escapeHtml(deletedColor)};` : ""} + } + ${insertedColor ? `ins.diffins, ins.diffmod, .diff-col-ins { background-color: var(--custom-ins-bg) !important; }` : ""} + ${deletedColor ? `del.diffdel, del.diffmod, .diff-col-del { background-color: var(--custom-del-bg) !important; }` : ""} - +
@@ -2221,7 +2231,7 @@ export function getWebviewContent(
${showGitBlame ? '
' : ''}