From b6ad8433a02c3538408a99e338e710f09961b87a Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Sun, 25 Oct 2026 02:46:14 -0500 Subject: [PATCH 1/2] Add plain-text copying for code blocks --- README.md | 3 +- docs/images/screenshots/README.md | 8 ++ docs/images/screenshots/copylinks-code.png | Bin 0 -> 7195 bytes language/en/common.php | 5 + styles/all/template/copylinks.js | 89 +++++++++++--- .../template/event/overall_footer_after.html | 5 + styles/all/theme/copylinks.css | 15 ++- tests/browser/README.md | 20 ++++ tests/browser/code-copy.js | 110 ++++++++++++++++++ 9 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 docs/images/screenshots/copylinks-code.png create mode 100644 tests/browser/README.md create mode 100644 tests/browser/code-copy.js diff --git a/README.md b/README.md index 161815e..3dcba7f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Click a forum, topic, post or FAQ heading to copy a link to it, ready-made as BB ## Features - Click a forum or topic title, a post subject, or a question in the FAQ or BBCode guide, and a link to it is copied as BBCode, for example `[url=https://example.com/forum/viewtopic.php?t=42]Topic title[/url]`. Paste it into a post and it shows as a readable link. +- Code blocks have a separate **Copy code** button beside **Select all**. It copies plain text, preserving indentation and line breaks, and provides visible and screen reader feedback. If copying is blocked, the code is selected for manual copying. The ACP link-format setting does not affect code copying. - Also works on the title above the posting form and on the posts in the topic review below it. - Links are full addresses built from the forum, topic or post ID, so they work anywhere they're pasted and never carry a session ID. They always point at the forum, topic or post itself, not a page of it. - A small icon (Font Awesome `fa-clone` by default) marks each copyable heading and turns into a tick for a moment after copying. Screen readers announce "The link has been copied." @@ -54,7 +55,7 @@ Contributions are welcome! ## Acknowledgments - Based on [Click/tap/Enter to copy pre-formatted links](https://www.phpbb.com/community/viewtopic.php?t=2676238), style code shared on phpBB.com by Gumboots, which itself grew out of the older Post Numbers extension. -- Code review, bug fixes, and documentation assisted by [Claude](https://www.anthropic.com/claude). +- Code review, bug fixes, and documentation assisted by [Claude](https://www.anthropic.com/claude) and [Codex](https://openai.com/codex/). ## License diff --git a/docs/images/screenshots/README.md b/docs/images/screenshots/README.md index 6bedf60..4210761 100644 --- a/docs/images/screenshots/README.md +++ b/docs/images/screenshots/README.md @@ -20,6 +20,14 @@ Clicking a question in the FAQ copies a link to that question. Tick after copying an FAQ question +## Code block + +The separate **Copy code** button copies plain code without BBCode. **Select all** +remains available. This Playwright screenshot shows the success label on phpBB +3.3 after an actual clipboard write and read. + +Copied feedback beside Select all in a code block + ## ACP settings **Extensions → Copy Links → Settings**: choose plain URLs or BBCode (the default), turn the icon on or off, and choose which Font Awesome icon it is. With the icon off, only the tick shows after a copy. diff --git a/docs/images/screenshots/copylinks-code.png b/docs/images/screenshots/copylinks-code.png new file mode 100644 index 0000000000000000000000000000000000000000..9931caebcf1a76ba0cb8f581ee4fafa2d78ab73b GIT binary patch literal 7195 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 'Copy code', + 'COPYLINKS_CODE_FAILED_LABEL' => 'Copy manually', + 'COPYLINKS_CODE_COPIED_LABEL' => 'Copied!', + 'COPYLINKS_CODE_COPIED' => 'The code has been copied.', + 'COPYLINKS_CODE_COPY_FAILED' => 'Automatic copying failed. The code is selected; copy it manually.', 'COPYLINKS_HINT_PLAIN' => 'Copy a link to this', 'COPYLINKS_HINT' => 'Copy a link to this as BBCode', 'COPYLINKS_COPIED' => 'The link has been copied.', diff --git a/styles/all/template/copylinks.js b/styles/all/template/copylinks.js index 8186de6..1f1d33c 100644 --- a/styles/all/template/copylinks.js +++ b/styles/all/template/copylinks.js @@ -2,7 +2,8 @@ * Copy Links extension for the phpBB Forum Software package * * Turns forum, topic, post and FAQ headings into "copy link" controls. A - * click copies a BBCode link or a plain URL to the clipboard. + * click copies a BBCode link or a plain URL to the clipboard. Code blocks + * have a separate control that copies their contents as plain text. * Links are built from IDs, so they never carry a session ID, and always point * at the forum, topic or post itself rather than a page within it. * @@ -24,6 +25,13 @@ var hint = status.getAttribute('data-hint'); var copiedMessage = status.getAttribute('data-copied'); var failedMessage = status.getAttribute('data-copy-failed'); + var codeLabel = status.getAttribute('data-code-label'); + var codeCopiedLabel = status.getAttribute('data-code-copied-label'); + var codeCopiedMessage = status.getAttribute('data-code-copied'); + var codeFailedMessage = status.getAttribute('data-code-copy-failed'); + var codeFailedLabel = status.getAttribute('data-code-failed-label'); + var announceTimer; + var statusTimer; // How long the tick and the status message stay, in milliseconds var SHOW_COPIED_FOR = 2000; @@ -106,49 +114,71 @@ return '[url=' + url + ']' + text.replace(/\[(\/?url)/gi, '($1') + '[/url]'; } + /** + * Announce the latest action, including repeated identical messages. + * + * @param {string} message + */ + function announce(message) { + window.clearTimeout(announceTimer); + window.clearTimeout(statusTimer); + status.textContent = ''; + announceTimer = window.setTimeout(function () { + status.textContent = message; + }, ANNOUNCE_DELAY); + statusTimer = window.setTimeout(function () { + status.textContent = ''; + }, SHOW_COPIED_FOR); + } + /** * Show the tick and announce the copy to screen readers. * * @param {Element} target + * @param {string} message */ - function showCopied(target) { + function showCopied(target, message) { var targetIcon = target.querySelector('.copylinks-icon'); target.classList.add('copylinks-copied'); + if (target.classList.contains('copylinks-code')) { + target.textContent = codeCopiedLabel; + } if (targetIcon) { targetIcon.classList.remove(icon); targetIcon.classList.add(COPIED_ICON); } - // Clear first so the same message is read out again on a repeat copy - status.textContent = ''; - window.setTimeout(function () { - status.textContent = copiedMessage; - }, ANNOUNCE_DELAY); + announce(message); // A repeat copy restarts the timer instead of being cut short by the // previous one window.clearTimeout(target.copylinksTimer); target.copylinksTimer = window.setTimeout(function () { target.classList.remove('copylinks-copied'); + if (target.classList.contains('copylinks-code')) { + target.textContent = codeLabel; + } if (targetIcon) { targetIcon.classList.remove(COPIED_ICON); targetIcon.classList.add(icon); } - status.textContent = ''; }, SHOW_COPIED_FOR); } /** * Copy text to the clipboard, or let the reader copy it by hand when the - * browser does not allow it (plain http, private windows, old browsers). + * browser does not allow it (for example, on plain http). * * @param {Element} target * @param {string} text + * @param {string} successMessage + * @param {string} failureMessage + * @param {Function} [fallback] Optional manual-copy action for code blocks */ - function copy(target, text) { - var copyByHand = function () { - window.prompt(failedMessage, text); + function copy(target, text, successMessage, failureMessage, fallback) { + var copyByHand = fallback || function () { + window.prompt(failureMessage, text); }; if (!navigator.clipboard || !window.isSecureContext) { @@ -157,7 +187,7 @@ } navigator.clipboard.writeText(text).then(function () { - showCopied(target); + showCopied(target, successMessage); }, copyByHand); } @@ -209,6 +239,37 @@ mark(anchor, 'faq'); }); + // Keep Select all intact and add a native button beside it. Reading the + // rendered code preserves indentation, line breaks and highlighted text. + document.querySelectorAll('.codebox').forEach(function (box) { + var header = box.querySelector('p'); + var code = box.querySelector('pre > code'); + if (!header || !code) { + return; + } + + var button = document.createElement('button'); + button.type = 'button'; + button.className = 'copylinks-code'; + button.textContent = codeLabel; + button.addEventListener('click', function () { + copy(button, code.innerText, codeCopiedMessage, codeFailedMessage, function () { + // Select the code in place: a prompt's single-line field is not + // suitable for manually copying multiline code. + var selection = window.getSelection(); + var range = document.createRange(); + range.selectNodeContents(code); + selection.removeAllRanges(); + selection.addRange(range); + window.clearTimeout(button.copylinksTimer); + button.classList.remove('copylinks-copied'); + button.textContent = codeFailedLabel; + announce(codeFailedMessage); + }); + }); + header.appendChild(button); + }); + // One listener for every control. Clicks with a modifier key or a // middle button keep their normal behaviour, such as opening a new tab. document.addEventListener('click', function (event) { @@ -229,6 +290,6 @@ window.location.hash = fragment; } target.focus({preventScroll: true}); - copy(target, plainUrl ? url : bbcodeFor(url, headingText(target))); + copy(target, plainUrl ? url : bbcodeFor(url, headingText(target)), copiedMessage, failedMessage); }); })(); diff --git a/styles/all/template/event/overall_footer_after.html b/styles/all/template/event/overall_footer_after.html index 76959dd..25b6843 100644 --- a/styles/all/template/event/overall_footer_after.html +++ b/styles/all/template/event/overall_footer_after.html @@ -1,5 +1,10 @@ {% if not S_IS_BOT %}