]> source.dussan.org Git - gitea.git/commitdiff
Support quote selected comments to reply (#32431)
authorwxiaoguang <wxiaoguang@gmail.com>
Thu, 7 Nov 2024 03:57:07 +0000 (11:57 +0800)
committerGitHub <noreply@github.com>
Thu, 7 Nov 2024 03:57:07 +0000 (03:57 +0000)
Many existing tests were quite hacky, these could be improved later.

<details>

![image](https://github.com/user-attachments/assets/93aebb4f-9de5-4cb8-910b-50c64cbcd25a)

</details>

16 files changed:
modules/markup/html.go
modules/markup/html_codepreview_test.go
modules/markup/html_internal_test.go
modules/markup/html_test.go
modules/markup/markdown/markdown_test.go
modules/markup/sanitizer_default.go
modules/templates/util_render_test.go
routers/api/v1/misc/markup_test.go
templates/repo/diff/comments.tmpl
templates/repo/issue/view_content.tmpl
templates/repo/issue/view_content/comments.tmpl
templates/repo/issue/view_content/context_menu.tmpl
templates/repo/issue/view_content/conversation.tmpl
web_src/js/features/repo-issue-edit.ts
web_src/js/markup/html2markdown.test.ts [new file with mode: 0644]
web_src/js/markup/html2markdown.ts [new file with mode: 0644]

index a9c3dc9ba289f93d769088bec7670be18d639b7d..e2eefefc4bafd7a9dcfddec306dc86517c2d79d4 100644 (file)
@@ -442,7 +442,10 @@ func createLink(href, content, class string) *html.Node {
        a := &html.Node{
                Type: html.ElementNode,
                Data: atom.A.String(),
-               Attr: []html.Attribute{{Key: "href", Val: href}},
+               Attr: []html.Attribute{
+                       {Key: "href", Val: href},
+                       {Key: "data-markdown-generated-content"},
+               },
        }
 
        if class != "" {
index d33630d0401b446af9304923854ccaf0ec5400d0..a90de278f57ff7033f139771e756da3cc060da05 100644 (file)
@@ -30,5 +30,5 @@ func TestRenderCodePreview(t *testing.T) {
                assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
        }
        test("http://localhost:3000/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", "<p><div>code preview</div></p>")
-       test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
+       test("http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20", `<p><a href="http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20" data-markdown-generated-content="" rel="nofollow">http://other/owner/repo/src/commit/0123456789/foo/bar.md#L10-L20</a></p>`)
 }
index 74089cffddfa11c257004d845a1cb0c718587001..8f516751b08b811bdbf70aeee6e3f5c9a836477e 100644 (file)
@@ -33,11 +33,9 @@ func numericIssueLink(baseURL, class string, index int, marker string) string {
 
 // link an HTML link
 func link(href, class, contents string) string {
-       if class != "" {
-               class = " class=\"" + class + "\""
-       }
-
-       return fmt.Sprintf("<a href=\"%s\"%s>%s</a>", href, class, contents)
+       extra := ` data-markdown-generated-content=""`
+       extra += util.Iif(class != "", ` class="`+class+`"`, "")
+       return fmt.Sprintf(`<a href="%s"%s>%s</a>`, href, extra, contents)
 }
 
 var numericMetas = map[string]string{
@@ -353,7 +351,9 @@ func TestRender_FullIssueURLs(t *testing.T) {
                        Metas: localMetas,
                }, []processor{fullIssuePatternProcessor}, strings.NewReader(input), &result)
                assert.NoError(t, err)
-               assert.Equal(t, expected, result.String())
+               actual := result.String()
+               actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, expected, actual)
        }
        test("Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6",
                "Here is a link https://git.osgeo.org/gogs/postgis/postgis/pulls/6")
index 32858dbd6b31be2b2c6a90b27a304a711f8d54f5..82aded4407c2ae281507c2064d43352678e550d3 100644 (file)
@@ -116,7 +116,9 @@ func TestRender_CrossReferences(t *testing.T) {
                        Metas: localMetas,
                }, input)
                assert.NoError(t, err)
-               assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+               actual := strings.TrimSpace(buffer)
+               actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, strings.TrimSpace(expected), actual)
        }
 
        test(
@@ -156,7 +158,9 @@ func TestRender_links(t *testing.T) {
                        },
                }, input)
                assert.NoError(t, err)
-               assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
+               actual := strings.TrimSpace(buffer)
+               actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, strings.TrimSpace(expected), actual)
        }
 
        oldCustomURLSchemes := setting.Markdown.CustomURLSchemes
@@ -267,7 +271,9 @@ func TestRender_email(t *testing.T) {
                        },
                }, input)
                assert.NoError(t, err)
-               assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res))
+               actual := strings.TrimSpace(res)
+               actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, strings.TrimSpace(expected), actual)
        }
        // Text that should be turned into email link
 
@@ -616,7 +622,9 @@ func TestPostProcess_RenderDocument(t *testing.T) {
                        Metas: localMetas,
                }, strings.NewReader(input), &res)
                assert.NoError(t, err)
-               assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res.String()))
+               actual := strings.TrimSpace(res.String())
+               actual = strings.ReplaceAll(actual, ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, strings.TrimSpace(expected), actual)
        }
 
        // Issue index shouldn't be post processing in a document.
index cfb821ab19ff48b0a0d72429dc6f86e3f0c20776..ad38e7a088fbf0aa5d41de590fc0cc7a99286d37 100644 (file)
@@ -311,7 +311,8 @@ func TestTotal_RenderWiki(t *testing.T) {
                        IsWiki: true,
                }, sameCases[i])
                assert.NoError(t, err)
-               assert.Equal(t, template.HTML(answers[i]), line)
+               actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, answers[i], actual)
        }
 
        testCases := []string{
@@ -336,7 +337,8 @@ func TestTotal_RenderWiki(t *testing.T) {
                        IsWiki: true,
                }, testCases[i])
                assert.NoError(t, err)
-               assert.Equal(t, template.HTML(testCases[i+1]), line)
+               actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+               assert.EqualValues(t, testCases[i+1], actual)
        }
 }
 
@@ -356,7 +358,8 @@ func TestTotal_RenderString(t *testing.T) {
                        Metas: localMetas,
                }, sameCases[i])
                assert.NoError(t, err)
-               assert.Equal(t, template.HTML(answers[i]), line)
+               actual := strings.ReplaceAll(string(line), ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, answers[i], actual)
        }
 
        testCases := []string{}
@@ -996,7 +999,8 @@ space</p>
        for i, c := range cases {
                result, err := markdown.RenderString(&markup.RenderContext{Ctx: context.Background(), Links: c.Links, IsWiki: c.IsWiki}, input)
                assert.NoError(t, err, "Unexpected error in testcase: %v", i)
-               assert.Equal(t, c.Expected, string(result), "Unexpected result in testcase %v", i)
+               actual := strings.ReplaceAll(string(result), ` data-markdown-generated-content=""`, "")
+               assert.Equal(t, c.Expected, actual, "Unexpected result in testcase %v", i)
        }
 }
 
index 669dc24eae39c4de0e91fae4dccbe742f502ea2b..476ae5e26f0c1673d58e48a3c744de25ca5e7edd 100644 (file)
@@ -107,6 +107,7 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
                "start", "summary", "tabindex", "target",
                "title", "type", "usemap", "valign", "value",
                "vspace", "width", "itemprop",
+               "data-markdown-generated-content",
        }
 
        generalSafeElements := []string{
index 3e4ea04c63191202872cf7401dc46f761f278a9f..0a6e89c5f2edc09156fa6a6cfbeb4548d5f75130 100644 (file)
@@ -129,18 +129,18 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 <a href="/mention-user" class="mention">@mention-user</a> test
 <a href="/user13/repo11/issues/123" class="ref-issue">#123</a>
   space`
-
-       assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitBody(testInput(), testMetas))
+       actual := strings.ReplaceAll(string(newTestRenderUtils().RenderCommitBody(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
+       assert.EqualValues(t, expected, actual)
 }
 
 func TestRenderCommitMessage(t *testing.T) {
-       expected := `space <a href="/mention-user" class="mention">@mention-user</a>  `
+       expected := `space <a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>  `
 
        assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessage(testInput(), testMetas))
 }
 
 func TestRenderCommitMessageLinkSubject(t *testing.T) {
-       expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" class="mention">@mention-user</a>`
+       expected := `<a href="https://example.com/link" class="default-link muted">space </a><a href="/mention-user" data-markdown-generated-content="" class="mention">@mention-user</a>`
 
        assert.EqualValues(t, expected, newTestRenderUtils().RenderCommitMessageLinkSubject(testInput(), "https://example.com/link", testMetas))
 }
@@ -168,7 +168,8 @@ mail@domain.com
   space<SPACE><SPACE>
 `
        expected = strings.ReplaceAll(expected, "<SPACE>", " ")
-       assert.EqualValues(t, expected, newTestRenderUtils().RenderIssueTitle(testInput(), testMetas))
+       actual := strings.ReplaceAll(string(newTestRenderUtils().RenderIssueTitle(testInput(), testMetas)), ` data-markdown-generated-content=""`, "")
+       assert.EqualValues(t, expected, actual)
 }
 
 func TestRenderMarkdownToHtml(t *testing.T) {
@@ -193,7 +194,8 @@ com 88fc37a3c0a4dda553bdcfc80c178a58247f42fb mit
 #123
 space</p>
 `
-       assert.Equal(t, expected, string(newTestRenderUtils().MarkdownToHtml(testInput())))
+       actual := strings.ReplaceAll(string(newTestRenderUtils().MarkdownToHtml(testInput())), ` data-markdown-generated-content=""`, "")
+       assert.Equal(t, expected, actual)
 }
 
 func TestRenderLabels(t *testing.T) {
@@ -211,5 +213,5 @@ func TestRenderLabels(t *testing.T) {
 
 func TestUserMention(t *testing.T) {
        rendered := newTestRenderUtils().MarkdownToHtml("@no-such-user @mention-user @mention-user")
-       assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" rel="nofollow">@mention-user</a> <a href="/mention-user" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
+       assert.EqualValues(t, `<p>@no-such-user <a href="/mention-user" data-markdown-generated-content="" rel="nofollow">@mention-user</a> <a href="/mention-user" data-markdown-generated-content="" rel="nofollow">@mention-user</a></p>`, strings.TrimSpace(string(rendered)))
 }
index e2ab7141b72bd23b0e364bf5a82b92671e205145..abffdf351614aca50f89c92b0d352930c17833f8 100644 (file)
@@ -38,7 +38,8 @@ func testRenderMarkup(t *testing.T, mode string, wiki bool, filePath, text, expe
        ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markup")
        web.SetForm(ctx, &options)
        Markup(ctx)
-       assert.Equal(t, expectedBody, resp.Body.String())
+       actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
+       assert.Equal(t, expectedBody, actual)
        assert.Equal(t, expectedCode, resp.Code)
        resp.Body.Reset()
 }
@@ -58,7 +59,8 @@ func testRenderMarkdown(t *testing.T, mode string, wiki bool, text, responseBody
        ctx, resp := contexttest.MockAPIContext(t, "POST /api/v1/markdown")
        web.SetForm(ctx, &options)
        Markdown(ctx)
-       assert.Equal(t, responseBody, resp.Body.String())
+       actual := strings.ReplaceAll(resp.Body.String(), ` data-markdown-generated-content=""`, "")
+       assert.Equal(t, responseBody, actual)
        assert.Equal(t, responseCode, resp.Code)
        resp.Body.Reset()
 }
index 0ed231b07e9b2ab57ca867b19d94b0c2971d7d3d..2d716688b91edbe19bfe9a6d410ce9cdff2edc1d 100644 (file)
@@ -49,7 +49,7 @@
                                        {{end}}
                                {{end}}
                                {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.root.RepoLink .ID)}}
-                               {{template "repo/issue/view_content/context_menu" dict "ctxData" $.root "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
+                               {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" false "diff" true "IsCommentPoster" (and $.root.IsSigned (eq $.root.SignedUserID .PosterID))}}
                        </div>
                </div>
                <div class="ui attached segment comment-body">
index 74440c5ef2e3684bff0b0c7614e7a4cce04ad794..1f9bbd86aa4e46c98c1faf863d639c2aeb3fd949 100644 (file)
@@ -48,7 +48,7 @@
                                                        {{if not $.Repository.IsArchived}}
                                                                {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/issues/%d/reactions" $.RepoLink .Issue.Index)}}
                                                        {{end}}
-                                                       {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
+                                                       {{template "repo/issue/view_content/context_menu" dict "item" .Issue "delete" false "issue" true "diff" false "IsCommentPoster" $.IsIssuePoster}}
                                                </div>
                                        </div>
                                        <div class="ui attached segment comment-body" role="article">
index 30475331544cfe9113e0c47113b74741558ff607..477b6b33c6a5cbb48c8b63af1ead182d36103331 100644 (file)
@@ -55,7 +55,7 @@
                                                        {{if not $.Repository.IsArchived}}
                                                                {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
                                                        {{end}}
-                                                       {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
+                                                       {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
                                                </div>
                                        </div>
                                        <div class="ui attached segment comment-body" role="article">
                                                                {{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
                                                                {{if not $.Repository.IsArchived}}
                                                                        {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
-                                                                       {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
+                                                                       {{template "repo/issue/view_content/context_menu" dict "item" . "delete" false "issue" true "diff" false "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
                                                                {{end}}
                                                        </div>
                                                </div>
index 9e38442c369fd354f41273ee92ac7b6ca4362411..749a2fa0ddc0fe48d8d6ccbcba63b924cf2f5286 100644 (file)
@@ -5,29 +5,29 @@
        <div class="menu">
                {{$referenceUrl := ""}}
                {{if .issue}}
-                       {{$referenceUrl = printf "%s#%s" .ctxData.Issue.Link .item.HashTag}}
+                       {{$referenceUrl = printf "%s#%s" ctx.RootData.Issue.Link .item.HashTag}}
                {{else}}
-                       {{$referenceUrl = printf "%s/files#%s" .ctxData.Issue.Link .item.HashTag}}
+                       {{$referenceUrl = printf "%s/files#%s" ctx.RootData.Issue.Link .item.HashTag}}
                {{end}}
                <div class="item context js-aria-clickable" data-clipboard-text-type="url" data-clipboard-text="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.copy_link"}}</div>
-               {{if .ctxData.IsSigned}}
+               {{if ctx.RootData.IsSigned}}
                        {{$needDivider := false}}
-                       {{if not .ctxData.Repository.IsArchived}}
+                       {{if not ctx.RootData.Repository.IsArchived}}
                                {{$needDivider = true}}
                                <div class="item context js-aria-clickable quote-reply {{if .diff}}quote-reply-diff{{end}}" data-target="{{.item.HashTag}}-raw">{{ctx.Locale.Tr "repo.issues.context.quote_reply"}}</div>
                                {{if not ctx.Consts.RepoUnitTypeIssues.UnitGlobalDisabled}}
                                        <div class="item context js-aria-clickable reference-issue" data-target="{{.item.HashTag}}-raw" data-modal="#reference-issue-modal" data-poster="{{.item.Poster.GetDisplayName}}" data-poster-username="{{.item.Poster.Name}}" data-reference="{{$referenceUrl}}">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</div>
                                {{end}}
-                               {{if or .ctxData.Permission.IsAdmin .IsCommentPoster .ctxData.HasIssuesOrPullsWritePermission}}
+                               {{if or ctx.RootData.Permission.IsAdmin .IsCommentPoster ctx.RootData.HasIssuesOrPullsWritePermission}}
                                        <div class="divider"></div>
                                        <div class="item context js-aria-clickable edit-content">{{ctx.Locale.Tr "repo.issues.context.edit"}}</div>
                                        {{if .delete}}
-                                               <div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{.ctxData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
+                                               <div class="item context js-aria-clickable delete-comment" data-comment-id={{.item.HashTag}} data-url="{{ctx.RootData.RepoLink}}/comments/{{.item.ID}}/delete" data-locale="{{ctx.Locale.Tr "repo.issues.delete_comment_confirm"}}">{{ctx.Locale.Tr "repo.issues.context.delete"}}</div>
                                        {{end}}
                                {{end}}
                        {{end}}
-                       {{$canUserBlock := call .ctxData.CanBlockUser .ctxData.SignedUser .item.Poster}}
-                       {{$canOrgBlock := and .ctxData.Repository.Owner.IsOrganization (call .ctxData.CanBlockUser .ctxData.Repository.Owner .item.Poster)}}
+                       {{$canUserBlock := call ctx.RootData.CanBlockUser ctx.RootData.SignedUser .item.Poster}}
+                       {{$canOrgBlock := and ctx.RootData.Repository.Owner.IsOrganization (call ctx.RootData.CanBlockUser ctx.RootData.Repository.Owner .item.Poster)}}
                        {{if or $canOrgBlock $canUserBlock}}
                                {{if $needDivider}}
                                        <div class="divider"></div>
@@ -36,7 +36,7 @@
                                <div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{AppSubUrl}}/user/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.user"}}</div>
                                {{end}}
                                {{if $canOrgBlock}}
-                               <div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{.ctxData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
+                               <div class="item context js-aria-clickable show-modal" data-modal="#block-user-modal" data-modal-modal-blockee="{{.item.Poster.Name}}" data-modal-modal-blockee-name="{{.item.Poster.GetDisplayName}}" data-modal-modal-form.action="{{ctx.RootData.Repository.Owner.OrganisationLink}}/settings/blocked_users">{{ctx.Locale.Tr "user.block.block.org"}}</div>
                                {{end}}
                        {{end}}
                {{end}}
index fd657fb66a975b8e755365c1a6ae42a636d2c966..14803298b83cb14f94c9d73d897d9533e260ae6d 100644 (file)
@@ -83,7 +83,7 @@
                                                                        {{template "repo/issue/view_content/show_role" dict "ShowRole" .ShowRole}}
                                                                        {{if not $.Repository.IsArchived}}
                                                                                {{template "repo/issue/view_content/add_reaction" dict "ActionURL" (printf "%s/comments/%d/reactions" $.RepoLink .ID)}}
-                                                                               {{template "repo/issue/view_content/context_menu" dict "ctxData" $ "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
+                                                                               {{template "repo/issue/view_content/context_menu" dict "item" . "delete" true "issue" true "diff" true "IsCommentPoster" (and $.IsSigned (eq $.SignedUserID .PosterID))}}
                                                                        {{end}}
                                                                </div>
                                                        </div>
index af97ee4eab140d63b1875e8f543870665b74c366..9d146951bd42fbe50b0e40db77f5557693b59947 100644 (file)
@@ -1,4 +1,3 @@
-import $ from 'jquery';
 import {handleReply} from './repo-issue.ts';
 import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
 import {POST} from '../modules/fetch.ts';
@@ -7,11 +6,14 @@ import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
 import {attachRefIssueContextPopup} from './contextpopup.ts';
 import {initCommentContent, initMarkupContent} from '../markup/content.ts';
 import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
+import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
 
-async function onEditContent(event) {
-  event.preventDefault();
+async function tryOnEditContent(e) {
+  const clickTarget = e.target.closest('.edit-content');
+  if (!clickTarget) return;
 
-  const segment = this.closest('.header').nextElementSibling;
+  e.preventDefault();
+  const segment = clickTarget.closest('.header').nextElementSibling;
   const editContentZone = segment.querySelector('.edit-content-zone');
   const renderContent = segment.querySelector('.render-content');
   const rawContent = segment.querySelector('.raw-content');
@@ -102,33 +104,53 @@ async function onEditContent(event) {
   triggerUploadStateChanged(comboMarkdownEditor.container);
 }
 
+function extractSelectedMarkdown(container: HTMLElement) {
+  const selection = window.getSelection();
+  if (!selection.rangeCount) return '';
+  const range = selection.getRangeAt(0);
+  if (!container.contains(range.commonAncestorContainer)) return '';
+
+  // todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content
+  // otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes
+  const contents = selection.getRangeAt(0).cloneContents();
+  const el = document.createElement('div');
+  el.append(contents);
+  return convertHtmlToMarkdown(el);
+}
+
+async function tryOnQuoteReply(e) {
+  const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
+  if (!clickTarget) return;
+
+  e.preventDefault();
+  const contentToQuoteId = clickTarget.getAttribute('data-target');
+  const targetRawToQuote = document.querySelector<HTMLElement>(`#${contentToQuoteId}.raw-content`);
+  const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup');
+  let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
+  if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
+  const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
+
+  let editor;
+  if (clickTarget.classList.contains('quote-reply-diff')) {
+    const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
+    editor = await handleReply(replyBtn);
+  } else {
+    // for normal issue/comment page
+    editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor'));
+  }
+
+  if (editor.value()) {
+    editor.value(`${editor.value()}\n\n${quotedContent}`);
+  } else {
+    editor.value(quotedContent);
+  }
+  editor.focus();
+  editor.moveCursorToEnd();
+}
+
 export function initRepoIssueCommentEdit() {
-  // Edit issue or comment content
-  $(document).on('click', '.edit-content', onEditContent);
-
-  // Quote reply
-  $(document).on('click', '.quote-reply', async function (event) {
-    event.preventDefault();
-    const target = this.getAttribute('data-target');
-    const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> ');
-    const content = `> ${quote}\n\n`;
-
-    let editor;
-    if (this.classList.contains('quote-reply-diff')) {
-      const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
-      editor = await handleReply(replyBtn);
-    } else {
-      // for normal issue/comment page
-      editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
-    }
-    if (editor) {
-      if (editor.value()) {
-        editor.value(`${editor.value()}\n\n${content}`);
-      } else {
-        editor.value(content);
-      }
-      editor.focus();
-      editor.moveCursorToEnd();
-    }
+  document.addEventListener('click', (e) => {
+    tryOnEditContent(e); // Edit issue or comment content
+    tryOnQuoteReply(e); // Quote reply to the comment editor
   });
 }
diff --git a/web_src/js/markup/html2markdown.test.ts b/web_src/js/markup/html2markdown.test.ts
new file mode 100644 (file)
index 0000000..99a6395
--- /dev/null
@@ -0,0 +1,24 @@
+import {convertHtmlToMarkdown} from './html2markdown.ts';
+import {createElementFromHTML} from '../utils/dom.ts';
+
+const h = createElementFromHTML;
+
+test('convertHtmlToMarkdown', () => {
+  expect(convertHtmlToMarkdown(h(`<h1>h</h1>`))).toBe('# h');
+  expect(convertHtmlToMarkdown(h(`<strong>txt</strong>`))).toBe('**txt**');
+  expect(convertHtmlToMarkdown(h(`<em>txt</em>`))).toBe('_txt_');
+  expect(convertHtmlToMarkdown(h(`<del>txt</del>`))).toBe('~~txt~~');
+
+  expect(convertHtmlToMarkdown(h(`<a href="link">txt</a>`))).toBe('[txt](link)');
+  expect(convertHtmlToMarkdown(h(`<a href="https://link">https://link</a>`))).toBe('https://link');
+
+  expect(convertHtmlToMarkdown(h(`<img src="link">`))).toBe('![image](link)');
+  expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe('![name](link)');
+  expect(convertHtmlToMarkdown(h(`<img src="link" width="1" height="1">`))).toBe('<img alt="image" width="1" height="1" src="link">');
+
+  expect(convertHtmlToMarkdown(h(`<p>txt</p>`))).toBe('txt\n');
+  expect(convertHtmlToMarkdown(h(`<blockquote>a\nb</blockquote>`))).toBe('> a\n> b\n');
+
+  expect(convertHtmlToMarkdown(h(`<ol><li>a<ul><li>b</li></ul></li></ol>`))).toBe('1. a\n    * b\n\n');
+  expect(convertHtmlToMarkdown(h(`<ol><li><input checked>a</li></ol>`))).toBe('1. [x] a\n');
+});
diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts
new file mode 100644 (file)
index 0000000..c690e0c
--- /dev/null
@@ -0,0 +1,119 @@
+import {htmlEscape} from 'escape-goat';
+
+type Processors = {
+  [tagName: string]: (el: HTMLElement) => string | HTMLElement | void;
+}
+
+type ProcessorContext = {
+  elementIsFirst: boolean;
+  elementIsLast: boolean;
+  listNestingLevel: number;
+}
+
+function prepareProcessors(ctx:ProcessorContext): Processors {
+  const processors = {
+    H1(el) {
+      const level = parseInt(el.tagName.slice(1));
+      el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
+    },
+    STRONG(el) {
+      return `**${el.textContent}**`;
+    },
+    EM(el) {
+      return `_${el.textContent}_`;
+    },
+    DEL(el) {
+      return `~~${el.textContent}~~`;
+    },
+
+    A(el) {
+      const text = el.textContent || 'link';
+      const href = el.getAttribute('href');
+      if (/^https?:/.test(text) && text === href) {
+        return text;
+      }
+      return href ? `[${text}](${href})` : text;
+    },
+    IMG(el) {
+      const alt = el.getAttribute('alt') || 'image';
+      const src = el.getAttribute('src');
+      const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
+      const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
+      if (widthAttr || heightAttr) {
+        return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
+      }
+      return `![${alt}](${src})`;
+    },
+
+    P(el) {
+      el.textContent = `${el.textContent}\n`;
+    },
+    BLOCKQUOTE(el) {
+      el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`;
+    },
+
+    OL(el) {
+      const preNewLine = ctx.listNestingLevel ? '\n' : '';
+      el.textContent = `${preNewLine}${el.textContent}\n`;
+    },
+    LI(el) {
+      const parent = el.parentNode;
+      const bullet = parent.tagName === 'OL' ? `1. ` : '* ';
+      const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1);
+      el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
+      return el;
+    },
+    INPUT(el) {
+      return el.checked ? '[x] ' : '[ ] ';
+    },
+
+    CODE(el) {
+      const text = el.textContent;
+      if (el.parentNode && el.parentNode.tagName === 'PRE') {
+        el.textContent = `\`\`\`\n${text}\n\`\`\`\n`;
+        return el;
+      }
+      if (text.includes('`')) {
+        return `\`\` ${text} \`\``;
+      }
+      return `\`${text}\``;
+    },
+  };
+  processors['UL'] = processors.OL;
+  for (let level = 2; level <= 6; level++) {
+    processors[`H${level}`] = processors.H1;
+  }
+  return processors;
+}
+
+function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) {
+  if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
+  if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
+    return processElement(ctx, processors, el.children[0] as HTMLElement);
+  }
+
+  const isListContainer = el.tagName === 'OL' || el.tagName === 'UL';
+  if (isListContainer) ctx.listNestingLevel++;
+  for (let i = 0; i < el.children.length; i++) {
+    ctx.elementIsFirst = i === 0;
+    ctx.elementIsLast = i === el.children.length - 1;
+    processElement(ctx, processors, el.children[i] as HTMLElement);
+  }
+  if (isListContainer) ctx.listNestingLevel--;
+
+  if (processors[el.tagName]) {
+    const ret = processors[el.tagName](el);
+    if (ret && ret !== el) {
+      el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret);
+    }
+  }
+}
+
+export function convertHtmlToMarkdown(el: HTMLElement): string {
+  const div = document.createElement('div');
+  div.append(el);
+  const ctx = {} as ProcessorContext;
+  ctx.listNestingLevel = 0;
+  processElement(ctx, prepareProcessors(ctx), el);
+  return div.textContent;
+}