diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index 3f9c2ca87..dcdf9f35c 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -89,3 +89,22 @@ test('markdown: no math conversion inside a backtick span, math outside still co assert.ok(/]*>\$x\$<\/code>/.test(out), 'backtick span stays literal'); assert.ok(out.includes('data-mx-maths="y"')); }); + +test('[#107] a code block language becomes class="language-…"', () => { + const block = { + ...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('let a = 1;')])]) as object), + lang: 'js', + } as unknown as Descendant; + assert.equal( + toMatrixCustomHTML(block, OPTS), + '
let a = 1;\n
', + ); +}); + +test('[#107] an unsafe language value is dropped', () => { + const block = { + ...(el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('x')])]) as object), + lang: 'js" onclick="x', + } as unknown as Descendant; + assert.equal(toMatrixCustomHTML(block, OPTS), '
x\n
'); +}); diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 07ba8c514..f097b8c3c 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -86,7 +86,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { case BlockType.CodeLine: return `${children}\n`; case BlockType.CodeBlock: - return `
${children}
`; + // [Gitea #107] Only a plain identifier is ever put in the class. + return node.lang && /^[a-z0-9+#-]{1,20}$/.test(node.lang) + ? `
${children}
` + : `
${children}
`; case BlockType.QuoteLine: return `${children}
`; case BlockType.BlockQuote: diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts index da1460e5f..3f44785f5 100644 --- a/src/app/components/editor/slate.d.ts +++ b/src/app/components/editor/slate.d.ts @@ -64,6 +64,8 @@ export type CodeLineElement = { }; export type CodeBlockElement = { type: BlockType.CodeBlock; + /** [Gitea #107] Optional language → ``. */ + lang?: string; children: CodeLineElement[]; }; export type QuoteLineElement = { diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 21cc04d32..3718e275a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -13,8 +13,9 @@ import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; -import { Transforms, Editor } from 'slate'; +import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate'; import { + Button, Box, Dialog, Icon, @@ -58,6 +59,7 @@ import { getBeginCommand, trimCommand, getMentions, + BlockType, } from '../../components/editor'; import { EmojiBoardTab } from '../../components/emoji-board/types'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -99,6 +101,7 @@ import { filesToUploadItems } from '../../utils/uploadItems'; import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; +import { looksLikeCode } from '../../utils/looksLikeCode'; import { useAlive } from '../../hooks/useAlive'; import { ComposerToolbarButtonKey, @@ -297,6 +300,48 @@ export const RoomInput = forwardRef( const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled'); // [Gitea #103] Privacy: drop tracking params from links on paste and on send. const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams'); + // [Gitea #107] "Paste as code block?" offer for the top-level blocks a + // paste just filled. Dismissed by typing, by No, or after 8 s. + const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste'); + const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>(); + useEffect(() => { + if (!codeOffer) return undefined; + const t = window.setTimeout(() => setCodeOffer(undefined), 8000); + return () => window.clearTimeout(t); + }, [codeOffer]); + const acceptCodeOffer = useCallback(() => { + if (!codeOffer) return; + const { start, end, lang } = codeOffer; + setCodeOffer(undefined); + if (end >= editor.children.length || start > end) return; + const blocks = editor.children.slice(start, end + 1); + // Only plain paragraphs (what a paste produces); anything else, leave it. + if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return; + // Rebuild from plain text: code lines hold text only, and a paste may + // have produced links/mentions. + const lines = blocks.map((b) => SlateNode.string(b)); + Editor.withoutNormalizing(editor, () => { + for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] }); + Transforms.insertNodes( + editor, + { + type: BlockType.CodeBlock, + lang, + children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })), + }, + { at: [start] }, + ); + if (start + 1 >= editor.children.length) { + Transforms.insertNodes( + editor, + { type: BlockType.Paragraph, children: [{ text: '' }] }, + { at: [start + 1] }, + ); + } + }); + Transforms.select(editor, Editor.start(editor, [start + 1])); + ReactEditor.focus(editor); + }, [codeOffer, editor]); const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata'); const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled; const showLocation = composerToolbarButtons?.showLocation ?? true; @@ -411,7 +456,26 @@ export const RoomInput = forwardRef( const handlePaste = useCallback( (evt) => { handleFilePaste(evt); - if (evt.defaultPrevented || !stripTracking) return; + if (evt.defaultPrevented) return; + if (offerCodePaste && editor.selection) { + const pasted = evt.clipboardData?.getData('text/plain') ?? ''; + const startBlock = Range.start(editor.selection).path[0]; + const startNode = editor.children[startBlock]; + const inCode = + SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock; + const guess = + !inCode && pasted + ? looksLikeCode(pasted, evt.clipboardData?.getData('text/html')) + : undefined; + if (guess?.isCode) { + // Slate inserts after this handler returns; read where it ended. + window.setTimeout(() => { + const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock; + setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang }); + }, 0); + } + } + if (!stripTracking) return; const text = evt.clipboardData?.getData('text/plain'); if (!text) return; const cleaned = stripTrackingParamsInText(text); @@ -424,7 +488,7 @@ export const RoomInput = forwardRef( dt.setData('text/plain', cleaned); ReactEditor.insertData(editor, dt); }, - [handleFilePaste, stripTracking, editor], + [handleFilePaste, stripTracking, editor, offerCodePaste], ); const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles); const { gifApiKey } = useClientConfig(); @@ -853,6 +917,15 @@ export const RoomInput = forwardRef( const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + // Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer. + if ( + evt.key.length === 1 || + evt.key === 'Backspace' || + evt.key === 'Delete' || + evt.key === 'Enter' + ) { + setCodeOffer(undefined); + } if ( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !isComposing(evt) @@ -1149,6 +1222,43 @@ export const RoomInput = forwardRef( )} + {codeOffer && ( + + + + That looks like code. Format it as a code block + {codeOffer.lang ? ` (${codeOffer.lang})` : ''}? + + + + + )} } /> + + } + /> + looksLikeCode(text, html); + +describe('looksLikeCode: code', () => { + it('JavaScript', () => { + const r = code(`function add(a, b) { + const sum = a + b; + if (sum > 10) { + return sum; + } + return 0; +}`); + assert.equal(r.isCode, true); + assert.equal(r.lang, 'js'); + }); + + it('TypeScript', () => { + const r = code(`interface User { + id: string; + name: string; +} +const u: User = { id: '1', name: 'a' };`); + assert.deepEqual(r, { isCode: true, lang: 'ts' }); + }); + + it('Python', () => { + const r = code(`def greet(name): + if not name: + return "hi" + return f"hi {name}" +print(greet("bob"))`); + assert.deepEqual(r, { isCode: true, lang: 'python' }); + }); + + it('SQL', () => { + const r = code(`SELECT id, name +FROM users +WHERE active = 1 +ORDER BY name;`); + assert.deepEqual(r, { isCode: true, lang: 'sql' }); + }); + + it('Rust', () => { + const r = code(`pub fn main() { + let mut total = 0; + for i in 0..10 { + total += i; + } +}`); + assert.deepEqual(r, { isCode: true, lang: 'rust' }); + }); + + it('config-ish JSON without a language guess', () => { + const r = code(`{ + "name": "lotus", + "version": "1.0.0", + "private": true +}`); + assert.equal(r.isCode, true); + }); + + it('an editor paste carrying monospace HTML counts as a signal', () => { + const text = `server { + listen 443 ssl; + server_name chat.example.org; +}`; + assert.equal(code(text, '
x
').isCode, true); + }); +}); + +describe('looksLikeCode: not code', () => { + it('fewer than three lines', () => { + assert.equal(code('const a = 1;\nconst b = 2;').isCode, false); + }); + + it('prose with line breaks', () => { + assert.equal( + code(`Hey, are we still on for tonight? +I can bring snacks if someone else handles drinks. +Let me know what time works, I'm free after six.`).isCode, + false, + ); + }); + + it('a long paragraph pasted as lines', () => { + const line = + 'This is a long sentence about the plan. It keeps going for a while. And then some more words follow here.'; + assert.equal(code([line, line, line].join('\n')).isCode, false); + }); + + it('a markdown list', () => { + assert.equal( + code(`- milk (2 litres) +- eggs: a dozen +- bread, the good kind +- coffee!`).isCode, + false, + ); + }); + + it('a quoted reply', () => { + assert.equal( + code(`> const a = 1; +> if (a) { run(); } +> return a;`).isCode, + false, + ); + }); + + it('a list of links', () => { + assert.equal( + code(`https://example.org/a?b=1 +https://example.org/c +https://example.org/d;e`).isCode, + false, + ); + }); + + it('a log excerpt', () => { + assert.equal( + code(`2026-09-25 10:00:01 INFO server started +2026-09-25 10:00:02 INFO listening on 8008 +2026-09-25 10:00:05 WARN slow request 1200ms`).isCode, + false, + ); + }); +}); diff --git a/src/app/utils/looksLikeCode.ts b/src/app/utils/looksLikeCode.ts new file mode 100644 index 000000000..6fe770a5f --- /dev/null +++ b/src/app/utils/looksLikeCode.ts @@ -0,0 +1,111 @@ +/** + * [Gitea #107] Decide whether a multi-line paste looks like source code, so the + * composer can OFFER to turn it into a code block (never converts on its own). + * Pure and unit-tested; tuned to keep prose, lists, quotes and URL lists out. + */ + +export type CodeGuess = { + /** Offer "Paste as code block?" */ + isCode: boolean; + /** Language for `class="language-…"`, only when fairly sure. */ + lang?: string; +}; + +const KEYWORDS = [ + /\bfunction\b/, + /\bconst\b/, + /\blet\b/, + /\bvar\b/, + /\breturn\b/, + /\bimport\b/, + /\bexport\b/, + /\bclass\b/, + /\bdef\b/, + /\bfn\b/, + /\bpub\b/, + /\bstruct\b/, + /\bif \(/, + /\bfor \(/, + /\bwhile \(/, + /#include\b/, + /\bSELECT\b/i, + /\bFROM\b/i, + /<\?php/, + /<\//, +]; + +const MONO_HTML = /font-family:[^;"]*(mono|courier|consolas|menlo)|]|]/i; + +const indentOf = (line: string): number => { + const m = /^[ \t]*/.exec(line); + return m ? m[0].replace(/\t/g, ' ').length : 0; +}; + +export function guessLanguage(text: string): string | undefined { + if ( + /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE TABLE)\b/im.test(text) && + /\bFROM\b|\bINTO\b|\bSET\b|\(/i.test(text) + ) + return 'sql'; + if (/\bpub fn\b|\bfn \w+\(.*\)\s*(->|\{)|\blet mut\b/.test(text)) return 'rust'; + if (/^\s*#include\s*[<"]/m.test(text)) return 'c'; + if ( + /^\s*def \w+\(.*\):\s*$/m.test(text) || + (/^\s*(from \w+ )?import \w+\s*$/m.test(text) && /:\s*$/m.test(text)) + ) + return 'python'; + if (/:\s*(string|number|boolean)\b|\binterface \w+|\btype \w+ =/.test(text) && /[{;]/.test(text)) + return 'ts'; + if (/\b(const|let|var)\b|=>/.test(text) && /[{;]/.test(text)) return 'js'; + if (/^\s*<\?php/m.test(text)) return 'php'; + return undefined; +} + +export function looksLikeCode(text: string, html?: string): CodeGuess { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + const nonEmpty = lines.filter((l) => l.trim() !== ''); + if (nonEmpty.length < 3) return { isCode: false }; + const n = nonEmpty.length; + const share = (pred: (l: string) => boolean) => nonEmpty.filter(pred).length / n; + + // ── Prose guards ── + if (share((l) => /^\s*>/.test(l)) > 0.5) return { isCode: false }; // quoted reply + if (nonEmpty.every((l) => /^\s*(<)?https?:\/\/\S+(>)?\s*$/.test(l))) return { isCode: false }; + const avgLen = nonEmpty.reduce((sum, l) => sum + l.length, 0) / n; + if (avgLen > 90 && share((l) => (l.match(/[.!?] /g) ?? []).length >= 2) > 0.5) { + return { isCode: false }; + } + if (share((l) => /^[\p{L}\p{N}\s'’",.!?-]*$/u.test(l)) > 0.6) return { isCode: false }; + if (share((l) => /^\s*([-*+]|\d+[.)])\s+\S/.test(l)) > 0.6) return { isCode: false }; // list + + // ── Signals: need two ── + let signals = 0; + const indented = share((l) => /^( {2,}|\t)/.test(l)); + let depthChanges = 0; + for (let i = 1; i < nonEmpty.length; i += 1) { + if (indentOf(nonEmpty[i]) !== indentOf(nonEmpty[i - 1])) depthChanges += 1; + } + if (indented >= 0.4 && depthChanges >= 2) signals += 1; + if (share((l) => /[;{}):,]\s*$/.test(l)) >= 0.3) signals += 1; + if ( + nonEmpty.filter((l) => /=>|->|::|\(\)|[^=!<>]=[^=]/.test(l)).length >= 2 && + /[{}[\]]/.test(text) + ) { + signals += 1; + } + if (KEYWORDS.filter((k) => k.test(text)).length >= 2) signals += 1; + if (html && MONO_HTML.test(html)) signals += 1; + // SQL has few symbols: lines that open with a clause keyword are its shape. + if ( + share((l) => + /^\s*(SELECT|FROM|WHERE|(LEFT |RIGHT |INNER )?JOIN|ORDER BY|GROUP BY|HAVING|INSERT|VALUES|UPDATE|SET|DELETE|LIMIT|AND|OR)\b/.test( + l, + ), + ) >= 0.5 + ) { + signals += 1; + } + + if (signals < 2) return { isCode: false }; + return { isCode: true, lang: guessLanguage(text) }; +}