diff --git a/.eslintrc.yaml b/.eslintrc.yaml
index cd86b680ee..2f213db37d 100644
--- a/.eslintrc.yaml
+++ b/.eslintrc.yaml
@@ -199,7 +199,7 @@ rules:
   newline-per-chained-call: [0]
   no-alert: [0]
   no-array-constructor: [2]
-  no-async-promise-executor: [2]
+  no-async-promise-executor: [0]
   no-await-in-loop: [0]
   no-bitwise: [0]
   no-buffer-constructor: [0]
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index ce93e92d34..02598dc3dc 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -95,6 +95,7 @@ copy_content = Copy content
 copy_branch = Copy branch name
 copy_success = Copied!
 copy_error = Copy failed
+copy_type_unsupported = This file type can not be copied
 
 write = Write
 preview = Preview
@@ -1096,7 +1097,6 @@ editor.cannot_edit_non_text_files = Binary files cannot be edited in the web int
 editor.edit_this_file = Edit File
 editor.this_file_locked = File is locked
 editor.must_be_on_a_branch = You must be on a branch to make or propose changes to this file.
-editor.only_copy_raw = You may only copy raw text files.
 editor.fork_before_edit = You must fork this repository to make or propose changes to this file.
 editor.delete_this_file = Delete File
 editor.must_have_write_access = You must have write access to make or propose changes to this file.
diff --git a/routers/web/repo/view.go b/routers/web/repo/view.go
index 7a9e44ff5e..1d1ba25064 100644
--- a/routers/web/repo/view.go
+++ b/routers/web/repo/view.go
@@ -443,7 +443,12 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 	ctx.Data["IsRepresentableAsText"] = isRepresentableAsText
 	ctx.Data["IsDisplayingSource"] = isDisplayingSource
 	ctx.Data["IsDisplayingRendered"] = isDisplayingRendered
-	ctx.Data["IsTextSource"] = isTextFile || isDisplayingSource
+
+	isTextSource := isTextFile || isDisplayingSource
+	ctx.Data["IsTextSource"] = isTextSource
+	if isTextSource {
+		ctx.Data["CanCopyContent"] = true
+	}
 
 	// Check LFS Lock
 	lfsLock, err := git_model.GetTreePathLock(ctx.Repo.Repository.ID, ctx.Repo.TreePath)
@@ -474,6 +479,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 	case isRepresentableAsText:
 		if st.IsSvgImage() {
 			ctx.Data["IsImageFile"] = true
+			ctx.Data["CanCopyContent"] = true
 			ctx.Data["HasSourceRenderedToggle"] = true
 		}
 
@@ -608,6 +614,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry, treeLink, rawLink st
 		ctx.Data["IsAudioFile"] = true
 	case st.IsImage() && (setting.UI.SVG.Enabled || !st.IsSvgImage()):
 		ctx.Data["IsImageFile"] = true
+		ctx.Data["CanCopyContent"] = true
 	default:
 		if fileSize >= setting.UI.MaxDisplayFileSize {
 			ctx.Data["IsFileTooLarge"] = true
diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl
index 321600a997..0fe0a13198 100644
--- a/templates/repo/view_file.tmpl
+++ b/templates/repo/view_file.tmpl
@@ -38,11 +38,7 @@
 					{{end}}
 				</div>
 				<a download href="{{$.RawFileLink}}"><span class="btn-octicon tooltip" data-content="{{.locale.Tr "repo.download_file"}}" data-position="bottom center">{{svg "octicon-download"}}</span></a>
-				{{if or .IsMarkup .IsRenderedHTML (not .IsTextSource)}}
-				<span class="btn-octicon tooltip disabled" id="copy-file-content" data-content="{{.locale.Tr "repo.editor.only_copy_raw"}}" aria-label="{{.locale.Tr "repo.editor.only_copy_raw"}}">{{svg "octicon-copy" 14}}</span>
-				{{else}}
-				<a class="btn-octicon tooltip" id="copy-file-content" data-content="{{.locale.Tr "copy_content"}}" aria-label="{{.locale.Tr "copy_content"}}">{{svg "octicon-copy" 14}}</a>
-				{{end}}
+				<a id="copy-content" class="btn-octicon tooltip{{if not .CanCopyContent}} disabled{{end}}"{{if or .IsImageFile (and .HasSourceRenderedToggle (not .IsDisplayingSource))}} data-link="{{$.RawFileLink}}"{{end}} data-content="{{if .CanCopyContent}}{{.locale.Tr "copy_content"}}{{else}}{{.locale.Tr "copy_type_unsupported"}}{{end}}">{{svg "octicon-copy" 14}}</a>
 				{{if .Repository.CanEnableEditor}}
 					{{if .CanEditFile}}
 						<a href="{{.RepoLink}}/_edit/{{PathEscapeSegments .BranchName}}/{{PathEscapeSegments .TreePath}}"><span class="btn-octicon tooltip" data-content="{{.EditFileTooltip}}" data-position="bottom center">{{svg "octicon-pencil"}}</span></a>
diff --git a/web_src/js/features/clipboard.js b/web_src/js/features/clipboard.js
index 85324303e3..f266d4f64d 100644
--- a/web_src/js/features/clipboard.js
+++ b/web_src/js/features/clipboard.js
@@ -2,11 +2,16 @@ import {showTemporaryTooltip} from '../modules/tippy.js';
 
 const {copy_success, copy_error} = window.config.i18n;
 
-export async function copyToClipboard(text) {
-  try {
-    await navigator.clipboard.writeText(text);
-  } catch {
-    return fallbackCopyToClipboard(text);
+export async function copyToClipboard(content) {
+  if (content instanceof Blob) {
+    const item = new window.ClipboardItem({[content.type]: content});
+    await navigator.clipboard.write([item]);
+  } else { // text
+    try {
+      await navigator.clipboard.writeText(content);
+    } catch {
+      return fallbackCopyToClipboard(content);
+    }
   }
   return true;
 }
diff --git a/web_src/js/features/copycontent.js b/web_src/js/features/copycontent.js
new file mode 100644
index 0000000000..9b791bedba
--- /dev/null
+++ b/web_src/js/features/copycontent.js
@@ -0,0 +1,59 @@
+import {copyToClipboard} from './clipboard.js';
+import {showTemporaryTooltip} from '../modules/tippy.js';
+import {convertImage} from '../utils.js';
+const {i18n} = window.config;
+
+async function doCopy(content, btn) {
+  const success = await copyToClipboard(content);
+  showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
+}
+
+export function initCopyContent() {
+  const btn = document.getElementById('copy-content');
+  if (!btn || btn.classList.contains('disabled')) return;
+
+  btn.addEventListener('click', async () => {
+    if (btn.classList.contains('is-loading')) return;
+    let content, isImage;
+    const link = btn.getAttribute('data-link');
+
+    // when data-link is present, we perform a fetch. this is either because
+    // the text to copy is not in the DOM or it is an image which should be
+    // fetched to copy in full resolution
+    if (link) {
+      btn.classList.add('is-loading');
+      try {
+        const res = await fetch(link, {credentials: 'include', redirect: 'follow'});
+        const contentType = res.headers.get('content-type');
+
+        if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
+          isImage = true;
+          content = await res.blob();
+        } else {
+          content = await res.text();
+        }
+      } catch {
+        return showTemporaryTooltip(btn, i18n.copy_error);
+      } finally {
+        btn.classList.remove('is-loading');
+      }
+    } else { // text, read from DOM
+      const lineEls = document.querySelectorAll('.file-view .lines-code');
+      content = Array.from(lineEls).map((el) => el.textContent).join('');
+    }
+
+    try {
+      await doCopy(content, btn);
+    } catch {
+      if (isImage) { // convert image to png as last-resort as some browser only support png copy
+        try {
+          await doCopy(await convertImage(content, 'image/png'), btn);
+        } catch {
+          showTemporaryTooltip(btn, i18n.copy_error);
+        }
+      } else {
+        showTemporaryTooltip(btn, i18n.copy_error);
+      }
+    }
+  });
+}
diff --git a/web_src/js/features/repo-code.js b/web_src/js/features/repo-code.js
index ef6b61196b..083a17bf21 100644
--- a/web_src/js/features/repo-code.js
+++ b/web_src/js/features/repo-code.js
@@ -1,10 +1,9 @@
 import $ from 'jquery';
 import {svg} from '../svg.js';
 import {invertFileFolding} from './file-fold.js';
-import {createTippy, showTemporaryTooltip} from '../modules/tippy.js';
+import {createTippy} from '../modules/tippy.js';
 import {copyToClipboard} from './clipboard.js';
 
-const {i18n} = window.config;
 export const singleAnchorRegex = /^#(L|n)([1-9][0-9]*)$/;
 export const rangeAnchorRegex = /^#(L[1-9][0-9]*)-(L[1-9][0-9]*)$/;
 
@@ -114,18 +113,6 @@ function showLineButton() {
   });
 }
 
-function initCopyFileContent() {
-  // get raw text for copy content button, at the moment, only one button (and one related file content) is supported.
-  const copyFileContent = document.querySelector('#copy-file-content');
-  if (!copyFileContent) return;
-
-  copyFileContent.addEventListener('click', async () => {
-    const text = Array.from(document.querySelectorAll('.file-view .lines-code')).map((el) => el.textContent).join('');
-    const success = await copyToClipboard(text);
-    showTemporaryTooltip(copyFileContent, success ? i18n.copy_success : i18n.copy_error);
-  });
-}
-
 export function initRepoCodeView() {
   if ($('.code-view .lines-num').length > 0) {
     $(document).on('click', '.lines-num span', function (e) {
@@ -205,5 +192,4 @@ export function initRepoCodeView() {
     if (!success) return;
     document.querySelector('.code-line-button')?._tippy?.hide();
   });
-  initCopyFileContent();
 }
diff --git a/web_src/js/index.js b/web_src/js/index.js
index a829deaf11..f4638a60e0 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -89,6 +89,7 @@ import {initRepoWikiForm} from './features/repo-wiki.js';
 import {initRepoCommentForm, initRepository} from './features/repo-legacy.js';
 import {initFormattingReplacements} from './features/formatting.js';
 import {initMcaptcha} from './features/mcaptcha.js';
+import {initCopyContent} from './features/copycontent.js';
 
 // Run time-critical code as soon as possible. This is safe to do because this
 // script appears at the end of <body> and rendered HTML is accessible at that point.
@@ -136,6 +137,7 @@ $(document).ready(() => {
   initStopwatch();
   initTableSort();
   initFindFileInRepo();
+  initCopyContent();
 
   initAdminCommon();
   initAdminEmails();
diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js
index 045df6f0a0..6a89151691 100644
--- a/web_src/js/modules/tippy.js
+++ b/web_src/js/modules/tippy.js
@@ -27,6 +27,7 @@ export function createTippy(target, opts = {}) {
 export function initTooltip(el, props = {}) {
   const content = el.getAttribute('data-content') || props.content;
   if (!content) return null;
+  if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', content);
   return createTippy(el, {
     content,
     delay: 100,
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index 9b8bf925a9..62ee11c2eb 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -85,3 +85,51 @@ export function translateMonth(month) {
 export function translateDay(day) {
   return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short'});
 }
+
+// convert a Blob to a DataURI
+export function blobToDataURI(blob) {
+  return new Promise((resolve, reject) => {
+    try {
+      const reader = new FileReader();
+      reader.addEventListener('load', (e) => {
+        resolve(e.target.result);
+      });
+      reader.addEventListener('error', () => {
+        reject(new Error('FileReader failed'));
+      });
+      reader.readAsDataURL(blob);
+    } catch (err) {
+      reject(err);
+    }
+  });
+}
+
+// convert image Blob to another mime-type format.
+export function convertImage(blob, mime) {
+  return new Promise(async (resolve, reject) => {
+    try {
+      const img = new Image();
+      const canvas = document.createElement('canvas');
+      img.addEventListener('load', () => {
+        try {
+          canvas.width = img.naturalWidth;
+          canvas.height = img.naturalHeight;
+          const context = canvas.getContext('2d');
+          context.drawImage(img, 0, 0);
+          canvas.toBlob((blob) => {
+            if (!(blob instanceof Blob)) return reject(new Error('imageBlobToPng failed'));
+            resolve(blob);
+          }, mime);
+        } catch (err) {
+          reject(err);
+        }
+      });
+      img.addEventListener('error', () => {
+        reject(new Error('imageBlobToPng failed'));
+      });
+      img.src = await blobToDataURI(blob);
+    } catch (err) {
+      reject(err);
+    }
+  });
+}
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js
index 0567a5c64a..1df0caa211 100644
--- a/web_src/js/utils.test.js
+++ b/web_src/js/utils.test.js
@@ -1,7 +1,7 @@
 import {expect, test} from 'vitest';
 import {
   basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref,
-  prettyNumber, parseUrl, translateMonth, translateDay
+  prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
 } from './utils.js';
 
 test('basename', () => {
@@ -131,3 +131,8 @@ test('translateDay', () => {
   expect(translateDay(5)).toEqual('pt.');
   document.documentElement.lang = originalLang;
 });
+
+test('blobToDataURI', async () => {
+  const blob = new Blob([JSON.stringify({test: true})], {type: 'application/json'});
+  expect(await blobToDataURI(blob)).toEqual('data:application/json;base64,eyJ0ZXN0Ijp0cnVlfQ==');
+});
diff --git a/web_src/less/animations.less b/web_src/less/animations.less
index 6d32625704..689898da2a 100644
--- a/web_src/less/animations.less
+++ b/web_src/less/animations.less
@@ -33,6 +33,12 @@
   height: var(--height-loading);
 }
 
+.btn-octicon.is-loading::after {
+  border-width: 2px;
+  height: 1.25rem;
+  width: 1.25rem;
+}
+
 code.language-math.is-loading::after {
   padding: 0;
   border-width: 2px;