diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-14 15:55:13 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-14 15:55:13 +0200 |
| commit | dcdc892d940adffced3d2bc272d813a47c7d4700 (patch) | |
| tree | 722878382c4d3a2fdfae0a99106878de07375c09 | |
| parent | 6d56e9eedfc8fee9b1804e805d0be1def861018d (diff) | |
Select revisions to compare by picking rows
Two hidden fields hold the pair, picking a row pushes it as the target and
the previous target becomes the source, so any pair takes two picks. Source
and target carry the diff view's own colours, in the rows and in the sticky
bar's readout.
Rows are focusable and respond to Enter and Space, the readout is the
accessible equivalent of the colour, so it is aria-live.
| -rw-r--r-- | app/views/revisions/index.html.erb | 123 | ||||
| -rw-r--r-- | config/locales/de.yml | 3 | ||||
| -rw-r--r-- | config/locales/en.yml | 3 | ||||
| -rw-r--r-- | public/stylesheets/admin.css | 22 |
4 files changed, 96 insertions, 55 deletions
diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index c913b2a9..e951016f 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb | |||
| @@ -18,11 +18,11 @@ | |||
| 18 | </p> | 18 | </p> |
| 19 | <% end %> | 19 | <% end %> |
| 20 | 20 | ||
| 21 | <% pages = (@pages || @node.pages.all).reverse %> | ||
| 22 | |||
| 21 | <table id="revisions" class="admin_table revisions_table"> | 23 | <table id="revisions" class="admin_table revisions_table"> |
| 22 | <thead> | 24 | <thead> |
| 23 | <tr class="header"> | 25 | <tr class="header"> |
| 24 | <th><%= t(".first") %></th> | ||
| 25 | <th><%= t(".last") %></th> | ||
| 26 | <th><%= t("admin.columns.rev") %></th> | 26 | <th><%= t("admin.columns.rev") %></th> |
| 27 | <th><%= t("admin.columns.title") %></th> | 27 | <th><%= t("admin.columns.title") %></th> |
| 28 | <th><%= t("admin.columns.editor") %></th> | 28 | <th><%= t("admin.columns.editor") %></th> |
| @@ -31,24 +31,27 @@ | |||
| 31 | <th></th> | 31 | <th></th> |
| 32 | </tr> | 32 | </tr> |
| 33 | <tr class="diff_sticky_bar"> | 33 | <tr class="diff_sticky_bar"> |
| 34 | <td colspan="8"> | 34 | <td colspan="6"> |
| 35 | <%= button_to t(".diff_revisions"), diff_node_revisions_path(@node), | 35 | <%= form_tag diff_node_revisions_path(@node), :method => :post, |
| 36 | method: :post, | 36 | :id => "diff_form", :class => "button_to computation" do %> |
| 37 | params: { translation_locale: @translation_locale }, | 37 | <%= hidden_field_tag :translation_locale, @translation_locale %> |
| 38 | form: { id: 'diff_form', class: 'button_to computation' }, | 38 | <%= hidden_field_tag :start_revision, pages[1]&.revision %> |
| 39 | disabled: true %> | 39 | <%= hidden_field_tag :end_revision, pages[0]&.revision %> |
| 40 | <span id="diff_selection_label" data-against="<%= t(".against_word") %>" data-selected="<%= t(".selected_word") %>"></span> | 40 | <%= submit_tag t(".diff_revisions") %> |
| 41 | <label><%= radio_button_tag :view, 'inline', true %> <%= t("revisions.inline") %></label> | 41 | <span id="diff_selection_label" aria-live="polite"> |
| 42 | <label><%= radio_button_tag :view, 'side_by_side', false %> <%= t("revisions.side_by_side") %></label> | 42 | <%= t(".diff_selection_html", |
| 43 | :from => tag.span(:id => "diff_from"), | ||
| 44 | :to => tag.span(:id => "diff_to")) %> | ||
| 45 | </span> | ||
| 46 | <label><%= radio_button_tag :view, 'inline', true %> <%= t("revisions.inline") %></label> | ||
| 47 | <label><%= radio_button_tag :view, 'side_by_side', false %> <%= t("revisions.side_by_side") %></label> | ||
| 48 | <% end %> | ||
| 43 | </td> | 49 | </td> |
| 44 | </tr> | 50 | </tr> |
| 45 | </thead> | 51 | </thead> |
| 46 | <tbody> | 52 | <tbody> |
| 47 | <% pages = (@pages || @node.pages.all).reverse %> | 53 | <% pages.each do |page| %> |
| 48 | <% pages.each_with_index do |page, index| %> | 54 | <tr data-revision="<%= page.revision %>" tabindex="0" aria-pressed="false"> |
| 49 | <tr> | ||
| 50 | <td><%= radio_button_tag :start_revision, page.revision, index == 1 %></td> | ||
| 51 | <td><%= radio_button_tag :end_revision, page.revision, index == 0 %></td> | ||
| 52 | <td class="revision"><%= page.revision %></td> | 55 | <td class="revision"><%= page.revision %></td> |
| 53 | <td class="title"><%= page.translations.find_by(:locale => @translation_locale)&.title || "—" %></td> | 56 | <td class="title"><%= page.translations.find_by(:locale => @translation_locale)&.title || "—" %></td> |
| 54 | <td class="user"><%= page.editor.try(:login) %></td> | 57 | <td class="user"><%= page.editor.try(:login) %></td> |
| @@ -65,45 +68,63 @@ | |||
| 65 | </table> | 68 | </table> |
| 66 | 69 | ||
| 67 | <%= javascript_tag nonce: true do %> | 70 | <%= javascript_tag nonce: true do %> |
| 68 | function update_diff_button_state() { | 71 | (function () { |
| 69 | var start = document.querySelector('input[name="start_revision"]:checked'); | 72 | var table = document.getElementById('revisions'); |
| 70 | var end = document.querySelector('input[name="end_revision"]:checked'); | 73 | var form = document.getElementById('diff_form'); |
| 71 | var valid = start && end && start.value !== end.value; | 74 | if (!table || !form) { return; } |
| 72 | document.querySelector('#diff_form button[type="submit"]').disabled = !valid; | ||
| 73 | 75 | ||
| 74 | var label = document.getElementById('diff_selection_label'); | 76 | var from_field = form.querySelector('input[name="start_revision"]'); |
| 75 | if (start && end) { | 77 | var to_field = form.querySelector('input[name="end_revision"]'); |
| 76 | label.textContent = start.value + ' ' + label.dataset.against + ' ' + end.value; | 78 | var from_label = document.getElementById('diff_from'); |
| 77 | } else if (start || end) { | 79 | var to_label = document.getElementById('diff_to'); |
| 78 | label.textContent = (start || end).value + ' ' + label.dataset.selected; | 80 | var readout = document.getElementById('diff_selection_label'); |
| 79 | } else { | 81 | var submit = form.querySelector('input[type="submit"]'); |
| 80 | label.textContent = ''; | 82 | var rows = table.querySelectorAll('tbody tr'); |
| 81 | } | ||
| 82 | } | ||
| 83 | 83 | ||
| 84 | document.querySelectorAll('input[name="start_revision"], input[name="end_revision"]') | 84 | // Two-slot FIFO: the newest pick becomes the target and the previous |
| 85 | .forEach(function(radio) { radio.addEventListener('change', update_diff_button_state); }); | 85 | // target becomes the source, so two taps anywhere give any pair. |
| 86 | // Picking the current target is a no-op; picking the current source | ||
| 87 | // swaps them. | ||
| 88 | var stack = [from_field.value, to_field.value].filter(function (v) { return v !== ''; }); | ||
| 86 | 89 | ||
| 87 | update_diff_button_state(); | 90 | function render() { |
| 91 | var from = stack[0] || ''; | ||
| 92 | var to = stack[1] || ''; | ||
| 93 | from_field.value = from; | ||
| 94 | to_field.value = to; | ||
| 95 | from_label.textContent = from; | ||
| 96 | to_label.textContent = to; | ||
| 97 | readout.hidden = !(from && to); | ||
| 98 | submit.disabled = !(from && to && from !== to); | ||
| 88 | 99 | ||
| 89 | document.getElementById('diff_form').addEventListener('submit', function(e) { | 100 | rows.forEach(function (row) { |
| 90 | var start = document.querySelector('input[name="start_revision"]:checked'); | 101 | var rev = row.dataset.revision; |
| 91 | var end = document.querySelector('input[name="end_revision"]:checked'); | 102 | row.classList.toggle('diff_source', rev === from); |
| 92 | var view = document.querySelector('input[name="view"]:checked'); | 103 | row.classList.toggle('diff_target', rev === to); |
| 93 | if (start) { | 104 | row.setAttribute('aria-pressed', (rev === from || rev === to) ? 'true' : 'false'); |
| 94 | var s = document.createElement('input'); | 105 | }); |
| 95 | s.type = 'hidden'; s.name = 'start_revision'; s.value = start.value; | ||
| 96 | this.appendChild(s); | ||
| 97 | } | ||
| 98 | if (end) { | ||
| 99 | var en = document.createElement('input'); | ||
| 100 | en.type = 'hidden'; en.name = 'end_revision'; en.value = end.value; | ||
| 101 | this.appendChild(en); | ||
| 102 | } | 106 | } |
| 103 | if (view) { | 107 | |
| 104 | var v = document.createElement('input'); | 108 | function pick(rev) { |
| 105 | v.type = 'hidden'; v.name = 'view'; v.value = view.value; | 109 | if (rev === stack[1]) { return; } |
| 106 | this.appendChild(v); | 110 | stack.push(rev); |
| 111 | if (stack.length > 2) { stack.shift(); } | ||
| 112 | render(); | ||
| 107 | } | 113 | } |
| 108 | }); | 114 | |
| 115 | rows.forEach(function (row) { | ||
| 116 | row.addEventListener('click', function (e) { | ||
| 117 | if (e.target.closest('a, button, input, label')) { return; } | ||
| 118 | pick(row.dataset.revision); | ||
| 119 | }); | ||
| 120 | row.addEventListener('keydown', function (e) { | ||
| 121 | if (e.key === 'Enter' || e.key === ' ') { | ||
| 122 | e.preventDefault(); | ||
| 123 | pick(row.dataset.revision); | ||
| 124 | } | ||
| 125 | }); | ||
| 126 | }); | ||
| 127 | |||
| 128 | render(); | ||
| 129 | })(); | ||
| 109 | <% end %> | 130 | <% end %> |
diff --git a/config/locales/de.yml b/config/locales/de.yml index 3f2eb0ab..35f262cc 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml | |||
| @@ -757,8 +757,7 @@ de: | |||
| 757 | first: "Von" | 757 | first: "Von" |
| 758 | last: "Bis" | 758 | last: "Bis" |
| 759 | diff_revisions: "Revisionen vergleichen" | 759 | diff_revisions: "Revisionen vergleichen" |
| 760 | against_word: "mit" | 760 | diff_selection_html: "Vergleicht %{from} mit %{to}" |
| 761 | selected_word: "ausgewählt" | ||
| 762 | show_link: "anzeigen" | 761 | show_link: "anzeigen" |
| 763 | restore_link: "wiederherstellen" | 762 | restore_link: "wiederherstellen" |
| 764 | confirm_restore: "Diese Revision wiederherstellen?" | 763 | confirm_restore: "Diese Revision wiederherstellen?" |
diff --git a/config/locales/en.yml b/config/locales/en.yml index e8428f69..75c762a3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml | |||
| @@ -725,8 +725,7 @@ en: | |||
| 725 | first: "First" | 725 | first: "First" |
| 726 | last: "Last" | 726 | last: "Last" |
| 727 | diff_revisions: "Diff revisions" | 727 | diff_revisions: "Diff revisions" |
| 728 | against_word: "against" | 728 | diff_selection_html: "Comparing %{to} against %{from}" |
| 729 | selected_word: "selected" | ||
| 730 | show_link: "show" | 729 | show_link: "show" |
| 731 | restore_link: "restore" | 730 | restore_link: "restore" |
| 732 | confirm_restore: "Restore this revision?" | 731 | confirm_restore: "Restore this revision?" |
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index d3f3afe6..8bbf7289 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css | |||
| @@ -686,6 +686,28 @@ form.button_to svg { | |||
| 686 | border-bottom: 1px solid var(--hairline); | 686 | border-bottom: 1px solid var(--hairline); |
| 687 | } | 687 | } |
| 688 | 688 | ||
| 689 | .revisions_table tbody tr { | ||
| 690 | cursor: pointer; | ||
| 691 | } | ||
| 692 | |||
| 693 | .revisions_table tbody tr.diff_source { | ||
| 694 | background-color: var(--diff-del-bg); | ||
| 695 | } | ||
| 696 | |||
| 697 | .revisions_table tbody tr.diff_target { | ||
| 698 | background-color: var(--diff-ins-bg); | ||
| 699 | } | ||
| 700 | |||
| 701 | #diff_selection_label #diff_from { | ||
| 702 | color: var(--diff-del-fg); | ||
| 703 | font-weight: bold; | ||
| 704 | } | ||
| 705 | |||
| 706 | #diff_selection_label #diff_to { | ||
| 707 | color: var(--diff-ins-fg); | ||
| 708 | font-weight: bold; | ||
| 709 | } | ||
| 710 | |||
| 689 | .row_primary { | 711 | .row_primary { |
| 690 | font-weight: bold; | 712 | font-weight: bold; |
| 691 | } | 713 | } |
