diff --git a/apps/code/package.json b/apps/code/package.json index dee944027f..ce2a226687 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -51,10 +51,10 @@ "@electron-forge/plugin-vite": "^7.11.1", "@electron-forge/publisher-github": "^7.11.1", "@electron-forge/shared-types": "^7.11.1", - "@reforged/maker-appimage": "^5.2.0", "@electron/rebuild": "^4.0.3", "@playwright/test": "^1.42.0", "@posthog/rollup-plugin": "^1.4.0", + "@reforged/maker-appimage": "^5.2.0", "@storybook/addon-a11y": "10.2.0", "@storybook/addon-docs": "10.2.0", "@storybook/react-vite": "10.2.0", @@ -67,6 +67,7 @@ "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", "@types/semver": "^7.7.1", + "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.2.1", "@vitest/ui": "^4.0.10", "adm-zip": "^0.5.16", @@ -118,6 +119,7 @@ "@codemirror/view": "^6.39.17", "@dnd-kit/react": "^0.1.21", "@fontsource-variable/inter": "^5.2.8", + "@joplin/turndown-plugin-gfm": "^1.0.67", "@lezer/common": "^1.5.1", "@lezer/highlight": "^1.2.3", "@modelcontextprotocol/ext-apps": "^1.1.2", @@ -203,6 +205,7 @@ "tailwind-merge": "^3.5.0", "tailwindcss-scroll-mask": "^0.0.3", "tippy.js": "^6.3.7", + "turndown": "^7.2.4", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "vscode-icons-js": "^11.6.1", diff --git a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts index 7df2b75f70..bc11a2a105 100644 --- a/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts +++ b/apps/code/src/renderer/features/message-editor/tiptap/useTiptapEditor.ts @@ -19,6 +19,7 @@ import { type ParsedGithubIssueUrl, parseGithubIssueUrl, } from "../utils/githubIssueUrl"; +import { htmlToMarkdown } from "../utils/htmlToMarkdown"; import { persistImageFile, persistTextContent, @@ -451,19 +452,24 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + // Editor is plain-text, so preserve pasted formatting as Markdown. + const html = event.clipboardData?.getData("text/html"); + const markdown = html ? htmlToMarkdown(html, clipboardText) : null; + const effectiveText = markdown ?? clipboardText; + // Auto-convert long pasted text into a file attachment const autoConvertThreshold = useFeatureSettingsStore.getState().autoConvertLongText; if ( - clipboardText && + effectiveText && autoConvertThreshold !== "off" && - clipboardText.length > Number(autoConvertThreshold) + effectiveText.length > Number(autoConvertThreshold) ) { event.preventDefault(); (async () => { try { - await pasteTextAsFile(view, clipboardText, pasteCountRef); + await pasteTextAsFile(view, effectiveText, pasteCountRef); showPasteHint( "Pasted as file attachment", "Click the chip to convert back to text.", @@ -476,6 +482,13 @@ export function useTiptapEditor(options: UseTiptapEditorOptions) { return true; } + // Insert inline; ProseMirror would otherwise drop the HTML formatting. + if (markdown) { + event.preventDefault(); + view.dispatch(view.state.tr.insertText(markdown, from, to)); + return true; + } + if (clipboardText && clipboardText.length > 200) { showPasteHint( "Pasted as text", diff --git a/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.test.ts b/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.test.ts new file mode 100644 index 0000000000..cd490a12b5 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { htmlToMarkdown } from "./htmlToMarkdown"; + +describe("htmlToMarkdown", () => { + it.each([ + [ + "headings, emphasis and links", + "
Some bold and italic with a link.
", + "# Title\n\nSome **bold** and *italic* with a [link](https://posthog.com).", + ], + [ + "unordered lists", + "| a | b |
|---|---|
| 1 | 2 |
const x = 1;",
+ "```\nconst x = 1;\n```",
+ ],
+ ])("converts %s", (_, html, expected) => {
+ expect(htmlToMarkdown(html)).toBe(expected);
+ });
+
+ it("returns null when there is no formatting beyond the plain-text fallback", () => {
+ const html = "just text
"; + expect(htmlToMarkdown(html, "just text")).toBeNull(); + }); + + it("returns null for empty html", () => { + expect(htmlToMarkdown("")).toBeNull(); + expect(htmlToMarkdown("")).toBeNull(); + }); +}); diff --git a/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.ts b/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.ts new file mode 100644 index 0000000000..197fcfcc83 --- /dev/null +++ b/apps/code/src/renderer/features/message-editor/utils/htmlToMarkdown.ts @@ -0,0 +1,35 @@ +import { gfm } from "@joplin/turndown-plugin-gfm"; +import TurndownService from "turndown"; + +let turndown: TurndownService | null = null; + +function getTurndown(): TurndownService { + if (turndown) return turndown; + turndown = new TurndownService({ + headingStyle: "atx", + codeBlockStyle: "fenced", + bulletListMarker: "-", + emDelimiter: "*", + }); + turndown.use(gfm); // tables, strikethrough, task lists + return turndown; +} + +/** Convert clipboard HTML to Markdown. Returns null when it adds nothing over the plain-text fallback. */ +export function htmlToMarkdown( + html: string, + plainTextFallback?: string, +): string | null { + const markdown = getTurndown().turndown(html).trim(); + if (!markdown) return null; + + // No formatting beyond the plain text; defer to the default paste. + if ( + plainTextFallback !== undefined && + markdown === plainTextFallback.trim() + ) { + return null; + } + + return markdown; +} diff --git a/apps/code/src/renderer/types/joplin-turndown-plugin-gfm.d.ts b/apps/code/src/renderer/types/joplin-turndown-plugin-gfm.d.ts new file mode 100644 index 0000000000..7694851d89 --- /dev/null +++ b/apps/code/src/renderer/types/joplin-turndown-plugin-gfm.d.ts @@ -0,0 +1,9 @@ +declare module "@joplin/turndown-plugin-gfm" { + import type { Plugin } from "turndown"; + + export const gfm: Plugin; + export const tables: Plugin; + export const strikethrough: Plugin; + export const taskListItems: Plugin; + export const highlightedCodeBlock: Plugin; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e7c6a229c..563cac104f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 + '@joplin/turndown-plugin-gfm': + specifier: ^1.0.67 + version: 1.0.67 '@lezer/common': specifier: ^1.5.1 version: 1.5.1 @@ -376,6 +379,9 @@ importers: tippy.js: specifier: ^6.3.7 version: 6.3.7 + turndown: + specifier: ^7.2.4 + version: 7.2.4 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -464,6 +470,9 @@ importers: '@types/semver': specifier: ^7.7.1 version: 7.7.1 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 '@vitejs/plugin-react': specifier: ^4.2.1 version: 4.7.0(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -2960,6 +2969,9 @@ packages: resolution: {integrity: sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==} engines: {node: '>=18'} + '@joplin/turndown-plugin-gfm@1.0.67': + resolution: {integrity: sha512-FZfW5EZfidhzd1IaY1uxHnIZPTVOxAdleMZ4/1U6Nt5b7+Qj5JThDnaIomuJtetnUBzuRNbe9FWMuqD4B3dlWA==} + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3': resolution: {integrity: sha512-9TGZuAX+liGkNKkwuo3FYJu7gHWT0vkBcf7GkOe7s7fmC19XwH/4u5u7sDIFrMooe558ORcmuBvBz7Ur5PlbHw==} peerDependencies: @@ -3187,6 +3199,9 @@ packages: '@types/react': '>=16' react: '>=16' + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/ext-apps@1.2.2': resolution: {integrity: sha512-qMnhIKb8tyPesl+kZU76Xz9Bi9putCO+LcgvBJ00fDdIniiLZsnQbAeTKoq+sTiYH1rba2Fvj8NPAFxij+gyxw==} engines: {node: '>=20'} @@ -5465,6 +5480,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -11553,6 +11571,10 @@ packages: resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -14969,6 +14991,8 @@ snapshots: '@jimp/types': 1.6.0 tinycolor2: 1.6.0 + '@joplin/turndown-plugin-gfm@1.0.67': {} + '@joshwooding/vite-plugin-react-docgen-typescript@0.6.3(typescript@5.9.3)(vite@6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: glob: 11.1.0 @@ -15248,6 +15272,8 @@ snapshots: '@types/react': 19.2.11 react: 19.1.0 + '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(zod@4.3.6)': dependencies: '@modelcontextprotocol/sdk': 1.27.1(zod@4.3.6) @@ -17655,6 +17681,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -24739,6 +24767,10 @@ snapshots: turbo-windows-64: 2.8.3 turbo-windows-arm64: 2.8.3 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tw-animate-css@1.4.0: {} type-detect@4.0.8: {}