diff options
author | wxiaoguang <wxiaoguang@gmail.com> | 2024-11-07 11:57:07 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-11-07 03:57:07 +0000 |
commit | 145e26698791221b007c7dd460fb506cb0237235 (patch) | |
tree | ceeff90fbc2a1e97d906daa9a8023b399c04dcf8 | |
parent | 276500c314db1c0ef360088753861ffc010a99da (diff) | |
download | gitea-145e26698791221b007c7dd460fb506cb0237235.tar.gz gitea-145e26698791221b007c7dd460fb506cb0237235.zip |
Support quote selected comments to reply (#32431)
Many existing tests were quite hacky, these could be improved later.
<details>

</details>
-rw-r--r-- | modules/markup/html.go | 5 | ||||
-rw-r--r-- | modules/markup/html_codepreview_test.go | 2 | ||||
-rw-r--r-- | modules/markup/html_internal_test.go | 12 | ||||
-rw-r--r-- | modules/markup/html_test.go | 16 | ||||
-rw-r--r-- | modules/markup/markdown/markdown_test.go | 12 | ||||
-rw-r--r-- | modules/markup/sanitizer_default.go | 1 | ||||
-rw-r--r-- | modules/templates/util_render_test.go | 16 | ||||
-rw-r--r-- | routers/api/v1/misc/markup_test.go | 6 | ||||
-rw-r--r-- | templates/repo/diff/comments.tmpl | 2 | ||||
-rw-r--r-- | templates/repo/issue/view_content.tmpl | 2 | ||||
-rw-r--r-- | templates/repo/issue/view_content/comments.tmpl | 4 | ||||
-rw-r--r-- | templates/repo/issue/view_content/context_menu.tmpl | 18 | ||||
-rw-r--r-- | templates/repo/issue/view_content/conversation.tmpl | 2 | ||||
-rw-r--r-- | web_src/js/features/repo-issue-edit.ts | 84 | ||||
-rw-r--r-- | web_src/js/markup/html2markdown.test.ts | 24 | ||||
-rw-r--r-- | web_src/js/markup/html2markdown.ts | 119 |
16 files changed, 255 insertions, 70 deletions
diff --git a/modules/markup/html.go b/modules/markup/html.go index a9c3dc9ba2..e2eefefc4b 100644 --- a/modules/markup/html.go +++ b/modules/markup/html.go @@ -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 != "" { diff --git a/modules/markup/html_codepreview_test.go b/modules/markup/html_codepreview_test.go index d33630d040..a90de278f5 100644 --- a/modules/markup/html_codepreview_test.go +++ b/modules/markup/html_codepreview_test.go @@ -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>`) } diff --git a/modules/markup/html_internal_test.go b/modules/markup/html_internal_test.go index 74089cffdd..8f516751b0 100644 --- a/modules/markup/html_internal_test.go +++ b/modules/markup/html_internal_test.go @@ -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") diff --git a/modules/markup/html_test.go b/modules/markup/html_test.go index 32858dbd6b..82aded4407 100644 --- a/modules/markup/html_test.go +++ b/modules/markup/html_test.go @@ -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. diff --git a/modules/markup/markdown/markdown_test.go b/modules/markup/markdown/markdown_test.go index cfb821ab19..ad38e7a088 100644 --- a/modules/markup/markdown/markdown_test.go +++ b/modules/markup/markdown/markdown_test.go @@ -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) } } diff --git a/modules/markup/sanitizer_default.go b/modules/markup/sanitizer_default.go index 669dc24eae..476ae5e26f 100644 --- a/modules/markup/sanitizer_default.go +++ b/modules/markup/sanitizer_default.go @@ -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{ diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go index 3e4ea04c63..0a6e89c5f2 100644 --- a/modules/templates/util_render_test.go +++ b/modules/templates/util_render_test.go @@ -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))) } diff --git a/routers/api/v1/misc/markup_test.go b/routers/api/v1/misc/markup_test.go index e2ab7141b7..abffdf3516 100644 --- a/routers/api/v1/misc/markup_test.go +++ b/routers/api/v1/misc/markup_test.go @@ -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() } diff --git a/templates/repo/diff/comments.tmpl b/templates/repo/diff/comments.tmpl index 0ed231b07e..2d716688b9 100644 --- a/templates/repo/diff/comments.tmpl +++ b/templates/repo/diff/comments.tmpl @@ -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"> diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index 74440c5ef2..1f9bbd86aa 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -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"> diff --git a/templates/repo/issue/view_content/comments.tmpl b/templates/repo/issue/view_content/comments.tmpl index 3047533154..477b6b33c6 100644 --- a/templates/repo/issue/view_content/comments.tmpl +++ b/templates/repo/issue/view_content/comments.tmpl @@ -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"> @@ -430,7 +430,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" 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> diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 9e38442c36..749a2fa0dd 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -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}} diff --git a/templates/repo/issue/view_content/conversation.tmpl b/templates/repo/issue/view_content/conversation.tmpl index fd657fb66a..14803298b8 100644 --- a/templates/repo/issue/view_content/conversation.tmpl +++ b/templates/repo/issue/view_content/conversation.tmpl @@ -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> diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index af97ee4eab..9d146951bd 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -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 index 0000000000..99a63956a0 --- /dev/null +++ b/web_src/js/markup/html2markdown.test.ts @@ -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(''); + expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe(''); + 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 index 0000000000..c690e0c8b1 --- /dev/null +++ b/web_src/js/markup/html2markdown.ts @@ -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 ``; + }, + + 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; +} |