You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@answer.apache.org by ro...@apache.org on 2025/05/19 03:34:32 UTC

(answer) 01/01: feat: add Turndown integration for HTML to Markdown conversion in image handling

This is an automated email from the ASF dual-hosted git repository.

robin0716 pushed a commit to branch refactor/turndown
in repository https://gitbox.apache.org/repos/asf/answer.git

commit a924920f278d7f3a2f17f43cb5dbdf401d8aa277
Author: robin <ro...@rnode.me>
AuthorDate: Mon May 19 11:33:52 2025 +0800

    feat: add Turndown integration for HTML to Markdown conversion in image handling
---
 ui/package.json                             |  2 ++
 ui/pnpm-lock.yaml                           | 23 ++++++++++++++++++
 ui/src/components/Editor/ToolBars/image.tsx | 37 +++++++++++------------------
 ui/src/components/Editor/utils/index.ts     | 18 ++++++++++++++
 4 files changed, 57 insertions(+), 23 deletions(-)

diff --git a/ui/package.json b/ui/package.json
index b88fc12d..842b607d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -46,6 +46,7 @@
     "react-router-dom": "^7.0.2",
     "semver": "^7.3.8",
     "swr": "^1.3.0",
+    "turndown": "^7.2.0",
     "zustand": "^5.0.2"
   },
   "devDependencies": {
@@ -65,6 +66,7 @@
     "@types/qs": "^6.9.7",
     "@types/react": "^18.0.17",
     "@types/react-dom": "^18.0.6",
+    "@types/turndown": "^5.0.5",
     "@typescript-eslint/eslint-plugin": "^6.11.0",
     "@typescript-eslint/parser": "^6.11.0",
     "buffer": "6.0.3",
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 842009a7..cbed2c6e 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -95,6 +95,9 @@ importers:
       swr:
         specifier: ^1.3.0
         version: 1.3.0(react@18.3.1)
+      turndown:
+        specifier: ^7.2.0
+        version: 7.2.0
       zustand:
         specifier: ^5.0.2
         version: 5.0.2(@types/react@18.3.16)(immer@9.0.21)(react@18.3.1)(use-sync-external-store@1.2.2(react@18.3.1))
@@ -147,6 +150,9 @@ importers:
       '@types/react-dom':
         specifier: ^18.0.6
         version: 18.3.5(@types/react@18.3.16)
+      '@types/turndown':
+        specifier: ^5.0.5
+        version: 5.0.5
       '@typescript-eslint/eslint-plugin':
         specifier: ^6.11.0
         version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
@@ -1409,6 +1415,9 @@ packages:
   '@marijn/find-cluster-break@1.0.2':
     resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
 
+  '@mixmark-io/domino@2.2.0':
+    resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==}
 
@@ -1808,6 +1817,9 @@ packages:
   '@types/trusted-types@2.0.7':
     resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
 
+  '@types/turndown@5.0.5':
+    resolution: {integrity: sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==}
+
   '@types/warning@3.0.3':
     resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==}
 
@@ -6454,6 +6466,9 @@ packages:
   turbo-stream@2.4.0:
     resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
 
+  turndown@7.2.0:
+    resolution: {integrity: sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==}
+
   type-check@0.3.2:
     resolution: {integrity: sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==}
     engines: {node: '>= 0.8.0'}
@@ -8677,6 +8692,8 @@ snapshots:
 
   '@marijn/find-cluster-break@1.0.2': {}
 
+  '@mixmark-io/domino@2.2.0': {}
+
   '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1':
     dependencies:
       eslint-scope: 5.1.1
@@ -9127,6 +9144,8 @@ snapshots:
 
   '@types/trusted-types@2.0.7': {}
 
+  '@types/turndown@5.0.5': {}
+
   '@types/warning@3.0.3': {}
 
   '@types/ws@8.5.13':
@@ -14599,6 +14618,10 @@ snapshots:
 
   turbo-stream@2.4.0: {}
 
+  turndown@7.2.0:
+    dependencies:
+      '@mixmark-io/domino': 2.2.0
+
   type-check@0.3.2:
     dependencies:
       prelude-ls: 1.1.2
diff --git a/ui/src/components/Editor/ToolBars/image.tsx b/ui/src/components/Editor/ToolBars/image.tsx
index 322da08d..5b31d99d 100644
--- a/ui/src/components/Editor/ToolBars/image.tsx
+++ b/ui/src/components/Editor/ToolBars/image.tsx
@@ -21,6 +21,8 @@ import { useEffect, useState, memo } from 'react';
 import { Button, Form, Modal, Tab, Tabs } from 'react-bootstrap';
 import { useTranslation } from 'react-i18next';
 
+import TurndownService from 'turndown';
+
 import { Modal as AnswerModal } from '@/components';
 import ToolItem from '../toolItem';
 import { IEditorContext, Editor } from '../types';
@@ -152,11 +154,21 @@ const Image = ({ editorInstance }) => {
     e.stopPropagation();
     e.preventDefault();
   }
+
+  function htmlToMarkdown(htmlStr) {
+    const turndownService = new TurndownService();
+
+    const markdown = turndownService.turndown(htmlStr);
+    return markdown;
+  }
   const drop = async (e) => {
     const fileList = e.dataTransfer.files;
     const bool = verifyImageSize(fileList);
 
     if (!bool) {
+      const htmlStr = e.dataTransfer.getData('text/html');
+      const markdown = htmlToMarkdown(htmlStr);
+      editor.replaceSelection(markdown);
       return;
     }
 
@@ -228,30 +240,9 @@ const Image = ({ editorInstance }) => {
       return;
     }
     event.preventDefault();
+    const markdown = htmlToMarkdown(htmlStr);
 
-    let innerText = '';
-    const allPTag = new DOMParser()
-      .parseFromString(
-        htmlStr.replace(
-          /<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/gi,
-          `<p>![${t('image.text')}]($3)\n\n</p>`,
-        ),
-        'text/html',
-      )
-      .querySelectorAll('body p');
-
-    allPTag.forEach((p, index) => {
-      const text = p.textContent || '';
-      if (text !== '') {
-        if (index === allPTag.length - 1) {
-          innerText += `${p.textContent}`;
-        } else {
-          innerText += `${p.textContent}${text.endsWith('\n') ? '' : '\n\n'}`;
-        }
-      }
-    });
-
-    editor.replaceSelection(innerText);
+    editor.replaceSelection(markdown);
   };
   const handleClick = () => {
     if (!link.value) {
diff --git a/ui/src/components/Editor/utils/index.ts b/ui/src/components/Editor/utils/index.ts
index 3977367c..d9150c09 100644
--- a/ui/src/components/Editor/utils/index.ts
+++ b/ui/src/components/Editor/utils/index.ts
@@ -172,6 +172,24 @@ export const useEditor = ({
         placeholder(placeholderText),
         EditorView.lineWrapping,
         editableCompartment.of(EditorView.editable.of(true)),
+        EditorView.domEventHandlers({
+          paste(event) {
+            const clipboard = event.clipboardData as DataTransfer;
+            const htmlStr = clipboard.getData('text/html');
+            const imgRegex =
+              /<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
+
+            return Boolean(htmlStr.match(imgRegex));
+          },
+          drop(event) {
+            const clipboard = event.dataTransfer as DataTransfer;
+            const htmlStr = clipboard.getData('text/html');
+            const imgRegex =
+              /<img([\s\S]*?) src\s*=\s*(['"])([\s\S]*?)\2([^>]*)>/;
+
+            return Boolean(htmlStr.match(imgRegex));
+          },
+        }),
       ],
     });