feat(diff): copy to clipboard markdown (#187) #195

Merged
forgejo_admin merged 18 commits from feat/issue-187-copy-diff-clipboard into master 2026-05-22 19:55:00 +00:00

Summary

Adds a Copy button to the metadata diff expansion (and every cleaned-leaf diff inside a ZIP) that writes a markdown-formatted before/after report to the clipboard. Reuses the existing onCopyToast pipeline that previously only fired for ErrorExpansion.

Closes #187

Format

**Metadata diff** — `IMG_1234.jpg`
6 removed · 1 modified · 1 added · 2 kept

### EXIF · 2 removed · 1 added · 1 kept
- **DateTimeOriginal**: `2024:03:14 10:32:00` → (removed)
- **GPSLatitude**: `37.774929` → (removed)
- Orientation: (none) → `1`
- ColorSpace: `sRGB` (kept)

### XMP · 4 removed · 1 modified · 1 kept
- **XMP:CreatorTool**: `Lightroom 12.0` → (removed)
- XMP:Rating: `4` → `5`
- XMP:Format: `image/jpeg` (kept)
  • Bold field name on removed entries — easier visual scan
  • Total + per-source summary lines, skipping zero counts (matches in-app makePaneGroupSummary)
  • CommonMark-correct value wrapping (double-backtick when needed, newlines escaped to \n/\r)
  • Filename header is self-contained; ZIP leaves use the archive-relative path (outer.zip > inner/photo.jpg for nested archives)

Screenshots

Desktop (1280×800)

Empty state
empty

After processing — row collapsed
processed

Row expanded — Copy button top-right
expanded

After Copy click — toast visible
after-copy

ZIP archive expanded — cleaned-leaf diff with its own Copy button
zip-expanded

Android (390×844, isNativeAndroid mocked)

These screenshots show the Copy button alongside PR #189's Save (download arrow) + Share icons in the row's result column. No DOM or CSS collision — the Copy button lives inside the diff expansion, the Save/Share icons live in the row's result cell.

Empty state
android-empty

After processing — row shows Save + Share icons in result column
android-processed

Row expanded — Copy button (icon-only at 480px breakpoint) alongside Save/Share
android-expanded

Code changes

  • New src/web/utils/format_diff_clipboard.ts — pure markdown formatter, 12 unit tests
  • New src/web/utils/diff_rows.ts — moved DiffRow / computeDiffRows / groupRowsBySource / makeKey out of MetadataDiffTable so the formatter and the renderer share one source of truth
  • New src/web/components/icons/ClipboardIcon.tsx
  • MetadataDiffTable.tsx accepts optional onCopy?: () => void and renders the floating Copy button (absolute top-right; top-left in RTL; icon-only below 480px)
  • MetadataDiffExpansion.tsx builds handleCopy from navigator.clipboard.writeText(formatDiffForClipboard(...))
  • ZipExpansion.tsx threads onCopyToast through to every cleaned-leaf diff (recursive for nested archives) with the leaf's archive-relative path as the markdown header filename
  • FileRow.tsx pipes filename={file.name} + onCopyToast to both expansions
  • FileTable.tsx lifts the hardcoded "Copied to clipboard" toast string to t("copyToast")
  • .resources/strings.json — 6 new keys in en / es / ar (matches existing newer-keys pattern; locale fallback handles other languages)

Test plan

  • yarn typecheck — clean
  • yarn lint — clean (prettier)
  • yarn test — 583/583 (was 571 + 12 new formatter tests + 3 new diff_rows tests, minus 0)
  • yarn check:deps — no circular deps
  • yarn build:web + yarn build:web:standalone — both build clean
  • yarn test:e2e:web:desktop -- copy_diff — e2e passes; drops sample.jpg → expands → clicks Copy → asserts toast text + clipboard payload via navigator.clipboard.readText()
  • Visual verification via Playwright (screenshots above)

WebKit + standalone-mobile projects skip the e2e because Playwright's WebKit driver doesn't grant clipboard-read without a user prompt and file:// origins can't receive clipboard permission grants under Playwright. The clipboard write itself works in all environments (component test covers it).

## Summary Adds a **Copy** button to the metadata diff expansion (and every cleaned-leaf diff inside a ZIP) that writes a markdown-formatted before/after report to the clipboard. Reuses the existing `onCopyToast` pipeline that previously only fired for `ErrorExpansion`. Closes #187 ## Format ``` **Metadata diff** — `IMG_1234.jpg` 6 removed · 1 modified · 1 added · 2 kept ### EXIF · 2 removed · 1 added · 1 kept - **DateTimeOriginal**: `2024:03:14 10:32:00` → (removed) - **GPSLatitude**: `37.774929` → (removed) - Orientation: (none) → `1` - ColorSpace: `sRGB` (kept) ### XMP · 4 removed · 1 modified · 1 kept - **XMP:CreatorTool**: `Lightroom 12.0` → (removed) - XMP:Rating: `4` → `5` - XMP:Format: `image/jpeg` (kept) ``` - Bold field name on **removed** entries — easier visual scan - Total + per-source summary lines, skipping zero counts (matches in-app `makePaneGroupSummary`) - CommonMark-correct value wrapping (double-backtick when needed, newlines escaped to `\n`/`\r`) - Filename header is self-contained; ZIP leaves use the archive-relative path (`outer.zip > inner/photo.jpg` for nested archives) ## Screenshots ### Desktop (1280×800) **Empty state** ![empty](http://forgejo.localhost:3000/attachments/5b93886a-c510-4b6e-baed-edfbd6310610) **After processing — row collapsed** ![processed](http://forgejo.localhost:3000/attachments/62bf4244-c4d1-4207-a841-e23aeb75d33e) **Row expanded — Copy button top-right** ![expanded](http://forgejo.localhost:3000/attachments/1c62bbbe-097b-4821-887a-1d0978d6fa18) **After Copy click — toast visible** ![after-copy](http://forgejo.localhost:3000/attachments/12ab6bfa-627c-4d0f-b536-0d25281c2db7) **ZIP archive expanded — cleaned-leaf diff with its own Copy button** ![zip-expanded](http://forgejo.localhost:3000/attachments/af93cb2a-9af0-4f16-b22b-e5e9c7d7fd5a) ### Android (390×844, `isNativeAndroid` mocked) These screenshots show the Copy button **alongside** PR #189's Save (download arrow) + Share icons in the row's result column. No DOM or CSS collision — the Copy button lives inside the diff expansion, the Save/Share icons live in the row's result cell. **Empty state** ![android-empty](http://forgejo.localhost:3000/attachments/073650a8-0a86-4a57-a43d-9397234cf6bb) **After processing — row shows Save + Share icons in result column** ![android-processed](http://forgejo.localhost:3000/attachments/f323b5ca-3b47-435f-b7a6-acea251666d3) **Row expanded — Copy button (icon-only at 480px breakpoint) alongside Save/Share** ![android-expanded](http://forgejo.localhost:3000/attachments/f54b382f-df7f-4557-b87e-880b5f086837) ## Code changes - New `src/web/utils/format_diff_clipboard.ts` — pure markdown formatter, 12 unit tests - New `src/web/utils/diff_rows.ts` — moved `DiffRow` / `computeDiffRows` / `groupRowsBySource` / `makeKey` out of `MetadataDiffTable` so the formatter and the renderer share one source of truth - New `src/web/components/icons/ClipboardIcon.tsx` - `MetadataDiffTable.tsx` accepts optional `onCopy?: () => void` and renders the floating Copy button (absolute top-right; top-left in RTL; icon-only below 480px) - `MetadataDiffExpansion.tsx` builds `handleCopy` from `navigator.clipboard.writeText(formatDiffForClipboard(...))` - `ZipExpansion.tsx` threads `onCopyToast` through to every cleaned-leaf diff (recursive for nested archives) with the leaf's archive-relative path as the markdown header filename - `FileRow.tsx` pipes `filename={file.name}` + `onCopyToast` to both expansions - `FileTable.tsx` lifts the hardcoded `"Copied to clipboard"` toast string to `t("copyToast")` - `.resources/strings.json` — 6 new keys in en / es / ar (matches existing newer-keys pattern; locale fallback handles other languages) ## Test plan - [x] `yarn typecheck` — clean - [x] `yarn lint` — clean (prettier) - [x] `yarn test` — 583/583 (was 571 + 12 new formatter tests + 3 new diff_rows tests, minus 0) - [x] `yarn check:deps` — no circular deps - [x] `yarn build:web` + `yarn build:web:standalone` — both build clean - [x] `yarn test:e2e:web:desktop -- copy_diff` — e2e passes; drops sample.jpg → expands → clicks Copy → asserts toast text + clipboard payload via `navigator.clipboard.readText()` - [x] Visual verification via Playwright (screenshots above) WebKit + standalone-mobile projects skip the e2e because Playwright's WebKit driver doesn't grant `clipboard-read` without a user prompt and `file://` origins can't receive clipboard permission grants under Playwright. The clipboard *write* itself works in all environments (component test covers it).
forgejo_admin added 12 commits 2026-05-22 18:38:28 +00:00
Adds a Copy button to the metadata diff expansion (and ZIP leaf diffs)
that writes a markdown-formatted before/after report to the clipboard.

Format: header with filename, total summary, per-source `###` sections
with bullet rows. Bold field names on removed entries. Backtick-wrapped
values with CommonMark-correct escaping for embedded backticks and
newlines. Reuses existing onCopyToast pipeline.

i18n in en / es / ar (matches the existing pattern for newer keys in
strings.json). Lifts the hardcoded "Copied to clipboard" toast string
while we're touching the toast pipeline.

DOM / CSS namespaced to avoid collision with PR #189's Share / share-zip
buttons; conflicts limited to imports + prop wiring.

Closes #187

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
11 bite-sized tasks covering: helper extraction, TDD formatter, i18n,
icon, button wiring, ZipExpansion plumbing, FileRow/FileTable updates,
e2e, quality gates, combined-screenshots with PR #189, code review
at threshold 35.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Moves DiffRow / DiffRowStatus / computeDiffRows / groupRowsBySource /
makeKey out of MetadataDiffTable.tsx into src/web/utils/diff_rows.ts so
the upcoming clipboard formatter can reuse the same row-classification
logic without depending on the React component.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure function that takes a MetadataDocument plus a filename and a
translator, returns a markdown report with:

- Bold header line with filename
- Total summary (removed/modified/added/kept; skips zero counts)
- One ### section per source with the same summary style
- Bullet rows per row status — bold field name on removed
- CommonMark-correct value wrapping (double-backtick when needed,
  newline escapes)
- Single trailing newline

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds diffCopyButton, diffClipboardHeader, diffClipboardRemoved,
diffClipboardNone, diffClipboardKept, copyToast keys following the
existing newer-keys pattern (en/es/ar only; locale fallback handles
the rest).

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Optional onCopy prop; button is hidden when not provided. Floats
top-right of the diff wrapper (top-left in RTL). Collapses to icon-only
below 480px. Sits outside the pane header so it remains visible when the
header is hidden on mobile.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Builds a handleCopy callback that writes the formatted markdown via
navigator.clipboard.writeText and fires onCopyToast on success.
Clipboard failures are silent (matches ErrorExpansion precedent).

FileRow now passes filename={file.name} and onCopyToast through to
MetadataDiffExpansion so the diff knows what filename to put in the
markdown header.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds an optional onCopyToast prop to ZipExpansion (passed through to
nested archives recursively). When a cleaned-leaf diff renders, the
component builds a per-leaf copy handler with the leaf's archive-relative
path as the markdown header filename ('outer.zip > inner/photo.jpg' for
nested archives).

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
FileRow passes onCopyToast to ZipExpansion so cleaned-leaf diffs inside
archives get their own Copy button. The hardcoded 'Copied to clipboard'
string in FileTable now reads from t('copyToast'), respecting the
user's locale.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Drop sample.jpg → row complete → expand → click Copy. Asserts the
'Copied to clipboard' toast appears and navigator.clipboard.readText()
returns the expected markdown payload (filename header, ### source
heading, bullet rows).

Skips on WebKit (clipboard-read grant doesn't bypass the user prompt
under Playwright) and on standalone projects (file:// origin can't
receive clipboard grants).

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
style: prettier format
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 32s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 51s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m37s
CI / E2E (Web) (pull_request) Successful in 4m3s
6cb3068a36
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 18:46:48 +00:00
fix(diff): CommonMark-correct code-span fencing and emphasis escaping
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 45s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 46s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m41s
CI / E2E (Web) (pull_request) Successful in 3m35s
6e3c27b1b6
Code review findings:

1. codeWrap previously corrupted any value containing two or more
   consecutive backticks (the double-backtick outer delimiter terminated
   at the inner backtick run inside the value). Now finds the longest
   backtick run N in the value and uses an (N+1)-backtick fence per the
   CommonMark code-span rule. Adds leading/trailing padding when the
   value starts or ends with a backtick.

2. The filename in the markdown header line was interpolated into raw
   backticks instead of going through codeWrap; a backtick in a filename
   (legal everywhere) would have corrupted the header. Now uses
   codeWrap(filename).

3. The bold field name on removed bullets did not escape markdown
   emphasis characters; a tag name like 'Foo*Bar_Baz' would have broken
   the surrounding **…** bold span. Adds an escapeBold helper that
   escapes \, *, and _.

Adds 4 new tests covering: multi-backtick fence escalation, leading /
trailing backtick padding, filename with a backtick, field name with
emphasis chars.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 19:15:59 +00:00
fix(diff): wrap clipboard markdown in <div dir=ltr> for RTL locales
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 31s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 52s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m41s
CI / E2E (Web) (pull_request) Successful in 3m54s
a0d91b5f43
Mixed-bidi content like
  - **Author**: \`J.K. Rowling\` → (تمت إزالته)
rendered in an Arabic Forgejo context produces jarring directional
jumps: the parenthesised Arabic label, the arrow, and the LTR value
get reordered by the bidi algorithm and the line is hard to scan.

For Arabic (and other RTL locales added later), wrap the entire
payload in <div dir=ltr>...</div> so the destination renderer (Forgejo,
GitHub, GitLab — all of which support raw HTML with markdown inside
when blank lines separate the boundaries) treats the diff as a single
LTR paragraph block. Arabic glyphs inside still render in their
natural RTL run, but per-line bidi resolution stays LTR.

Threads locale from useI18n() through MetadataDiffExpansion and
ZipExpansion (recursive for nested archives). Adds 3 tests covering
RTL wrap, LTR pass-through, and regional Arabic variant detection.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 19:23:00 +00:00
fix(diff): use invisible Unicode bidi isolates instead of <div dir=ltr>
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 33s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 46s
CI / E2E (Standalone single-file) (pull_request) Successful in 2m35s
CI / E2E (Web) (pull_request) Successful in 3m48s
8333216009
The previous fix wrapped the markdown payload in a literal
<div dir="ltr"> block, which renders correctly in Forgejo / GitHub
(HTML-with-markdown-inside) but leaks visible <div> tags into plain
text paste destinations (chat, notes, terminal).

Switching to per-line Unicode bidi isolates: U+2066 LRI ... U+2069 PDI
around each non-empty line. The isolates are zero-width invisible
characters in every modern renderer:
- Browsers (Forgejo, GitHub, GitLab markdown output) honor them and
  resolve each line as LTR-base regardless of surrounding paragraph
  direction.
- Terminals, chat apps, text editors render them as zero-width / skip
  them silently — no visible HTML tag leakage.

Result: paste into a Forgejo Arabic context renders each line LTR
(field name + value + arrow + status read in source order). Paste into
a plain-text destination shows the same content with no visible
overhead.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 19:25:57 +00:00
i18n(ar): put {count} after the verb form for diff group summaries
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 42s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 52s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m45s
CI / E2E (Web) (pull_request) Successful in 3m37s
42aaa3843d
Arabic word order naturally places the verb first: 'تمت إزالة 6'
(removal of 6) rather than '6 تمت إزالته' (6 removed). The previous
translations copied English word order, which surfaces as visually
broken RTL flow when the line is read in an Arabic context:

  6 تمت إزالته، 1 تم تعديله، 4 تم الاحتفاظ به   (awkward)
  تمت إزالة 6 · تم تعديل 1 · تم الاحتفاظ بـ 4    (natural)

Affects diffGroupRemoved / diffGroupModified / diffGroupAdded /
diffGroupKept. Both the in-app diff group header and the new
clipboard payload pick up the fix automatically since both use the
same i18n keys.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 19:33:14 +00:00
fix(diff): keep markdown block prefixes at column 0 for RTL output
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 32s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 48s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m34s
CI / E2E (Web) (pull_request) Successful in 3m37s
24c718a361
The previous per-line LRI wrap put U+2066 at position 0 of every
non-empty line, including '### Heading' and '- Bullet' lines. That
silently broke ATX-heading and list-item parsing — CommonMark requires
the '#' or '-' marker at column 0 (or after up to 3 spaces of indent),
and an LRI in position 0 is not a recognized indentation character.

Result: a heading like '### Office Revisions · تمت إزالة 1' rendered
as a plain paragraph with the literal '###' visible, and the bidi
algorithm then resolved the line as RTL (because the LTR '###' run is
short relative to the Arabic content), pushing '###' to the right edge.

Fix: leave the markdown prefix at column 0 and wrap only the content
AFTER the prefix in LRI…PDI. So:
  '### Heading text' → '### <LRI>Heading text<PDI>'
  '- Bullet content' → '- <LRI>Bullet content<PDI>'
Other lines are wrapped whole as before.

Adds a test asserting heading and bullet lines keep their prefix at
column 0 in RTL output.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin added 1 commit 2026-05-22 19:45:10 +00:00
fix(diff): escape inline-markdown in all names + strip bidi controls
All checks were successful
CI / Lint, Typecheck & Unit Tests (pull_request) Successful in 32s
CI / Smoke build (VITE_ENABLE_FFMPEG_FALLBACK=false) (pull_request) Successful in 48s
CI / E2E (Standalone single-file) (pull_request) Successful in 1m37s
CI / E2E (Web) (pull_request) Successful in 3m25s
9ebcfaad84
Code review findings:

1. escapeBold previously only ran on the 'removed' bullet. Field names
   on added/modified/kept bullets and the source label on the '###'
   heading were interpolated raw. A tag name like 'Foo_Bar' would
   render with italic mid-string in every CommonMark target, and an
   Office walker source label containing '*' would corrupt the
   heading. Now uses a single escapeMdInline helper that escapes
   `\\`, `*`, `_`, `` ` ``, `[`, `]`, `<`, `>` for every interpolated
   name and source label across all four statuses + headings.

2. The per-line LRI/PDI wrap did not sanitize the content. A
   metadata value carrying a literal U+2069 (PDI) would close the
   outer LRI early, regressing the bidi-isolation fix from 8333216.
   Adversarial / unusual inputs only, but easy to defend against by
   stripping U+2066-U+2069 (LRI/RLI/FSI/PDI) and U+202A-U+202E
   (LRE/RLE/PDF/LRO/RLO) from the entire payload before wrapping.
   These codepoints serve no purpose in a clipboard payload.

Adds 3 new tests covering field-name escape across statuses, source
heading escape, and bidi-control sanitization. 21/21 formatter tests
pass; 592/592 overall.

Issue #187.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
forgejo_admin merged commit 4efdd770dd into master 2026-05-22 19:55:00 +00:00
forgejo_admin deleted branch feat/issue-187-copy-diff-clipboard 2026-05-22 19:55:00 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: forgejo_admin/exifcleaner-web#195
No description provided.