Render markdown preview diffs
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
991453a447fdd731df9b37f4a4e29bacb4a88aea- Parents
-
0d02b3b - Tree
04ffbfc
991453a
991453a447fdd731df9b37f4a4e29bacb4a88aea0d02b3b
04ffbfc| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/handlers/repo/editor.go
|
200 | 14 |
| A |
internal/web/handlers/repo/editor_diff_test.go
|
79 | 0 |
| M |
internal/web/static/css/shithub.css
|
27 | 6 |
| M |
internal/web/templates/repo/editor.html
|
25 | 3 |
| M |
internal/web/templates/repo/markdown_preview.html
|
11 | 1 |
internal/web/handlers/repo/editor.gomodified@@ -3,6 +3,7 @@ | ||
| 3 | 3 | package repo |
| 4 | 4 | |
| 5 | 5 | import ( |
| 6 | + "context" | |
| 6 | 7 | "errors" |
| 7 | 8 | "fmt" |
| 8 | 9 | "html/template" |
@@ -275,33 +276,218 @@ func (h *Handlers) codeMarkdownPreview(w http.ResponseWriter, r *http.Request) { | ||
| 275 | 276 | h.d.Render.HTTPError(w, r, http.StatusRequestEntityTooLarge, "") |
| 276 | 277 | return |
| 277 | 278 | } |
| 279 | + ref := r.PostFormValue("ref") | |
| 280 | + if ref == "" { | |
| 281 | + ref = row.DefaultBranch | |
| 282 | + } | |
| 283 | + filePath := cleanEditorPath(r.PostFormValue("path")) | |
| 284 | + if !validateSubpath(filePath) { | |
| 285 | + filePath = "" | |
| 286 | + } | |
| 287 | + if r.PostFormValue("show_diff") == "1" { | |
| 288 | + sourcePath := cleanEditorPath(r.PostFormValue("original_path")) | |
| 289 | + if sourcePath == "" { | |
| 290 | + sourcePath = filePath | |
| 291 | + } | |
| 292 | + blocks, err := h.markdownPreviewDiffBlocks(r.Context(), owner.Username, row.Name, ref, filePath, sourcePath, body) | |
| 293 | + if err != nil { | |
| 294 | + h.d.Logger.WarnContext(r.Context(), "code: markdown diff preview", "error", err) | |
| 295 | + } else { | |
| 296 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 297 | + if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{ | |
| 298 | + "DiffMode": true, | |
| 299 | + "DiffBlocks": blocks, | |
| 300 | + }); err != nil { | |
| 301 | + h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err) | |
| 302 | + } | |
| 303 | + return | |
| 304 | + } | |
| 305 | + } | |
| 278 | 306 | rendered, err := mdrender.RenderDocumentHTML(body) |
| 279 | 307 | if err != nil { |
| 280 | 308 | h.d.Render.HTTPError(w, r, http.StatusInternalServerError, "") |
| 281 | 309 | return |
| 282 | 310 | } |
| 283 | - ref := r.PostFormValue("ref") | |
| 284 | - if ref == "" { | |
| 285 | - ref = row.DefaultBranch | |
| 311 | + rendered = rewriteEditorMarkdownURLs(rendered, owner.Username, row.Name, ref, filePath) | |
| 312 | + w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 313 | + if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{ | |
| 314 | + "MarkdownHTML": template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer | |
| 315 | + }); err != nil { | |
| 316 | + h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err) | |
| 286 | 317 | } |
| 287 | - filePath := cleanEditorPath(r.PostFormValue("path")) | |
| 318 | +} | |
| 319 | + | |
| 320 | +type markdownPreviewDiffBlock struct { | |
| 321 | + Kind string | |
| 322 | + HTML template.HTML | |
| 323 | +} | |
| 324 | + | |
| 325 | +type markdownLineRun struct { | |
| 326 | + Kind string | |
| 327 | + Lines []string | |
| 328 | +} | |
| 329 | + | |
| 330 | +func (h *Handlers) markdownPreviewDiffBlocks(ctx context.Context, owner, repoName, ref, previewPath, sourcePath string, body []byte) ([]markdownPreviewDiffBlock, error) { | |
| 331 | + var original []byte | |
| 332 | + if validateSubpath(sourcePath) && sourcePath != "" { | |
| 333 | + gitDir, err := h.d.RepoFS.RepoPath(owner, repoName) | |
| 334 | + if err != nil { | |
| 335 | + return nil, err | |
| 336 | + } | |
| 337 | + kind, _, size, err := repogit.StatPath(ctx, gitDir, ref, sourcePath) | |
| 338 | + switch { | |
| 339 | + case err == nil && kind == repogit.EntryBlob && size <= webedit.MaxTextBytes: | |
| 340 | + original, err = repogit.ReadBlobBytes(ctx, gitDir, ref, sourcePath, webedit.MaxTextBytes) | |
| 341 | + if err != nil { | |
| 342 | + return nil, err | |
| 343 | + } | |
| 344 | + case err == nil: | |
| 345 | + original = nil | |
| 346 | + case errors.Is(err, repogit.ErrPathNotFound): | |
| 347 | + original = nil | |
| 348 | + default: | |
| 349 | + return nil, err | |
| 350 | + } | |
| 351 | + } | |
| 352 | + return renderMarkdownPreviewDiffBlocks(original, body, func(rendered string) string { | |
| 353 | + return rewriteEditorMarkdownURLs(rendered, owner, repoName, ref, previewPath) | |
| 354 | + }) | |
| 355 | +} | |
| 356 | + | |
| 357 | +func renderMarkdownPreviewDiffBlocks(original, current []byte, rewrite func(string) string) ([]markdownPreviewDiffBlock, error) { | |
| 358 | + runs := markdownLineDiff(splitMarkdownLines(string(original)), splitMarkdownLines(string(current))) | |
| 359 | + blocks := make([]markdownPreviewDiffBlock, 0, len(runs)) | |
| 360 | + for _, run := range runs { | |
| 361 | + body := strings.Join(run.Lines, "") | |
| 362 | + if strings.TrimSpace(body) == "" { | |
| 363 | + continue | |
| 364 | + } | |
| 365 | + rendered, err := mdrender.RenderDocumentHTML([]byte(body)) | |
| 366 | + if err != nil { | |
| 367 | + return nil, err | |
| 368 | + } | |
| 369 | + if rewrite != nil { | |
| 370 | + rendered = rewrite(rendered) | |
| 371 | + } | |
| 372 | + blocks = append(blocks, markdownPreviewDiffBlock{ | |
| 373 | + Kind: run.Kind, | |
| 374 | + HTML: template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer | |
| 375 | + }) | |
| 376 | + } | |
| 377 | + return blocks, nil | |
| 378 | +} | |
| 379 | + | |
| 380 | +func rewriteEditorMarkdownURLs(rendered, owner, repoName, ref, filePath string) string { | |
| 288 | 381 | dir := parentPath(filePath) |
| 289 | 382 | if !validateSubpath(dir) { |
| 290 | 383 | dir = "" |
| 291 | 384 | } |
| 292 | - rendered = rewriteMarkdownRelativeURLs( | |
| 385 | + return rewriteMarkdownRelativeURLs( | |
| 293 | 386 | rendered, |
| 294 | - codeRouteBase(owner.Username, row.Name, "blob", ref, dir), | |
| 295 | - codeRouteBase(owner.Username, row.Name, "blob", ref, ""), | |
| 296 | - codeRouteBase(owner.Username, row.Name, "raw", ref, dir), | |
| 297 | - codeRouteBase(owner.Username, row.Name, "raw", ref, ""), | |
| 387 | + codeRouteBase(owner, repoName, "blob", ref, dir), | |
| 388 | + codeRouteBase(owner, repoName, "blob", ref, ""), | |
| 389 | + codeRouteBase(owner, repoName, "raw", ref, dir), | |
| 390 | + codeRouteBase(owner, repoName, "raw", ref, ""), | |
| 298 | 391 | ) |
| 299 | - w.Header().Set("Content-Type", "text/html; charset=utf-8") | |
| 300 | - if err := h.d.Render.RenderFragment(w, "repo/markdown_preview", map[string]any{ | |
| 301 | - "MarkdownHTML": template.HTML(rendered), //nolint:gosec // sanitized by markdown renderer | |
| 302 | - }); err != nil { | |
| 303 | - h.d.Logger.WarnContext(r.Context(), "code: markdown preview", "error", err) | |
| 304 | 392 | } |
| 393 | + | |
| 394 | +func splitMarkdownLines(s string) []string { | |
| 395 | + s = strings.ReplaceAll(s, "\r\n", "\n") | |
| 396 | + s = strings.ReplaceAll(s, "\r", "\n") | |
| 397 | + if s == "" { | |
| 398 | + return nil | |
| 399 | + } | |
| 400 | + lines := strings.SplitAfter(s, "\n") | |
| 401 | + if len(lines) > 0 && lines[len(lines)-1] == "" { | |
| 402 | + lines = lines[:len(lines)-1] | |
| 403 | + } | |
| 404 | + return lines | |
| 405 | +} | |
| 406 | + | |
| 407 | +const maxMarkdownPreviewDiffCells = 40000 | |
| 408 | + | |
| 409 | +func markdownLineDiff(original, current []string) []markdownLineRun { | |
| 410 | + if len(original) == 0 && len(current) == 0 { | |
| 411 | + return nil | |
| 412 | + } | |
| 413 | + if len(original) > 0 && len(current) > maxMarkdownPreviewDiffCells/len(original) { | |
| 414 | + return boundedMarkdownLineDiff(original, current) | |
| 415 | + } | |
| 416 | + width := len(current) + 1 | |
| 417 | + table := make([]int, (len(original)+1)*width) | |
| 418 | + for i := len(original) - 1; i >= 0; i-- { | |
| 419 | + for j := len(current) - 1; j >= 0; j-- { | |
| 420 | + if original[i] == current[j] { | |
| 421 | + table[i*width+j] = table[(i+1)*width+j+1] + 1 | |
| 422 | + continue | |
| 423 | + } | |
| 424 | + deleted := table[(i+1)*width+j] | |
| 425 | + added := table[i*width+j+1] | |
| 426 | + if deleted >= added { | |
| 427 | + table[i*width+j] = deleted | |
| 428 | + } else { | |
| 429 | + table[i*width+j] = added | |
| 430 | + } | |
| 431 | + } | |
| 432 | + } | |
| 433 | + var runs []markdownLineRun | |
| 434 | + i, j := 0, 0 | |
| 435 | + for i < len(original) && j < len(current) { | |
| 436 | + switch { | |
| 437 | + case original[i] == current[j]: | |
| 438 | + runs = appendMarkdownLineRun(runs, "context", current[j]) | |
| 439 | + i++ | |
| 440 | + j++ | |
| 441 | + case table[(i+1)*width+j] >= table[i*width+j+1]: | |
| 442 | + runs = appendMarkdownLineRun(runs, "deleted", original[i]) | |
| 443 | + i++ | |
| 444 | + default: | |
| 445 | + runs = appendMarkdownLineRun(runs, "added", current[j]) | |
| 446 | + j++ | |
| 447 | + } | |
| 448 | + } | |
| 449 | + for ; i < len(original); i++ { | |
| 450 | + runs = appendMarkdownLineRun(runs, "deleted", original[i]) | |
| 451 | + } | |
| 452 | + for ; j < len(current); j++ { | |
| 453 | + runs = appendMarkdownLineRun(runs, "added", current[j]) | |
| 454 | + } | |
| 455 | + return runs | |
| 456 | +} | |
| 457 | + | |
| 458 | +func boundedMarkdownLineDiff(original, current []string) []markdownLineRun { | |
| 459 | + prefix := 0 | |
| 460 | + for prefix < len(original) && prefix < len(current) && original[prefix] == current[prefix] { | |
| 461 | + prefix++ | |
| 462 | + } | |
| 463 | + origEnd := len(original) | |
| 464 | + currEnd := len(current) | |
| 465 | + for origEnd > prefix && currEnd > prefix && original[origEnd-1] == current[currEnd-1] { | |
| 466 | + origEnd-- | |
| 467 | + currEnd-- | |
| 468 | + } | |
| 469 | + var runs []markdownLineRun | |
| 470 | + for _, line := range current[:prefix] { | |
| 471 | + runs = appendMarkdownLineRun(runs, "context", line) | |
| 472 | + } | |
| 473 | + for _, line := range original[prefix:origEnd] { | |
| 474 | + runs = appendMarkdownLineRun(runs, "deleted", line) | |
| 475 | + } | |
| 476 | + for _, line := range current[prefix:currEnd] { | |
| 477 | + runs = appendMarkdownLineRun(runs, "added", line) | |
| 478 | + } | |
| 479 | + for _, line := range current[currEnd:] { | |
| 480 | + runs = appendMarkdownLineRun(runs, "context", line) | |
| 481 | + } | |
| 482 | + return runs | |
| 483 | +} | |
| 484 | + | |
| 485 | +func appendMarkdownLineRun(runs []markdownLineRun, kind, line string) []markdownLineRun { | |
| 486 | + if len(runs) > 0 && runs[len(runs)-1].Kind == kind { | |
| 487 | + runs[len(runs)-1].Lines = append(runs[len(runs)-1].Lines, line) | |
| 488 | + return runs | |
| 489 | + } | |
| 490 | + return append(runs, markdownLineRun{Kind: kind, Lines: []string{line}}) | |
| 305 | 491 | } |
| 306 | 492 | |
| 307 | 493 | func (h *Handlers) editorData(r *http.Request, cc *codeContext, mode, pathValue, content string) codeEditorData { |
internal/web/handlers/repo/editor_diff_test.goadded@@ -0,0 +1,79 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package repo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func TestMarkdownLineDiffSplitsAddedDeletedAndContextRuns(t *testing.T) { | |
| 11 | + t.Parallel() | |
| 12 | + | |
| 13 | + runs := markdownLineDiff( | |
| 14 | + splitMarkdownLines("alpha\nold\nomega\n"), | |
| 15 | + splitMarkdownLines("alpha\nnew\nomega\nextra\n"), | |
| 16 | + ) | |
| 17 | + | |
| 18 | + kinds := make([]string, 0, len(runs)) | |
| 19 | + for _, run := range runs { | |
| 20 | + kinds = append(kinds, run.Kind) | |
| 21 | + } | |
| 22 | + got := strings.Join(kinds, ",") | |
| 23 | + want := "context,deleted,added,context,added" | |
| 24 | + if got != want { | |
| 25 | + t.Fatalf("run kinds = %q, want %q", got, want) | |
| 26 | + } | |
| 27 | +} | |
| 28 | + | |
| 29 | +func TestRenderMarkdownPreviewDiffBlocksRendersChangedMarkdown(t *testing.T) { | |
| 30 | + t.Parallel() | |
| 31 | + | |
| 32 | + blocks, err := renderMarkdownPreviewDiffBlocks( | |
| 33 | + []byte("# Demo\n\nold line\n\nkept line\n"), | |
| 34 | + []byte("# Demo\n\nnew line\n\nkept line\n"), | |
| 35 | + func(rendered string) string { return rendered }, | |
| 36 | + ) | |
| 37 | + if err != nil { | |
| 38 | + t.Fatalf("renderMarkdownPreviewDiffBlocks: %v", err) | |
| 39 | + } | |
| 40 | + | |
| 41 | + var added, deleted bool | |
| 42 | + var joined strings.Builder | |
| 43 | + for _, block := range blocks { | |
| 44 | + if block.Kind == "added" { | |
| 45 | + added = true | |
| 46 | + } | |
| 47 | + if block.Kind == "deleted" { | |
| 48 | + deleted = true | |
| 49 | + } | |
| 50 | + joined.WriteString(string(block.HTML)) | |
| 51 | + } | |
| 52 | + if !added || !deleted { | |
| 53 | + t.Fatalf("expected added and deleted blocks, got %#v", blocks) | |
| 54 | + } | |
| 55 | + html := joined.String() | |
| 56 | + for _, want := range []string{"new line", "old line", "<h1"} { | |
| 57 | + if !strings.Contains(html, want) { | |
| 58 | + t.Fatalf("rendered diff HTML missing %q: %s", want, html) | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +func TestRenderMarkdownPreviewDiffBlocksSanitizesHTML(t *testing.T) { | |
| 64 | + t.Parallel() | |
| 65 | + | |
| 66 | + blocks, err := renderMarkdownPreviewDiffBlocks( | |
| 67 | + nil, | |
| 68 | + []byte("<script>alert(1)</script>\n\nok\n"), | |
| 69 | + func(rendered string) string { return rendered }, | |
| 70 | + ) | |
| 71 | + if err != nil { | |
| 72 | + t.Fatalf("renderMarkdownPreviewDiffBlocks: %v", err) | |
| 73 | + } | |
| 74 | + for _, block := range blocks { | |
| 75 | + if strings.Contains(string(block.HTML), "<script") { | |
| 76 | + t.Fatalf("rendered diff block was not sanitized: %s", block.HTML) | |
| 77 | + } | |
| 78 | + } | |
| 79 | +} | |
internal/web/static/css/shithub.cssmodified@@ -5442,11 +5442,11 @@ button.shithub-repo-action { | ||
| 5442 | 5442 | .shithub-editor-textbox textarea:focus { |
| 5443 | 5443 | box-shadow: inset 0 0 0 2px var(--accent-emphasis); |
| 5444 | 5444 | } |
| 5445 | -.shithub-editor-helpbar, | |
| 5446 | -.shithub-editor-attach { | |
| 5445 | +.shithub-editor-helpbar { | |
| 5447 | 5446 | min-height: 36px; |
| 5448 | 5447 | padding: 0.45rem 0.75rem; |
| 5449 | 5448 | border-top: 1px solid var(--border-default); |
| 5449 | + border-radius: 0 0 6px 6px; | |
| 5450 | 5450 | color: var(--fg-muted); |
| 5451 | 5451 | background: var(--canvas-subtle); |
| 5452 | 5452 | font-size: 0.875rem; |
@@ -5462,10 +5462,6 @@ button.shithub-repo-action { | ||
| 5462 | 5462 | background: var(--canvas-default); |
| 5463 | 5463 | font: 0.8rem ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; |
| 5464 | 5464 | } |
| 5465 | -.shithub-editor-attach { | |
| 5466 | - border-radius: 0 0 6px 6px; | |
| 5467 | - font-weight: 600; | |
| 5468 | -} | |
| 5469 | 5465 | .shithub-editor-preview { |
| 5470 | 5466 | min-height: min(72vh, 900px); |
| 5471 | 5467 | padding: 3rem 2.75rem; |
@@ -5475,6 +5471,31 @@ button.shithub-repo-action { | ||
| 5475 | 5471 | max-width: none; |
| 5476 | 5472 | color: var(--fg-default); |
| 5477 | 5473 | } |
| 5474 | +.shithub-editor-preview-body.is-diff { | |
| 5475 | + display: flex; | |
| 5476 | + flex-direction: column; | |
| 5477 | + gap: 0.35rem; | |
| 5478 | +} | |
| 5479 | +.shithub-editor-preview-diff-block { | |
| 5480 | + margin: 0; | |
| 5481 | +} | |
| 5482 | +.shithub-editor-preview-diff-block.is-added, | |
| 5483 | +.shithub-editor-preview-diff-block.is-deleted { | |
| 5484 | + padding-left: 0.95rem; | |
| 5485 | + border-left: 4px solid; | |
| 5486 | +} | |
| 5487 | +.shithub-editor-preview-diff-block.is-added { | |
| 5488 | + border-left-color: var(--success-emphasis); | |
| 5489 | +} | |
| 5490 | +.shithub-editor-preview-diff-block.is-deleted { | |
| 5491 | + border-left-color: var(--danger-fg); | |
| 5492 | +} | |
| 5493 | +.shithub-editor-preview-diff-block > :first-child { | |
| 5494 | + margin-top: 0; | |
| 5495 | +} | |
| 5496 | +.shithub-editor-preview-diff-block > :last-child { | |
| 5497 | + margin-bottom: 0; | |
| 5498 | +} | |
| 5478 | 5499 | .shithub-editor-preview-empty { |
| 5479 | 5500 | margin: 0; |
| 5480 | 5501 | color: var(--fg-muted); |
internal/web/templates/repo/editor.htmlmodified@@ -99,7 +99,6 @@ | ||
| 99 | 99 | <div class="shithub-editor-helpbar"> |
| 100 | 100 | Use <kbd>Control + Shift + m</kbd> to toggle the <kbd>tab</kbd> key moving focus. Alternatively, use <kbd>esc</kbd> then <kbd>tab</kbd> to move to the next interactive element on the page. |
| 101 | 101 | </div> |
| 102 | - <div class="shithub-editor-attach">Attach files by dragging & dropping, selecting or pasting them.</div> | |
| 103 | 102 | </div> |
| 104 | 103 | <div class="shithub-editor-pane shithub-editor-preview" data-editor-pane="preview" hidden> |
| 105 | 104 | <div class="markdown-body shithub-editor-preview-body" data-editor-preview data-preview-url="{{ .PreviewURL }}" data-preview-ref="{{ .Ref }}"> |
@@ -154,6 +153,8 @@ | ||
| 154 | 153 | let indentSize = 2; |
| 155 | 154 | let softWrap = true; |
| 156 | 155 | let previewDirty = true; |
| 156 | + let tabMovesFocus = false; | |
| 157 | + let nextTabMovesFocus = false; | |
| 157 | 158 | let submitting = false; |
| 158 | 159 | |
| 159 | 160 | function updateGutter() { |
@@ -210,6 +211,8 @@ | ||
| 210 | 211 | body.set("content", textarea.value); |
| 211 | 212 | body.set("ref", preview.dataset.previewRef || ""); |
| 212 | 213 | body.set("path", pathInput ? pathInput.value : ""); |
| 214 | + if (pathInput) body.set("original_path", pathInput.dataset.original || pathInput.value || ""); | |
| 215 | + if (showDiff && showDiff.checked) body.set("show_diff", "1"); | |
| 213 | 216 | preview.innerHTML = "<p class=\"shithub-editor-preview-empty\">Rendering preview...</p>"; |
| 214 | 217 | const res = await fetch(preview.dataset.previewUrl, { |
| 215 | 218 | method: "POST", |
@@ -254,6 +257,7 @@ | ||
| 254 | 257 | updateCommitState(); |
| 255 | 258 | textarea.addEventListener("input", function () { |
| 256 | 259 | updateGutter(); |
| 260 | + nextTabMovesFocus = false; | |
| 257 | 261 | previewDirty = true; |
| 258 | 262 | updateCommitState(); |
| 259 | 263 | }); |
@@ -261,7 +265,24 @@ | ||
| 261 | 265 | if (gutter) gutter.scrollTop = textarea.scrollTop; |
| 262 | 266 | }); |
| 263 | 267 | textarea.addEventListener("keydown", function (event) { |
| 264 | - if (event.key !== "Tab") return; | |
| 268 | + if ((event.key === "m" || event.key === "M") && event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey) { | |
| 269 | + event.preventDefault(); | |
| 270 | + tabMovesFocus = !tabMovesFocus; | |
| 271 | + nextTabMovesFocus = false; | |
| 272 | + return; | |
| 273 | + } | |
| 274 | + if (event.key === "Escape") { | |
| 275 | + nextTabMovesFocus = true; | |
| 276 | + return; | |
| 277 | + } | |
| 278 | + if (event.key !== "Tab") { | |
| 279 | + nextTabMovesFocus = false; | |
| 280 | + return; | |
| 281 | + } | |
| 282 | + if (tabMovesFocus || nextTabMovesFocus) { | |
| 283 | + nextTabMovesFocus = false; | |
| 284 | + return; | |
| 285 | + } | |
| 265 | 286 | event.preventDefault(); |
| 266 | 287 | const start = textarea.selectionStart; |
| 267 | 288 | const end = textarea.selectionEnd; |
@@ -328,7 +349,8 @@ | ||
| 328 | 349 | |
| 329 | 350 | if (showDiff) { |
| 330 | 351 | showDiff.addEventListener("change", function () { |
| 331 | - if (preview) preview.classList.toggle("is-showing-diff", showDiff.checked); | |
| 352 | + previewDirty = true; | |
| 353 | + if (root.classList.contains("is-previewing")) renderPreview(); | |
| 332 | 354 | }); |
| 333 | 355 | } |
| 334 | 356 | |
internal/web/templates/repo/markdown_preview.htmlmodified@@ -1,3 +1,13 @@ | ||
| 1 | 1 | {{ define "page" -}} |
| 2 | -<div class="markdown-body shithub-editor-preview-body">{{ .MarkdownHTML }}</div> | |
| 2 | +<div class="markdown-body shithub-editor-preview-body{{ if .DiffMode }} is-diff{{ end }}"> | |
| 3 | + {{ if .DiffMode }} | |
| 4 | + {{ range .DiffBlocks }} | |
| 5 | + <div class="shithub-editor-preview-diff-block is-{{ .Kind }}">{{ .HTML }}</div> | |
| 6 | + {{ else }} | |
| 7 | + <p class="shithub-editor-preview-empty">No preview changes.</p> | |
| 8 | + {{ end }} | |
| 9 | + {{ else }} | |
| 10 | + {{ .MarkdownHTML }} | |
| 11 | + {{ end }} | |
| 12 | +</div> | |
| 3 | 13 | {{- end }} |