diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index b3eb7b1f4a4fd..92f58469f0bf8 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -115,6 +115,8 @@ preview = Preview
loading = Loading…
files = Files
+code_toggle_wrap = Toggle code wrap
+
error = Error
error404 = The page you are trying to reach either does not exist or you are not authorized to view it.
error503 = The server could not complete your request. Please try again later.
diff --git a/public/assets/img/svg/material-wrap-text.svg b/public/assets/img/svg/material-wrap-text.svg
new file mode 100644
index 0000000000000..295e79da6d74e
--- /dev/null
+++ b/public/assets/img/svg/material-wrap-text.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl
index f6648b59d8c53..ccf740adafb21 100644
--- a/templates/base/head_script.tmpl
+++ b/templates/base/head_script.tmpl
@@ -33,6 +33,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
{{/* this global i18n object should only contain general texts. for specialized texts, it should be provided inside the related modules by: (1) API response (2) HTML data-attribute (3) PageData */}}
i18n: {
+ copy: {{ctx.Locale.Tr "copy"}},
copy_success: {{ctx.Locale.Tr "copy_success"}},
copy_error: {{ctx.Locale.Tr "copy_error"}},
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
@@ -41,6 +42,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
modal_cancel: {{ctx.Locale.Tr "modal.cancel"}},
more_items: {{ctx.Locale.Tr "more_items"}},
+ code_toggle_wrap: {{ctx.Locale.Tr "code_toggle_wrap"}},
},
};
{{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 291cd04b2b95c..7007d7928f715 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -44,7 +44,7 @@
@import "./features/console.css";
@import "./markup/content.css";
-@import "./markup/codecopy.css";
+@import "./markup/codeblocks.css";
@import "./markup/codepreview.css";
@import "./markup/asciicast.css";
diff --git a/web_src/css/markup/codeblocks.css b/web_src/css/markup/codeblocks.css
new file mode 100644
index 0000000000000..2236f0b083cb9
--- /dev/null
+++ b/web_src/css/markup/codeblocks.css
@@ -0,0 +1,35 @@
+.markup .code-block-buttons {
+ position: absolute;
+ top: 8px;
+ right: 6px;
+ display: flex;
+ gap: 4px;
+ visibility: hidden;
+ animation: fadeout 0.2s both;
+}
+
+/* adjustments for comment content having only 14px font size */
+.repository.view.issue .comment-list .comment .markup .code-copy {
+ right: 5px;
+ padding: 8px;
+}
+
+.markup .code-block-buttons .btn {
+ background: var(--color-button) !important;
+ padding: 9px;
+ border: 1px solid var(--color-light-border);
+}
+
+.markup .code-block-buttons .btn:hover {
+ background: var(--color-hover-opaque) !important;
+}
+
+.markup .code-block-buttons .btn[data-active="false"] {
+ color: var(--color-text-light-3);
+}
+
+.markup .code-block-container:hover .code-block-buttons,
+.markup .code-block:hover .code-block-buttons {
+ visibility: visible;
+ animation: fadein 0.2s both;
+}
diff --git a/web_src/css/markup/codecopy.css b/web_src/css/markup/codecopy.css
deleted file mode 100644
index 5a7b9955e7ad2..0000000000000
--- a/web_src/css/markup/codecopy.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.markup .code-copy {
- position: absolute;
- top: 8px;
- right: 6px;
- padding: 9px;
- visibility: hidden;
- animation: fadeout 0.2s both;
-}
-
-/* adjustments for comment content having only 14px font size */
-.repository.view.issue .comment-list .comment .markup .code-copy {
- right: 5px;
- padding: 8px;
-}
-
-/* can not use regular transparent button colors for hover and active states because
- we need opaque colors here as code can appear behind the button */
-.markup .code-copy:hover {
- background: var(--color-secondary) !important;
-}
-
-.markup .code-copy:active {
- background: var(--color-secondary-dark-1) !important;
-}
-
-.markup .code-block-container:hover .code-copy,
-.markup .code-block:hover .code-copy {
- visibility: visible;
- animation: fadein 0.2s both;
-}
diff --git a/web_src/js/globals.d.ts b/web_src/js/globals.d.ts
index 00f1744a957b2..19caca6fc71fc 100644
--- a/web_src/js/globals.d.ts
+++ b/web_src/js/globals.d.ts
@@ -52,7 +52,7 @@ interface Element {
interface Window {
__webpack_public_path__: string;
- config: import('./web_src/js/types.ts').Config;
+ config: import('./types.ts').Config;
$: typeof import('@types/jquery'),
jQuery: typeof import('@types/jquery'),
htmx: typeof import('htmx.org').default,
diff --git a/web_src/js/markup/codeblocks.ts b/web_src/js/markup/codeblocks.ts
new file mode 100644
index 0000000000000..152a8a1de69d1
--- /dev/null
+++ b/web_src/js/markup/codeblocks.ts
@@ -0,0 +1,51 @@
+import {svg, type SvgName} from '../svg.ts';
+import {queryElems} from '../utils/dom.ts';
+
+export function makeCodeBlockButton(className: string, name: SvgName): HTMLButtonElement {
+ const button = document.createElement('button');
+ button.classList.add(className, 'btn');
+ button.innerHTML = svg(name, 14);
+ return button;
+}
+
+const getWrap = () => (localStorage.getItem('wrap-markup-code') || 'false') === 'true';
+const saveWrap = (value: boolean) => localStorage.setItem('wrap-markup-code', String(value));
+
+function updateWrap(container: Element, wrap: boolean) {
+ container.classList.remove(wrap ? 'code-overflow-scroll' : 'code-overflow-wrap');
+ container.classList.add(wrap ? 'code-overflow-wrap' : 'code-overflow-scroll');
+}
+
+export function initMarkupCodeBlocks(elMarkup: HTMLElement): void {
+ // .markup .code-block code
+ queryElems(elMarkup, '.code-block code', (el) => {
+ if (!el.textContent) return;
+
+ const copyBtn = makeCodeBlockButton('code-copy', 'octicon-copy');
+ copyBtn.setAttribute('data-tooltip-content', window.config.i18n.copy);
+ // remove final trailing newline introduced during HTML rendering
+ copyBtn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
+
+ // we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not.
+ const container = el.closest('.code-block-container') ?? el.closest('.code-block');
+
+ const wrapBtn = makeCodeBlockButton('code-wrap', 'material-wrap-text');
+ const wrap = getWrap();
+ wrapBtn.setAttribute('data-active', String(wrap));
+ updateWrap(container, wrap);
+
+ wrapBtn.setAttribute('data-tooltip-content', window.config.i18n.code_toggle_wrap);
+ wrapBtn.addEventListener('click', (e) => {
+ const wrap = !getWrap();
+ updateWrap(container, wrap);
+ (e.currentTarget as HTMLButtonElement).setAttribute('data-active', String(wrap));
+ saveWrap(wrap);
+ });
+
+ const btnContainer = document.createElement('div');
+ btnContainer.classList.add('code-block-buttons');
+ btnContainer.append(wrapBtn);
+ btnContainer.append(copyBtn);
+ container.append(btnContainer);
+ });
+}
diff --git a/web_src/js/markup/codecopy.ts b/web_src/js/markup/codecopy.ts
deleted file mode 100644
index b37aa3a2369a2..0000000000000
--- a/web_src/js/markup/codecopy.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import {svg} from '../svg.ts';
-import {queryElems} from '../utils/dom.ts';
-
-export function makeCodeCopyButton(): HTMLButtonElement {
- const button = document.createElement('button');
- button.classList.add('code-copy', 'ui', 'button');
- button.innerHTML = svg('octicon-copy');
- return button;
-}
-
-export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
- // .markup .code-block code
- queryElems(elMarkup, '.code-block code', (el) => {
- if (!el.textContent) return;
- const btn = makeCodeCopyButton();
- // remove final trailing newline introduced during HTML rendering
- btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
- // we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not.
- const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block');
- btnContainer.append(btn);
- });
-}
diff --git a/web_src/js/markup/content.ts b/web_src/js/markup/content.ts
index 55db4aa8102b3..3d583eb7500d6 100644
--- a/web_src/js/markup/content.ts
+++ b/web_src/js/markup/content.ts
@@ -1,6 +1,6 @@
import {initMarkupCodeMermaid} from './mermaid.ts';
import {initMarkupCodeMath} from './math.ts';
-import {initMarkupCodeCopy} from './codecopy.ts';
+import {initMarkupCodeBlocks} from './codeblocks.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
@@ -8,7 +8,7 @@ import {registerGlobalSelectorFunc} from '../modules/observer.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
- initMarkupCodeCopy(el);
+ initMarkupCodeBlocks(el);
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts
index 33d9a1ed9bd83..6927ecdb224fe 100644
--- a/web_src/js/markup/mermaid.ts
+++ b/web_src/js/markup/mermaid.ts
@@ -1,5 +1,5 @@
import {isDarkTheme} from '../utils.ts';
-import {makeCodeCopyButton} from './codecopy.ts';
+import {makeCodeBlockButton} from './codeblocks.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
@@ -53,7 +53,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise
\ No newline at end of file