diff --git a/package-lock.json b/package-lock.json
index 9d0c83f656..b9d998a69d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
         "@citation-js/plugin-csl": "0.6.7",
         "@citation-js/plugin-software-formats": "0.6.1",
         "@claviska/jquery-minicolors": "2.3.6",
+        "@github/markdown-toolbar-element": "2.1.1",
         "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
         "@primer/octicons": "18.3.0",
         "@vue/compiler-sfc": "3.2.47",
@@ -838,6 +839,11 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@github/markdown-toolbar-element": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.1.1.tgz",
+      "integrity": "sha512-J++rpd5H9baztabJQB82h26jtueOeBRSTqetk9Cri+Lj/s28ndu6Tovn0uHQaOKtBWDobFunk9b5pP5vcqt7cA=="
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.8",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",
diff --git a/package.json b/package.json
index 8ac5c312f6..3ccf0c0840 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
     "@citation-js/plugin-csl": "0.6.7",
     "@citation-js/plugin-software-formats": "0.6.1",
     "@claviska/jquery-minicolors": "2.3.6",
+    "@github/markdown-toolbar-element": "2.1.1",
     "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
     "@primer/octicons": "18.3.0",
     "@vue/compiler-sfc": "3.2.47",
diff --git a/routers/web/devtest/devtest.go b/routers/web/devtest/devtest.go
new file mode 100644
index 0000000000..eb77d0b927
--- /dev/null
+++ b/routers/web/devtest/devtest.go
@@ -0,0 +1,35 @@
+// Copyright 2023 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package devtest
+
+import (
+	"net/http"
+	"path"
+	"strings"
+
+	"code.gitea.io/gitea/modules/base"
+	"code.gitea.io/gitea/modules/context"
+	"code.gitea.io/gitea/modules/templates"
+)
+
+// List all devtest templates, they will be used for e2e tests for the UI components
+func List(ctx *context.Context) {
+	templateNames := templates.GetTemplateAssetNames()
+	var subNames []string
+	const prefix = "templates/devtest/"
+	for _, tmplName := range templateNames {
+		if strings.HasPrefix(tmplName, prefix) {
+			subName := strings.TrimSuffix(strings.TrimPrefix(tmplName, prefix), ".tmpl")
+			if subName != "list" {
+				subNames = append(subNames, subName)
+			}
+		}
+	}
+	ctx.Data["SubNames"] = subNames
+	ctx.HTML(http.StatusOK, "devtest/list")
+}
+
+func Tmpl(ctx *context.Context) {
+	ctx.HTML(http.StatusOK, base.TplName("devtest"+path.Clean("/"+ctx.Params("sub"))))
+}
diff --git a/routers/web/misc/markup.go b/routers/web/misc/markup.go
index f678316f44..1690378945 100644
--- a/routers/web/misc/markup.go
+++ b/routers/web/misc/markup.go
@@ -15,24 +15,6 @@ import (
 
 // Markup render markup document to HTML
 func Markup(ctx *context.Context) {
-	// swagger:operation POST /markup miscellaneous renderMarkup
-	// ---
-	// summary: Render a markup document as HTML
-	// parameters:
-	// - name: body
-	//   in: body
-	//   schema:
-	//     "$ref": "#/definitions/MarkupOption"
-	// consumes:
-	// - application/json
-	// produces:
-	//     - text/html
-	// responses:
-	//   "200":
-	//     "$ref": "#/responses/MarkupRender"
-	//   "422":
-	//     "$ref": "#/responses/validationError"
-
 	form := web.GetForm(ctx).(*api.MarkupOption)
 
 	if ctx.HasAPIError() {
diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go
index 654e9000fa..7d84c101d8 100644
--- a/routers/web/org/setting.go
+++ b/routers/web/org/setting.go
@@ -246,7 +246,6 @@ func Labels(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.labels")
 	ctx.Data["PageIsOrgSettings"] = true
 	ctx.Data["PageIsOrgSettingsLabels"] = true
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["LabelTemplates"] = repo_module.LabelTemplates
 	ctx.HTML(http.StatusOK, tplSettingsLabels)
 }
diff --git a/routers/web/repo/commit.go b/routers/web/repo/commit.go
index 843b1d8dfd..7439c2411b 100644
--- a/routers/web/repo/commit.go
+++ b/routers/web/repo/commit.go
@@ -253,7 +253,6 @@ func FileHistory(ctx *context.Context) {
 // Diff show different from current commit to previous commit
 func Diff(ctx *context.Context) {
 	ctx.Data["PageIsDiff"] = true
-	ctx.Data["RequireTribute"] = true
 
 	userName := ctx.Repo.Owner.Name
 	repoName := ctx.Repo.Repository.Name
diff --git a/routers/web/repo/compare.go b/routers/web/repo/compare.go
index d7e7bac7b7..c49eb762d8 100644
--- a/routers/web/repo/compare.go
+++ b/routers/web/repo/compare.go
@@ -781,7 +781,6 @@ func CompareDiff(ctx *context.Context) {
 
 	ctx.Data["IsRepoToolbarCommits"] = true
 	ctx.Data["IsDiffCompare"] = true
-	ctx.Data["RequireTribute"] = true
 	templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
 
 	if len(templateErrs) > 0 {
diff --git a/routers/web/repo/editor.go b/routers/web/repo/editor.go
index 2b66be22ae..f65e1ad3d8 100644
--- a/routers/web/repo/editor.go
+++ b/routers/web/repo/editor.go
@@ -538,7 +538,6 @@ func DeleteFilePost(ctx *context.Context) {
 // UploadFile render upload file page
 func UploadFile(ctx *context.Context) {
 	ctx.Data["PageIsUpload"] = true
-	ctx.Data["RequireTribute"] = true
 	upload.AddUploadContext(ctx, "repo")
 	canCommit := renderCommitRights(ctx)
 	treePath := cleanUploadFileName(ctx.Repo.TreePath)
@@ -573,7 +572,6 @@ func UploadFile(ctx *context.Context) {
 func UploadFilePost(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.UploadRepoFileForm)
 	ctx.Data["PageIsUpload"] = true
-	ctx.Data["RequireTribute"] = true
 	upload.AddUploadContext(ctx, "repo")
 	canCommit := renderCommitRights(ctx)
 
diff --git a/routers/web/repo/issue.go b/routers/web/repo/issue.go
index 612222598f..e4f1172dd9 100644
--- a/routers/web/repo/issue.go
+++ b/routers/web/repo/issue.go
@@ -849,7 +849,6 @@ func NewIssue(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.issues.new")
 	ctx.Data["PageIsIssueList"] = true
 	ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
 	title := ctx.FormString("title")
 	ctx.Data["TitleQuery"] = title
@@ -1295,7 +1294,6 @@ func ViewIssue(ctx *context.Context) {
 		ctx.Data["IssueType"] = "all"
 	}
 
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects)
 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
 	upload.AddUploadContext(ctx, "comment")
diff --git a/routers/web/repo/issue_label.go b/routers/web/repo/issue_label.go
index 31bf85fedb..3123359a65 100644
--- a/routers/web/repo/issue_label.go
+++ b/routers/web/repo/issue_label.go
@@ -28,7 +28,6 @@ func Labels(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.labels")
 	ctx.Data["PageIsIssueList"] = true
 	ctx.Data["PageIsLabels"] = true
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["LabelTemplates"] = repo_module.LabelTemplates
 	ctx.HTML(http.StatusOK, tplLabels)
 }
diff --git a/routers/web/repo/pull.go b/routers/web/repo/pull.go
index 4f99687738..c37d52640f 100644
--- a/routers/web/repo/pull.go
+++ b/routers/web/repo/pull.go
@@ -791,7 +791,6 @@ func ViewPullFiles(ctx *context.Context) {
 
 	setCompareContext(ctx, baseCommit, commit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name)
 
-	ctx.Data["RequireTribute"] = true
 	if ctx.Data["Assignees"], err = repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository); err != nil {
 		ctx.ServerError("GetAssignees", err)
 		return
@@ -1160,7 +1159,6 @@ func CompareAndPullRequestPost(ctx *context.Context) {
 	ctx.Data["PageIsComparePull"] = true
 	ctx.Data["IsDiffCompare"] = true
 	ctx.Data["IsRepoToolbarCommits"] = true
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
 	upload.AddUploadContext(ctx, "comment")
diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go
index 3ffadd34ac..b8c5f67f45 100644
--- a/routers/web/repo/release.go
+++ b/routers/web/repo/release.go
@@ -308,7 +308,6 @@ func LatestRelease(ctx *context.Context) {
 func NewRelease(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
 	ctx.Data["PageIsReleaseList"] = true
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch
 	if tagName := ctx.FormString("tag"); len(tagName) > 0 {
 		rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName)
@@ -351,7 +350,6 @@ func NewReleasePost(ctx *context.Context) {
 	form := web.GetForm(ctx).(*forms.NewReleaseForm)
 	ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
 	ctx.Data["PageIsReleaseList"] = true
-	ctx.Data["RequireTribute"] = true
 
 	if ctx.HasError() {
 		ctx.HTML(http.StatusOK, tplReleaseNew)
@@ -469,7 +467,6 @@ func EditRelease(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
 	ctx.Data["PageIsReleaseList"] = true
 	ctx.Data["PageIsEditRelease"] = true
-	ctx.Data["RequireTribute"] = true
 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
 	upload.AddUploadContext(ctx, "release")
 
@@ -514,7 +511,6 @@ func EditReleasePost(ctx *context.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
 	ctx.Data["PageIsReleaseList"] = true
 	ctx.Data["PageIsEditRelease"] = true
-	ctx.Data["RequireTribute"] = true
 
 	tagName := ctx.Params("*")
 	rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName)
diff --git a/routers/web/web.go b/routers/web/web.go
index 4bd2f76c57..6b62ff6f83 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -27,6 +27,7 @@ import (
 	"code.gitea.io/gitea/modules/web/routing"
 	"code.gitea.io/gitea/routers/web/admin"
 	"code.gitea.io/gitea/routers/web/auth"
+	"code.gitea.io/gitea/routers/web/devtest"
 	"code.gitea.io/gitea/routers/web/events"
 	"code.gitea.io/gitea/routers/web/explore"
 	"code.gitea.io/gitea/routers/web/feed"
@@ -1491,6 +1492,12 @@ func RegisterRoutes(m *web.Route) {
 	if setting.API.EnableSwagger {
 		m.Get("/swagger.v1.json", SwaggerV1Json)
 	}
+
+	if !setting.IsProd {
+		m.Any("/devtest", devtest.List)
+		m.Any("/devtest/{sub}", devtest.Tmpl)
+	}
+
 	m.NotFound(func(w http.ResponseWriter, req *http.Request) {
 		ctx := context.GetContext(req)
 		ctx.NotFound("", nil)
diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl
index 62fb10d89f..670d146b56 100644
--- a/templates/base/head_script.tmpl
+++ b/templates/base/head_script.tmpl
@@ -15,23 +15,19 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
 		useServiceWorker: {{UseServiceWorker}},
 		csrfToken: '{{.CsrfToken}}',
 		pageData: {{.PageData}},
-		requireTribute: {{.RequireTribute}},
 		notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
 		enableTimeTracking: {{EnableTimetracking}},
-		{{if .RequireTribute}}
+		{{if or .Participants .Assignees .MentionableTeams}}
 		tributeValues: Array.from(new Map([
-			{{range .Participants}}
-			['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
-			name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
-			{{end}}
-			{{range .Assignees}}
-			['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}',
-			name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
-			{{end}}
-			{{range .MentionableTeams}}
-				['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}',
-				name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
-			{{end}}
+			{{- range .Participants -}}
+				['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
+			{{- end -}}
+			{{- range .Assignees -}}
+				['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
+			{{- end -}}
+			{{- range .MentionableTeams -}}
+				['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}', name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
+			{{- end -}}
 		]).values()),
 		{{end}}
 		mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},
diff --git a/templates/devtest/gitea-ui.tmpl b/templates/devtest/gitea-ui.tmpl
new file mode 100644
index 0000000000..c5ab863d00
--- /dev/null
+++ b/templates/devtest/gitea-ui.tmpl
@@ -0,0 +1,12 @@
+{{template "base/head" .}}
+<div class="page-content devtest">
+	<div>
+		<gitea-origin-url data-url="test/url"></gitea-origin-url>
+		<gitea-origin-url data-url="/test/url"></gitea-origin-url>
+	</div>
+	<div>
+		<span data-tooltip-content="test tooltip">text with tooltip</span>
+	</div>
+	{{template "shared/combomarkdowneditor" .}}
+</div>
+{{template "base/footer" .}}
diff --git a/templates/devtest/list.tmpl b/templates/devtest/list.tmpl
new file mode 100644
index 0000000000..3a519c328e
--- /dev/null
+++ b/templates/devtest/list.tmpl
@@ -0,0 +1,5 @@
+<ul>
+	{{range .SubNames}}
+	<li><a href="{{AppSubUrl}}/devtest/{{.}}">{{.}}</a></li>
+	{{end}}
+</ul>
diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl
index 36e669276e..21ea63cc0a 100644
--- a/templates/repo/diff/box.tmpl
+++ b/templates/repo/diff/box.tmpl
@@ -198,24 +198,21 @@
 		</div>
 
 		{{if not $.Repository.IsArchived}}
-			<div class="gt-hidden" id="edit-content-form">
+			<template id="issue-comment-editor-template">
 				<div class="ui comment form">
-					<div class="ui top attached tabular menu">
-						<a class="active write item">{{$.locale.Tr "write"}}</a>
-						<a class="preview item" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a>
-					</div>
-					<div class="ui bottom attached active write tab segment">
-						<textarea class="review-textarea js-quick-submit" tabindex="1" name="content"></textarea>
-					</div>
-					<div class="ui bottom attached tab preview segment markup">
-					{{$.locale.Tr "loading"}}
-					</div>
+					{{template "shared/combomarkdowneditor" (dict
+						"locale" $.locale
+						"MarkdownPreviewUrl" (print $.Repository.Link "/markup")
+						"MarkdownPreviewContext" $.RepoLink
+						"TextareaName" "content"
+						"DropzoneParentContainer" ".ui.form"
+					)}}
 					<div class="text right edit buttons">
 						<button class="ui basic primary cancel button" tabindex="3">{{.locale.Tr "repo.issues.cancel"}}</button>
 						<button class="ui green save button" tabindex="2">{{.locale.Tr "repo.issues.save"}}</button>
 					</div>
 				</div>
-			</div>
+			</template>
 		{{end}}
 
 		{{template "repo/issue/view_content/reference_issue_dialog" .}}
diff --git a/templates/repo/diff/comment_form.tmpl b/templates/repo/diff/comment_form.tmpl
index 394a392bb9..109f167967 100644
--- a/templates/repo/diff/comment_form.tmpl
+++ b/templates/repo/diff/comment_form.tmpl
@@ -9,18 +9,16 @@
 		<input type="hidden" name="diff_start_cid">
 		<input type="hidden" name="diff_end_cid">
 		<input type="hidden" name="diff_base_cid">
-		<div class="ui top tabular menu" data-write="write" data-preview="preview">
-			<a class="active item" data-tab="write">{{$.root.locale.Tr "write"}}</a>
-			<a class="item" data-tab="preview" data-url="{{$.root.Repository.Link}}/markup" data-context="{{$.root.RepoLink}}">{{$.root.locale.Tr "preview"}}</a>
-		</div>
-		<div class="field">
-			<div class="ui active tab" data-tab="write">
-				<textarea name="content" placeholder="{{$.root.locale.Tr "repo.diff.comment.placeholder"}}"></textarea>
-			</div>
-			<div class="ui tab markup" data-tab="preview">
-			{{.locale.Tr "loading"}}
-			</div>
-		</div>
+
+		{{template "shared/combomarkdowneditor" (dict
+			"locale" $.root.locale
+			"MarkdownPreviewUrl" (print $.root.Repository.Link "/markup")
+			"MarkdownPreviewContext" $.root.RepoLink
+			"TextareaName" "content"
+			"TextareaPlaceholder" ($.locale.Tr "repo.diff.comment.placeholder")
+			"DropzoneParentContainer" "form"
+		)}}
+
 		<div class="field footer gt-mx-3">
 			<span class="markup-info">{{svg "octicon-markup"}} {{$.root.locale.Tr "repo.diff.comment.markdown_info"}}</span>
 			<div class="ui right">
diff --git a/templates/repo/diff/new_review.tmpl b/templates/repo/diff/new_review.tmpl
index 9d2208e289..bb97303034 100644
--- a/templates/repo/diff/new_review.tmpl
+++ b/templates/repo/diff/new_review.tmpl
@@ -7,14 +7,19 @@
 	<div class="review-box-panel tippy-target">
 		<div class="ui segment">
 			<form class="ui form" action="{{.Link}}/reviews/submit" method="post">
-			{{.CsrfTokenHtml}}
+				{{.CsrfTokenHtml}}
 				<input type="hidden" name="commit_id" value="{{.AfterCommitID}}">
 				<div class="header gt-df gt-ac gt-pb-3">
 					<div class="gt-f1">{{$.locale.Tr "repo.diff.review.header"}}</div>
 					<a class="muted close gt-px-3">{{svg "octicon-x" 16}}</a>
 				</div>
 				<div class="ui field">
-					<textarea name="content" tabindex="0" rows="2" placeholder="{{$.locale.Tr "repo.diff.review.placeholder"}}"></textarea>
+					{{template "shared/combomarkdowneditor" (dict
+						"locale" $.locale
+						"TextareaName" "content"
+						"TextareaPlaceholder" ($.locale.Tr "repo.diff.review.placeholder")
+						"DropzoneParentContainer" "form"
+					)}}
 				</div>
 				{{if .IsAttachmentEnabled}}
 					<div class="field">
diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl
index 47d6ca9587..2212d99a10 100644
--- a/templates/repo/issue/comment_tab.tmpl
+++ b/templates/repo/issue/comment_tab.tmpl
@@ -1,17 +1,17 @@
-	<div class="ui top tabular menu" data-write="write" data-preview="preview">
-		<a class="active item" data-tab="write">{{.locale.Tr "write"}}</a>
-		<a class="item" data-tab="preview" data-url="{{.Repository.Link}}/markup" data-context="{{.RepoLink}}">{{.locale.Tr "preview"}}</a>
-	</div>
-	<div class="field">
-		<div class="ui bottom active tab" data-tab="write">
-		<textarea id="content" class="edit_area js-quick-submit" name="content" tabindex="4" data-id="issue-{{.RepoName}}" data-url="{{.Repository.Link}}/markup" data-context="{{.Repo.RepoLink}}">
-			{{- if .BodyQuery}}{{.BodyQuery}}{{else if .IssueTemplate}}{{.IssueTemplate}}{{else if .PullRequestTemplate}}{{.PullRequestTemplate}}{{else}}{{.content}}{{end -}}
-		</textarea>
-		</div>
-		<div class="ui bottom tab markup" data-tab="preview">
-			{{.locale.Tr "loading"}}
-		</div>
-	</div>
+{{$textareaContent := .BodyQuery}}
+{{if not $textareaContent}}{{$textareaContent = .IssueTemplate}}{{end}}
+{{if not $textareaContent}}{{$textareaContent = .PullRequestTemplate}}{{end}}
+{{if not $textareaContent}}{{$textareaContent = .content}}{{end}}
+
+{{template "shared/combomarkdowneditor" (dict
+	"locale" $.locale
+	"MarkdownPreviewUrl" (print .Repository.Link "/markup")
+	"MarkdownPreviewContext" .RepoLink
+	"TextareaName" "content"
+	"TextareaContent" $textareaContent
+	"DropzoneParentContainer" "form, .ui.form"
+)}}
+
 {{if .IsAttachmentEnabled}}
 	<div class="field">
 		{{template "repo/upload" .}}
diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl
index 4b390fc9d6..ad3c5efa04 100644
--- a/templates/repo/issue/fields/textarea.tmpl
+++ b/templates/repo/issue/fields/textarea.tmpl
@@ -2,5 +2,5 @@
 	{{template "repo/issue/fields/header" .}}
 	{{/* FIXME: preview markdown result */}}
 	{{/* FIXME: required validation for markdown editor */}}
-	<textarea name="form-field-{{.item.ID}}" placeholder="{{.item.Attributes.placeholder}}" class="edit_area {{if .item.Attributes.render}}no-easymde{{end}}" {{if and .item.Validations.required .item.Attributes.render}}required{{end}}>{{.item.Attributes.value}}</textarea>
+	<textarea name="form-field-{{.item.ID}}" placeholder="{{.item.Attributes.placeholder}}" {{if and .item.Validations.required .item.Attributes.render}}required{{end}}>{{.item.Attributes.value}}</textarea>
 </div>
diff --git a/templates/repo/issue/labels/edit_delete_label.tmpl b/templates/repo/issue/labels/edit_delete_label.tmpl
index 568195880d..52e586b783 100644
--- a/templates/repo/issue/labels/edit_delete_label.tmpl
+++ b/templates/repo/issue/labels/edit_delete_label.tmpl
@@ -20,7 +20,7 @@
 			<div class="required field">
 				<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
 				<div class="ui small input">
-					<input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
+					<input class="label-name-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
 				</div>
 			</div>
 			<div class="field label-exclusive-input-field">
diff --git a/templates/repo/issue/labels/label_new.tmpl b/templates/repo/issue/labels/label_new.tmpl
index 0286f8f228..65b1cbe886 100644
--- a/templates/repo/issue/labels/label_new.tmpl
+++ b/templates/repo/issue/labels/label_new.tmpl
@@ -8,7 +8,7 @@
 			<div class="required field">
 				<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
 				<div class="ui small input">
-					<input class="label-name-input emoji-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
+					<input class="label-name-input" name="title" placeholder="{{.locale.Tr "repo.issues.new_label_placeholder"}}" autofocus required maxlength="50">
 				</div>
 			</div>
 			<div class="field label-exclusive-input-field">
diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl
index 99337c531f..2781db4329 100644
--- a/templates/repo/issue/view_content.tmpl
+++ b/templates/repo/issue/view_content.tmpl
@@ -164,25 +164,22 @@
 	{{template "repo/issue/view_content/sidebar" .}}
 </div>
 
-<div class="gt-hidden" id="edit-content-form">
+<template id="issue-comment-editor-template">
 	<div class="ui comment form">
-		<div class="ui top tabular menu">
-			<a class="active write item">{{$.locale.Tr "write"}}</a>
-			<a class="preview item" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a>
-		</div>
-		<div class="field">
-			<div class="ui bottom active tab write">
-				<textarea tabindex="1" name="content" class="js-quick-submit"></textarea>
-			</div>
-			<div class="ui bottom tab preview markup">
-				{{$.locale.Tr "loading"}}
-			</div>
-		</div>
+		{{template "shared/combomarkdowneditor" (dict
+			"locale" $.locale
+			"MarkdownPreviewUrl" (print .Repository.Link "/markup")
+			"MarkdownPreviewContext" .RepoLink
+			"TextareaName" "content"
+			"DropzoneParentContainer" ".ui.form"
+		)}}
+
 		{{if .IsAttachmentEnabled}}
 			<div class="field">
 				{{template "repo/upload" .}}
 			</div>
 		{{end}}
+
 		<div class="field footer">
 			<div class="text right edit">
 				<button class="ui basic secondary cancel button" tabindex="3">{{.locale.Tr "repo.issues.cancel"}}</button>
@@ -190,7 +187,7 @@
 			</div>
 		</div>
 	</div>
-</div>
+</template>
 
 {{template "repo/issue/view_content/reference_issue_dialog" .}}
 
diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl
index 1cd37d2dd3..589fe12cea 100644
--- a/templates/repo/release/new.tmpl
+++ b/templates/repo/release/new.tmpl
@@ -49,18 +49,17 @@
 					<label>{{.locale.Tr "repo.release.title"}}</label>
 					<input name="title" placeholder="{{.locale.Tr "repo.release.title"}}" value="{{.title}}" autofocus required maxlength="255">
 				</div>
-				<div class="field content-editor">
+				<div class="field">
 					<label>{{.locale.Tr "repo.release.content"}}</label>
-					<div class="ui top tabular menu" data-write="write" data-preview="preview">
-						<a class="active write item" data-tab="write">{{$.locale.Tr "write"}}</a>
-						<a class="preview item" data-tab="preview" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a>
-					</div>
-					<div class="ui bottom active tab" data-tab="write">
-						<textarea name="content">{{.content}}</textarea>
-					</div>
-					<div class="ui bottom tab markup" data-tab="preview">
-						{{$.locale.Tr "loading"}}
-					</div>
+
+					{{template "shared/combomarkdowneditor" (dict
+						"locale" $.locale
+						"MarkdownPreviewUrl" (print .Repository.Link "/markup")
+						"MarkdownPreviewContext" .RepoLink
+						"TextareaName" "content"
+						"TextareaContent" .content
+						"DropzoneParentContainer" "form"
+					)}}
 				</div>
 				{{range .attachments}}
 					<div class="field" id="attachment-{{.ID}}">
diff --git a/templates/repo/wiki/new.tmpl b/templates/repo/wiki/new.tmpl
index 085af4cbc9..03d710bb20 100644
--- a/templates/repo/wiki/new.tmpl
+++ b/templates/repo/wiki/new.tmpl
@@ -19,15 +19,18 @@
 			<div class="help">
 				{{.locale.Tr "repo.wiki.page_name_desc"}}
 			</div>
-			<div class="ui top attached tabular menu previewtabs" data-write="write" data-preview="preview">
-				<a class="active item" data-tab="write">{{.locale.Tr "write"}}</a>
-				<a class="item" data-tab="preview" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a>
-			</div>
-			<div class="field content" data-loading="{{.locale.Tr "loading"}}">
-				<div class="ui bottom active tab" data-tab="write">
-					<textarea class="js-quick-submit" id="edit_area" name="content" data-id="wiki-{{.title}}" data-url="{{.Repository.Link}}/markup" data-context="{{.RepoLink}}">{{if .PageIsWikiEdit}}{{.content}}{{else}}{{.locale.Tr "repo.wiki.welcome"}}{{end}}</textarea>
-				</div>
-			</div>
+
+			{{$content := .content}}
+			{{if not .PageIsWikiEdit}}
+				{{$content = .locale.Tr "repo.wiki.welcome"}}
+			{{end}}
+			{{template "shared/combomarkdowneditor" (dict
+				"locale" $.locale
+				"MarkdownPreviewUrl" (print .Repository.Link "/markup")
+				"MarkdownPreviewContext" .RepoLink
+				"TextareaName" "content"
+			)}}
+
 			<div class="field">
 				<input name="message" placeholder="{{.locale.Tr "repo.wiki.default_commit_message"}}">
 			</div>
diff --git a/templates/shared/combomarkdowneditor.tmpl b/templates/shared/combomarkdowneditor.tmpl
new file mode 100644
index 0000000000..0027ce8427
--- /dev/null
+++ b/templates/shared/combomarkdowneditor.tmpl
@@ -0,0 +1,47 @@
+{{/*
+Template Attributes:
+* locale
+* ContainerId / ContainerClasses : for the container element
+* MarkdownPreviewUrl / MarkdownPreviewContext: for the preview tab
+* TextareaName / TextareaContent / TextareaPlaceholder: for the main textarea
+* DropzoneParentContainer: for file upload (leave it empty if no upload)
+*/}}
+<div {{if .ContainerId}}id="{{.ContainerId}}"{{end}} class="combo-markdown-editor {{.ContainerClasses}}" data-dropzone-parent-container="{{.DropzoneParentContainer}}">
+	{{if .MarkdownPreviewUrl}}
+	<div class="ui top tabular menu">
+		<a class="active item" data-tab-for="markdown-writer">{{.locale.Tr "write"}}</a>
+		<a class="item" data-tab-for="markdown-previewer" data-preview-url="{{.MarkdownPreviewUrl}}" data-preview-context="{{.MarkdownPreviewContext}}">{{.locale.Tr "preview"}}</a>
+	</div>
+	{{end}}
+	<div class="ui tab active" data-tab-panel="markdown-writer">
+		<markdown-toolbar class="gt-df">
+			<div class="markdown-toolbar-group">
+				<md-header class="markdown-toolbar-button">{{svg "octicon-heading"}}</md-header>
+				<md-bold class="markdown-toolbar-button">{{svg "octicon-bold"}}</md-bold>
+				<md-italic class="markdown-toolbar-button">{{svg "octicon-italic"}}</md-italic>
+			</div>
+			<div class="markdown-toolbar-group">
+				<md-quote class="markdown-toolbar-button">{{svg "octicon-quote"}}</md-quote>
+				<md-code class="markdown-toolbar-button">{{svg "octicon-code"}}</md-code>
+				<md-link class="markdown-toolbar-button">{{svg "octicon-link"}}</md-link>
+			</div>
+			<div class="markdown-toolbar-group">
+				<md-unordered-list class="markdown-toolbar-button">{{svg "octicon-list-unordered"}}</md-unordered-list>
+				<md-ordered-list class="markdown-toolbar-button">{{svg "octicon-list-ordered"}}</md-ordered-list>
+				<md-task-list class="markdown-toolbar-button">{{svg "octicon-tasklist"}}</md-task-list>
+			</div>
+			<div class="markdown-toolbar-group">
+				<md-mention class="markdown-toolbar-button">{{svg "octicon-mention"}}</md-mention>
+				<md-ref class="markdown-toolbar-button">{{svg "octicon-cross-reference"}}</md-ref>
+			</div>
+			<div class="markdown-toolbar-group gt-f1"></div>
+			<div class="markdown-toolbar-group">
+				<span class="markdown-toolbar-button markdown-switch-easymde">{{svg "octicon-arrow-switch"}}</span>
+			</div>
+		</markdown-toolbar>
+		<textarea class="markdown-text-editor js-quick-submit" name="{{.TextareaName}}" placeholder="{{.TextareaPlaceholder}}">{{.TextareaContent}}</textarea>
+	</div>
+	<div class="ui tab markup" data-tab-panel="markdown-previewer">
+		{{.locale.Tr "loading"}}
+	</div>
+</div>
diff --git a/web_src/css/editor-markdown.css b/web_src/css/editor-markdown.css
new file mode 100644
index 0000000000..31ffeb06d0
--- /dev/null
+++ b/web_src/css/editor-markdown.css
@@ -0,0 +1,25 @@
+.combo-markdown-editor {
+  width: 100%;
+}
+
+.combo-markdown-editor markdown-toolbar {
+  cursor: default;
+  display: block;
+  padding-bottom: 10px;
+}
+
+.combo-markdown-editor .markdown-toolbar-group {
+  display: inline-block;
+}
+
+.combo-markdown-editor .markdown-toolbar-button {
+  user-select: none;
+  padding: 5px;
+  cursor: pointer;
+}
+
+.combo-markdown-editor .markdown-text-editor {
+  display: block;
+  width: 100%;
+  height: 200px;
+}
diff --git a/web_src/css/editor.css b/web_src/css/editor.css
index d3f9edeb2d..ba35036e4f 100644
--- a/web_src/css/editor.css
+++ b/web_src/css/editor.css
@@ -13,7 +13,6 @@
 }
 
 .editor-toolbar {
-  max-width: calc(100vw - 80px);
   border-color: var(--color-secondary);
 }
 
diff --git a/web_src/css/index.css b/web_src/css/index.css
index dd5f739379..e8d4e290d0 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -29,6 +29,7 @@
 @import "./form.css";
 @import "./repository.css";
 @import "./editor.css";
+@import "./editor-markdown.css";
 @import "./organization.css";
 @import "./user.css";
 @import "./dashboard.css";
diff --git a/web_src/css/repository.css b/web_src/css/repository.css
index 27d6a51cdd..fb5c75b73c 100644
--- a/web_src/css/repository.css
+++ b/web_src/css/repository.css
@@ -2116,10 +2116,6 @@
   height: 48px;
 }
 
-.repository.wiki.new .ui.attached.tabular.menu.previewtabs {
-  margin-bottom: 15px;
-}
-
 .repository.wiki.view > .markup {
   padding: 15px 30px;
 }
diff --git a/web_src/css/review.css b/web_src/css/review.css
index 3deb2192fc..913a7e9df2 100644
--- a/web_src/css/review.css
+++ b/web_src/css/review.css
@@ -248,6 +248,11 @@ a.blob-excerpt:hover {
   }
 }
 
+.review-box-panel .combo-markdown-editor textarea {
+  width: 730px;
+  max-width: calc(100vw - 70px);
+}
+
 #review-box {
   position: relative;
 }
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.js b/web_src/js/features/comp/ComboMarkdownEditor.js
new file mode 100644
index 0000000000..4905ec2341
--- /dev/null
+++ b/web_src/js/features/comp/ComboMarkdownEditor.js
@@ -0,0 +1,277 @@
+import '@github/markdown-toolbar-element';
+import {attachTribute} from '../tribute.js';
+import {hideElem, showElem} from '../../utils/dom.js';
+import {initEasyMDEImagePaste, initTextareaImagePaste} from './ImagePaste.js';
+import $ from 'jquery';
+import {initMarkupContent} from '../../markup/content.js';
+import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
+import {attachRefIssueContextPopup} from '../contextpopup.js';
+
+let elementIdCounter = 0;
+
+/**
+ * validate if the given textarea is non-empty.
+ * @param {jQuery} $textarea
+ * @returns {boolean} returns true if validation succeeded.
+ */
+export function validateTextareaNonEmpty($textarea) {
+  // When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
+  // The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
+  if (!$textarea.val()) {
+    if ($textarea.is(':visible')) {
+      $textarea.prop('required', true);
+      const $form = $textarea.parents('form');
+      $form[0]?.reportValidity();
+    } else {
+      // The alert won't hurt users too much, because we are dropping the EasyMDE and the check only occurs in a few places.
+      alert('Require non-empty content');
+    }
+    return false;
+  }
+  return true;
+}
+
+class ComboMarkdownEditor {
+  constructor(container, options = {}) {
+    container._giteaComboMarkdownEditor = this;
+    this.options = options;
+    this.container = container;
+  }
+
+  async init() {
+    this.textarea = this.container.querySelector('.markdown-text-editor');
+    this.textarea._giteaComboMarkdownEditor = this;
+    this.textarea.id = `_combo_markdown_editor_${String(elementIdCounter)}`;
+    this.textarea.addEventListener('input', (e) => {this.options?.onContentChanged?.(this, e)});
+    this.textareaMarkdownToolbar = this.container.querySelector('markdown-toolbar');
+    this.textareaMarkdownToolbar.setAttribute('for', this.textarea.id);
+
+    elementIdCounter++;
+
+    this.switchToEasyMDEButton = this.container.querySelector('.markdown-switch-easymde');
+    this.switchToEasyMDEButton?.addEventListener('click', async (e) => {
+      e.preventDefault();
+      await this.switchToEasyMDE();
+    });
+
+    await attachTribute(this.textarea, {mentions: true, emoji: true});
+
+    const dropzoneParentContainer = this.container.getAttribute('data-dropzone-parent-container');
+    if (dropzoneParentContainer) {
+      this.dropzone = this.container.closest(this.container.getAttribute('data-dropzone-parent-container'))?.querySelector('.dropzone');
+      initTextareaImagePaste(this.textarea, this.dropzone);
+    }
+
+    this.setupTab();
+    this.prepareEasyMDEToolbarActions();
+  }
+
+  setupTab() {
+    const $container = $(this.container);
+    const $tabMenu = $container.find('.tabular.menu');
+    const $tabs = $tabMenu.find('> .item');
+
+    // Fomantic Tab requires the "data-tab" to be globally unique.
+    // So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
+    const $tabEditor = $tabs.filter(`.item[data-tab-for="markdown-writer"]`);
+    const $tabPreviewer = $tabs.filter(`.item[data-tab-for="markdown-previewer"]`);
+    $tabEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
+    $tabPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
+    const $panelEditor = $container.find('.ui.tab[data-tab-panel="markdown-writer"]');
+    const $panelPreviewer = $container.find('.ui.tab[data-tab-panel="markdown-previewer"]');
+    $panelEditor.attr('data-tab', `markdown-writer-${elementIdCounter}`);
+    $panelPreviewer.attr('data-tab', `markdown-previewer-${elementIdCounter}`);
+    elementIdCounter++;
+
+    $tabs.tab();
+
+    this.previewUrl = $tabPreviewer.attr('data-preview-url');
+    this.previewContext = $tabPreviewer.attr('data-preview-context');
+    this.previewMode = this.options.previewMode ?? 'comment';
+    this.previewWiki = this.options.previewWiki ?? false;
+    $tabPreviewer.on('click', () => {
+      $.post(this.previewUrl, {
+        _csrf: window.config.csrfToken,
+        mode: this.previewMode,
+        context: this.previewContext,
+        text: this.value(),
+        wiki: this.previewWiki,
+      }, (data) => {
+        $panelPreviewer.html(data);
+        initMarkupContent();
+
+        const refIssues = $panelPreviewer.find('p .ref-issue');
+        attachRefIssueContextPopup(refIssues);
+      });
+    });
+  }
+
+  prepareEasyMDEToolbarActions() {
+    this.easyMDEToolbarDefault = [
+      'bold', 'italic', 'strikethrough', '|', 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
+      'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
+      'unordered-list', 'ordered-list', '|', 'link', 'image', 'table', 'horizontal-rule', '|', 'clean-block', '|',
+      'gitea-switch-to-textarea',
+    ];
+
+    this.easyMDEToolbarActions = {
+      'gitea-checkbox-empty': {
+        action(e) {
+          const cm = e.codemirror;
+          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
+          cm.focus();
+        },
+        className: 'fa fa-square-o',
+        title: 'Add Checkbox (empty)',
+      },
+      'gitea-checkbox-checked': {
+        action(e) {
+          const cm = e.codemirror;
+          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
+          cm.focus();
+        },
+        className: 'fa fa-check-square-o',
+        title: 'Add Checkbox (checked)',
+      },
+      'gitea-switch-to-textarea': {
+        action: this.switchToTextarea.bind(this),
+        className: 'fa fa-file',
+        title: 'Revert to simple textarea',
+      },
+      'gitea-code-inline': {
+        action(e) {
+          const cm = e.codemirror;
+          const selection = cm.getSelection();
+          cm.replaceSelection(`\`${selection}\``);
+          if (!selection) {
+            const cursorPos = cm.getCursor();
+            cm.setCursor(cursorPos.line, cursorPos.ch - 1);
+          }
+          cm.focus();
+        },
+        className: 'fa fa-angle-right',
+        title: 'Add Inline Code',
+      }
+    };
+  }
+
+  parseEasyMDEToolbar(actions) {
+    const processed = [];
+    for (const action of actions) {
+      if (action.startsWith('gitea-')) {
+        const giteaAction = this.easyMDEToolbarActions[action];
+        if (!giteaAction) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
+        processed.push(giteaAction);
+      } else {
+        processed.push(action);
+      }
+    }
+    return processed;
+  }
+
+  async switchToTextarea() {
+    showElem(this.textareaMarkdownToolbar);
+    if (this.easyMDE) {
+      this.easyMDE.toTextArea();
+      this.easyMDE = null;
+    }
+  }
+
+  async switchToEasyMDE() {
+    // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
+    const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
+    const easyMDEOpt = {
+      autoDownloadFontAwesome: false,
+      element: this.textarea,
+      forceSync: true,
+      renderingConfig: {singleLineBreaks: false},
+      indentWithTabs: false,
+      tabSize: 4,
+      spellChecker: false,
+      inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
+      nativeSpellcheck: true,
+      ...this.options.easyMDEOptions,
+    };
+    easyMDEOpt.toolbar = this.parseEasyMDEToolbar(easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
+
+    this.easyMDE = new EasyMDE(easyMDEOpt);
+    this.easyMDE.codemirror.on('change', (...args) => {this.options?.onContentChanged?.(this, ...args)});
+    this.easyMDE.codemirror.setOption('extraKeys', {
+      'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+      'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+      Enter: (cm) => {
+        const tributeContainer = document.querySelector('.tribute-container');
+        if (!tributeContainer || tributeContainer.style.display === 'none') {
+          cm.execCommand('newlineAndIndent');
+        }
+      },
+      Up: (cm) => {
+        const tributeContainer = document.querySelector('.tribute-container');
+        if (!tributeContainer || tributeContainer.style.display === 'none') {
+          return cm.execCommand('goLineUp');
+        }
+      },
+      Down: (cm) => {
+        const tributeContainer = document.querySelector('.tribute-container');
+        if (!tributeContainer || tributeContainer.style.display === 'none') {
+          return cm.execCommand('goLineDown');
+        }
+      },
+    });
+    await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
+    initEasyMDEImagePaste(this.easyMDE, this.dropzone);
+    hideElem(this.textareaMarkdownToolbar);
+  }
+
+  value(v = undefined) {
+    if (v === undefined) {
+      if (this.easyMDE) {
+        return this.easyMDE.value();
+      }
+      return this.textarea.value;
+    }
+
+    if (this.easyMDE) {
+      this.easyMDE.value(v);
+    } else {
+      this.textarea.value = v;
+    }
+  }
+
+  focus() {
+    if (this.easyMDE) {
+      this.easyMDE.codemirror.focus();
+    } else {
+      this.textarea.focus();
+    }
+  }
+
+  moveCursorToEnd() {
+    this.textarea.focus();
+    this.textarea.setSelectionRange(this.textarea.value.length, this.textarea.value.length);
+    if (this.easyMDE) {
+      this.easyMDE.codemirror.focus();
+      this.easyMDE.codemirror.setCursor(this.easyMDE.codemirror.lineCount(), 0);
+    }
+  }
+}
+
+export function getComboMarkdownEditor(el) {
+  if (el instanceof $) el = el[0];
+  return el?._giteaComboMarkdownEditor;
+}
+
+export async function initComboMarkdownEditor(container, options = {}) {
+  if (container instanceof $) {
+    if (container.length !== 1) {
+      throw new Error('initComboMarkdownEditor: container must be a single element');
+    }
+    container = container[0];
+  }
+  if (!container) {
+    throw new Error('initComboMarkdownEditor: container is null');
+  }
+  const editor = new ComboMarkdownEditor(container, options);
+  await editor.init();
+  return editor;
+}
diff --git a/web_src/js/features/comp/EasyMDE.js b/web_src/js/features/comp/EasyMDE.js
deleted file mode 100644
index 2979627b00..0000000000
--- a/web_src/js/features/comp/EasyMDE.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import $ from 'jquery';
-import {attachTribute} from '../tribute.js';
-import {handleGlobalEnterQuickSubmit} from './QuickSubmit.js';
-
-/**
- * @returns {EasyMDE}
- */
-export async function importEasyMDE() {
-  // EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can
-  // not overwrite the default styles.
-  const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
-  return EasyMDE;
-}
-
-/**
- * create an EasyMDE editor for comment
- * @param textarea jQuery or HTMLElement
- * @param easyMDEOptions the options for EasyMDE
- * @returns {null|EasyMDE}
- */
-export async function createCommentEasyMDE(textarea, easyMDEOptions = {}) {
-  if (textarea instanceof $) {
-    textarea = textarea[0];
-  }
-  if (!textarea) {
-    return null;
-  }
-
-  const EasyMDE = await importEasyMDE();
-
-  const easyMDE = new EasyMDE({
-    autoDownloadFontAwesome: false,
-    element: textarea,
-    forceSync: true,
-    renderingConfig: {
-      singleLineBreaks: false,
-    },
-    indentWithTabs: false,
-    tabSize: 4,
-    spellChecker: false,
-    inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
-    nativeSpellcheck: true,
-    toolbar: ['bold', 'italic', 'strikethrough', '|',
-      'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
-      'code', 'quote', '|', {
-        name: 'checkbox-empty',
-        action(e) {
-          const cm = e.codemirror;
-          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
-          cm.focus();
-        },
-        className: 'fa fa-square-o',
-        title: 'Add Checkbox (empty)',
-      },
-      {
-        name: 'checkbox-checked',
-        action(e) {
-          const cm = e.codemirror;
-          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
-          cm.focus();
-        },
-        className: 'fa fa-check-square-o',
-        title: 'Add Checkbox (checked)',
-      }, '|',
-      'unordered-list', 'ordered-list', '|',
-      'link', 'image', 'table', 'horizontal-rule', '|',
-      'clean-block', '|',
-      {
-        name: 'revert-to-textarea',
-        action(e) {
-          e.toTextArea();
-        },
-        className: 'fa fa-file',
-        title: 'Revert to simple textarea',
-      },
-    ], ...easyMDEOptions});
-
-  const inputField = easyMDE.codemirror.getInputField();
-
-  easyMDE.codemirror.on('change', (...args) => {
-    easyMDEOptions?.onChange?.(...args);
-  });
-  easyMDE.codemirror.setOption('extraKeys', {
-    'Cmd-Enter': codeMirrorQuickSubmit,
-    'Ctrl-Enter': codeMirrorQuickSubmit,
-    Enter: (cm) => {
-      const tributeContainer = document.querySelector('.tribute-container');
-      if (!tributeContainer || tributeContainer.style.display === 'none') {
-        cm.execCommand('newlineAndIndent');
-      }
-    },
-    Backspace: (cm) => {
-      if (cm.getInputField().trigger) {
-        cm.getInputField().trigger('input');
-      }
-      cm.execCommand('delCharBefore');
-    },
-    Up: (cm) => {
-      const tributeContainer = document.querySelector('.tribute-container');
-      if (!tributeContainer || tributeContainer.style.display === 'none') {
-        return cm.execCommand('goLineUp');
-      }
-    },
-    Down: (cm) => {
-      const tributeContainer = document.querySelector('.tribute-container');
-      if (!tributeContainer || tributeContainer.style.display === 'none') {
-        return cm.execCommand('goLineDown');
-      }
-    },
-  });
-  await attachTribute(inputField, {mentions: true, emoji: true});
-  attachEasyMDEToElements(easyMDE);
-  return easyMDE;
-}
-
-/**
- * attach the EasyMDE object to its input elements (InputField, TextArea)
- * @param {EasyMDE} easyMDE
- */
-export function attachEasyMDEToElements(easyMDE) {
-  // TODO: that's the only way we can do now to attach the EasyMDE object to a HTMLElement
-
-  // InputField is used by CodeMirror to accept user input
-  const inputField = easyMDE.codemirror.getInputField();
-  inputField._data_easyMDE = easyMDE;
-
-  // TextArea is the real textarea element in the form
-  const textArea = easyMDE.codemirror.getTextArea();
-  textArea._data_easyMDE = easyMDE;
-}
-
-
-/**
- * get the attached EasyMDE editor created by createCommentEasyMDE
- * @param el jQuery or HTMLElement
- * @returns {null|EasyMDE}
- */
-export function getAttachedEasyMDE(el) {
-  if (el instanceof $) {
-    el = el[0];
-  }
-  if (!el) {
-    return null;
-  }
-  return el._data_easyMDE;
-}
-
-/**
- * validate if the given EasyMDE textarea is is non-empty.
- * @param {jQuery} $textarea
- * @returns {boolean} returns true if validation succeeded.
- */
-export function validateTextareaNonEmpty($textarea) {
-  const $mdeInputField = $(getAttachedEasyMDE($textarea).codemirror.getInputField());
-  // The original edit area HTML element is hidden and replaced by the
-  // SimpleMDE/EasyMDE editor, breaking HTML5 input validation if the text area is empty.
-  // This is a workaround for this upstream bug.
-  // See https://github.com/sparksuite/simplemde-markdown-editor/issues/324
-  if (!$textarea.val()) {
-    $mdeInputField.prop('required', true);
-    const $form = $textarea.parents('form');
-    if (!$form.length) {
-      // this should never happen. we put a alert here in case the textarea would be forgotten to be put in a form
-      alert('Require non-empty content');
-    } else {
-      $form[0].reportValidity();
-    }
-    return false;
-  }
-  $mdeInputField.prop('required', false);
-  return true;
-}
-
-/**
- * there is no guarantee that the CodeMirror object is inside the same form as the textarea,
- * so can not call handleGlobalEnterQuickSubmit directly.
- * @param {CodeMirror.EditorFromTextArea} codeMirror
- */
-export function codeMirrorQuickSubmit(codeMirror) {
-  handleGlobalEnterQuickSubmit(codeMirror.getTextArea());
-}
diff --git a/web_src/js/features/comp/ImagePaste.js b/web_src/js/features/comp/ImagePaste.js
index da41e7611a..9145b24062 100644
--- a/web_src/js/features/comp/ImagePaste.js
+++ b/web_src/js/features/comp/ImagePaste.js
@@ -88,38 +88,43 @@ class CodeMirrorEditor {
 }
 
 
-export function initEasyMDEImagePaste(easyMDE, $dropzone) {
+const uploadClipboardImage = async (editor, dropzone, e) => {
+  const $dropzone = $(dropzone);
   const uploadUrl = $dropzone.attr('data-upload-url');
   const $files = $dropzone.find('.files');
 
   if (!uploadUrl || !$files.length) return;
 
-  const uploadClipboardImage = async (editor, e) => {
-    const pastedImages = clipboardPastedImages(e);
-    if (!pastedImages || pastedImages.length === 0) {
-      return;
-    }
-    e.preventDefault();
-    e.stopPropagation();
+  const pastedImages = clipboardPastedImages(e);
+  if (!pastedImages || pastedImages.length === 0) {
+    return;
+  }
+  e.preventDefault();
+  e.stopPropagation();
 
-    for (const img of pastedImages) {
-      const name = img.name.slice(0, img.name.lastIndexOf('.'));
+  for (const img of pastedImages) {
+    const name = img.name.slice(0, img.name.lastIndexOf('.'));
 
-      const placeholder = `![${name}](uploading ...)`;
-      editor.insertPlaceholder(placeholder);
-      const data = await uploadFile(img, uploadUrl);
-      editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`);
+    const placeholder = `![${name}](uploading ...)`;
+    editor.insertPlaceholder(placeholder);
+    const data = await uploadFile(img, uploadUrl);
+    editor.replacePlaceholder(placeholder, `![${name}](/attachments/${data.uuid})`);
 
-      const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid);
-      $files.append($input);
-    }
-  };
+    const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid);
+    $files.append($input);
+  }
+};
 
+export function initEasyMDEImagePaste(easyMDE, dropzone) {
+  if (!dropzone) return;
   easyMDE.codemirror.on('paste', async (_, e) => {
-    return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), e);
-  });
-
-  $(easyMDE.element).on('paste', async (e) => {
-    return uploadClipboardImage(new TextareaEditor(easyMDE.element), e.originalEvent);
+    return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
+  });
+}
+
+export function initTextareaImagePaste(textarea, dropzone) {
+  if (!dropzone) return;
+  $(textarea).on('paste', async (e) => {
+    return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e.originalEvent);
   });
 }
diff --git a/web_src/js/features/comp/MarkupContentPreview.js b/web_src/js/features/comp/MarkupContentPreview.js
deleted file mode 100644
index a32bf30184..0000000000
--- a/web_src/js/features/comp/MarkupContentPreview.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import $ from 'jquery';
-import {initMarkupContent} from '../../markup/content.js';
-import {attachRefIssueContextPopup} from '../contextpopup.js';
-
-const {csrfToken} = window.config;
-
-export function initCompMarkupContentPreviewTab($form) {
-  const $tabMenu = $form.find('.tabular.menu');
-  $tabMenu.find('.item').tab();
-  $tabMenu.find(`.item[data-tab="${$tabMenu.data('preview')}"]`).on('click', function () {
-    const $this = $(this);
-    $.post($this.data('url'), {
-      _csrf: csrfToken,
-      mode: 'comment',
-      context: $this.data('context'),
-      text: $form.find(`.tab[data-tab="${$tabMenu.data('write')}"] textarea`).val()
-    }, (data) => {
-      const $previewPanel = $form.find(`.tab[data-tab="${$tabMenu.data('preview')}"]`);
-      $previewPanel.html(data);
-      const refIssues = $previewPanel.find('p .ref-issue');
-      attachRefIssueContextPopup(refIssues);
-      initMarkupContent();
-    });
-  });
-}
diff --git a/web_src/js/features/contextpopup.js b/web_src/js/features/contextpopup.js
index 7b37035547..5c5733b35a 100644
--- a/web_src/js/features/contextpopup.js
+++ b/web_src/js/features/contextpopup.js
@@ -10,17 +10,16 @@ export function initContextPopups() {
 }
 
 export function attachRefIssueContextPopup(refIssues) {
-  if (!refIssues.length) return;
-  refIssues.each(function () {
-    if ($(this).hasClass('ref-external-issue')) {
+  for (const refIssue of refIssues) {
+    if (refIssue.classList.contains('ref-external-issue')) {
       return;
     }
 
-    const {owner, repo, index} = parseIssueHref($(this).attr('href'));
+    const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
     if (!owner) return;
 
     const el = document.createElement('div');
-    this.parentNode.insertBefore(el, this.nextSibling);
+    refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
 
     const view = createApp(ContextPopup);
 
@@ -31,7 +30,7 @@ export function attachRefIssueContextPopup(refIssues) {
       el.textContent = 'ContextPopup failed to load';
     }
 
-    createTippy(this, {
+    createTippy(refIssue, {
       content: el,
       placement: 'top-start',
       interactive: true,
@@ -40,5 +39,5 @@ export function attachRefIssueContextPopup(refIssues) {
         el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
       }
     });
-  });
+  }
 }
diff --git a/web_src/js/features/repo-diff.js b/web_src/js/features/repo-diff.js
index 458f11c6f2..df66db7f6c 100644
--- a/web_src/js/features/repo-diff.js
+++ b/web_src/js/features/repo-diff.js
@@ -1,8 +1,8 @@
 import $ from 'jquery';
 import {initCompReactionSelector} from './comp/ReactionSelector.js';
 import {initRepoIssueContentHistory} from './repo-issue-content.js';
-import {validateTextareaNonEmpty} from './comp/EasyMDE.js';
 import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js';
+import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
 
 const {csrfToken} = window.config;
 
diff --git a/web_src/js/features/repo-issue.js b/web_src/js/features/repo-issue.js
index 3f1b73d91e..03c9977f49 100644
--- a/web_src/js/features/repo-issue.js
+++ b/web_src/js/features/repo-issue.js
@@ -1,12 +1,9 @@
 import $ from 'jquery';
 import {htmlEscape} from 'escape-goat';
-import {attachTribute} from './tribute.js';
-import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
-import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
 import {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
 import {hideElem, showElem, toggleElem} from '../utils/dom.js';
 import {setFileFolding} from './file-fold.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 
 const {appSubUrl, csrfToken} = window.config;
 
@@ -223,21 +220,6 @@ export function initRepoIssueCodeCommentCancel() {
   });
 }
 
-export function initRepoIssueStatusButton() {
-  // Change status
-  const $statusButton = $('#status-button');
-  $('#comment-form textarea').on('keyup', function () {
-    const easyMDE = getAttachedEasyMDE(this);
-    const value = easyMDE?.value() || $(this).val();
-    $statusButton.text($statusButton.data(value.length === 0 ? 'status' : 'status-and-comment'));
-  });
-  $statusButton.on('click', (e) => {
-    e.preventDefault();
-    $('#status').val($statusButton.data('status-val'));
-    $('#comment-form').trigger('submit');
-  });
-}
-
 export function initRepoPullRequestUpdate() {
   // Pull Request update button
   const $pullUpdateButton = $('.update-button > button');
@@ -402,35 +384,18 @@ export function initRepoIssueComments() {
   });
 }
 
-
-function assignMenuAttributes(menu) {
-  const id = Math.floor(Math.random() * Math.floor(1000000));
-  menu.attr('data-write', menu.attr('data-write') + id);
-  menu.attr('data-preview', menu.attr('data-preview') + id);
-  menu.find('.item').each(function () {
-    const tab = $(this).attr('data-tab') + id;
-    $(this).attr('data-tab', tab);
-  });
-  menu.parent().find("*[data-tab='write']").attr('data-tab', `write${id}`);
-  menu.parent().find("*[data-tab='preview']").attr('data-tab', `preview${id}`);
-  initCompMarkupContentPreviewTab(menu.parent('.form'));
-  return id;
-}
-
 export async function handleReply($el) {
   hideElem($el);
   const form = $el.closest('.comment-code-cloud').find('.comment-form');
   form.removeClass('gt-hidden');
+
   const $textarea = form.find('textarea');
-  let easyMDE = getAttachedEasyMDE($textarea);
-  if (!easyMDE) {
-    await attachTribute($textarea.get(), {mentions: true, emoji: true});
-    easyMDE = await createCommentEasyMDE($textarea);
+  let editor = getComboMarkdownEditor($textarea);
+  if (!editor) {
+    editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor'));
   }
-  $textarea.focus();
-  easyMDE.codemirror.focus();
-  assignMenuAttributes(form.find('.menu'));
-  return easyMDE;
+  editor.focus();
+  return editor;
 }
 
 export function initRepoPullRequestReview() {
@@ -494,14 +459,7 @@ export function initRepoPullRequestReview() {
 
   const $reviewBox = $('.review-box-panel');
   if ($reviewBox.length === 1) {
-    (async () => {
-      // the editor's height is too large in some cases, and the panel cannot be scrolled with page now because there is `.repository .diff-detail-box.sticky { position: sticky; }`
-      // the temporary solution is to make the editor's height smaller (about 4 lines). GitHub also only show 4 lines for default. We can improve the UI (including Dropzone area) in future
-      // EasyMDE's options can not handle minHeight & maxHeight together correctly, we have to set max-height for .CodeMirror-scroll in CSS.
-      const $reviewTextarea = $reviewBox.find('textarea');
-      const easyMDE = await createCommentEasyMDE($reviewTextarea, {minHeight: '80px'});
-      initEasyMDEImagePaste(easyMDE, $reviewBox.find('.dropzone'));
-    })();
+    const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
   }
 
   // The following part is only for diff views
@@ -565,20 +523,16 @@ export function initRepoPullRequestReview() {
     }
 
     const td = ntr.find(`.add-comment-${side}`);
-    let commentCloud = td.find('.comment-code-cloud');
+    const commentCloud = td.find('.comment-code-cloud');
     if (commentCloud.length === 0 && !ntr.find('button[name="pending_review"]').length) {
-      const data = await $.get($(this).closest('[data-new-comment-url]').data('new-comment-url'));
-      td.html(data);
-      commentCloud = td.find('.comment-code-cloud');
-      assignMenuAttributes(commentCloud.find('.menu'));
+      const html = await $.get($(this).closest('[data-new-comment-url]').attr('data-new-comment-url'));
+      td.html(html);
       td.find("input[name='line']").val(idx);
       td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
       td.find("input[name='path']").val(path);
-      const $textarea = commentCloud.find('textarea');
-      await attachTribute($textarea.get(), {mentions: true, emoji: true});
-      const easyMDE = await createCommentEasyMDE($textarea);
-      $textarea.focus();
-      easyMDE.codemirror.focus();
+
+      const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
+      editor.focus();
     }
   });
 }
diff --git a/web_src/js/features/repo-legacy.js b/web_src/js/features/repo-legacy.js
index 3689c34272..2e39d3762f 100644
--- a/web_src/js/features/repo-legacy.js
+++ b/web_src/js/features/repo-legacy.js
@@ -1,11 +1,8 @@
 import $ from 'jquery';
-import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
-import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
 import {
   initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
   initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
-  initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle,
+  initRepoIssueTitleEdit, initRepoIssueWipToggle,
   initRepoPullRequestUpdate, updateIssuesMeta, handleReply
 } from './repo-issue.js';
 import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
@@ -19,27 +16,27 @@ import {
 import {initCitationFileCopyContent} from './citation.js';
 import {initCompLabelEdit} from './comp/LabelEdit.js';
 import {initRepoDiffConversationNav} from './repo-diff.js';
-import {attachTribute} from './tribute.js';
 import {createDropzone} from './dropzone.js';
 import {initCommentContent, initMarkupContent} from '../markup/content.js';
 import {initCompReactionSelector} from './comp/ReactionSelector.js';
 import {initRepoSettingBranches} from './repo-settings.js';
 import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
 import {hideElem, showElem} from '../utils/dom.js';
+import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 import {attachRefIssueContextPopup} from './contextpopup.js';
 
 const {csrfToken} = window.config;
 
-// if there are draft comments (more than 20 chars), confirm before reloading, to avoid losing comments
+// if there are draft comments, confirm before reloading, to avoid losing comments
 function reloadConfirmDraftComment() {
   const commentTextareas = [
     document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'),
-    document.querySelector('.edit_area'),
+    document.querySelector('#comment-form textarea'),
   ];
   for (const textarea of commentTextareas) {
-    // Most users won't feel too sad if they lose a comment with 10 or 20 chars, they can re-type these in seconds.
+    // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
     // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
-    if (textarea && textarea.value.trim().length > 20) {
+    if (textarea && textarea.value.trim().length > 10) {
       textarea.parentElement.scrollIntoView();
       if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
         return;
@@ -85,25 +82,20 @@ export function initRepoCommentForm() {
     });
   }
 
-  (async () => {
-    const $statusButton = $('#status-button');
-    for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) {
-      // Don't initialize EasyMDE for the dormant #edit-content-form
-      if (textarea.closest('#edit-content-form')) {
-        continue;
-      }
-      const easyMDE = await createCommentEasyMDE(textarea, {
-        'onChange': () => {
-          const value = easyMDE?.value().trim();
-          $statusButton.text($statusButton.attr(value.length === 0 ? 'data-status' : 'data-status-and-comment'));
-        },
-      });
-      initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone'));
-    }
-  })();
+  const $statusButton = $('#status-button');
+  $statusButton.on('click', (e) => {
+    e.preventDefault();
+    $('#status').val($statusButton.data('status-val'));
+    $('#comment-form').trigger('submit');
+  });
+
+  const _promise = initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), {
+    onContentChanged(editor) {
+      $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status'));
+    },
+  });
 
   initBranchSelector();
-  initCompMarkupContentPreviewTab($commentForm);
 
   // List submits
   function initListSubmits(selector, outerSelector) {
@@ -275,7 +267,7 @@ export function initRepoCommentForm() {
       } else if (input_id === '#project_id') {
         icon = svg('octicon-project', 18, 'gt-mr-3');
       } else if (input_id === '#assignee_id') {
-        icon = `<img class="ui avatar image gt-mr-3" src=${$(this).data('avatar')}>`;
+        icon = `<img class="ui avatar image gt-mr-3" alt="avatar" src=${$(this).data('avatar')}>`;
       }
 
       $list.find('.selected').html(`
@@ -322,162 +314,148 @@ async function onEditContent(event) {
   const $editContentZone = $segment.find('.edit-content-zone');
   const $renderContent = $segment.find('.render-content');
   const $rawContent = $segment.find('.raw-content');
-  let $textarea;
-  let easyMDE;
 
-  // Setup new form
-  if ($editContentZone.html().length === 0) {
-    $editContentZone.html($('#edit-content-form').html());
-    $textarea = $editContentZone.find('textarea');
-    await attachTribute($textarea.get(), {mentions: true, emoji: true});
+  let comboMarkdownEditor;
 
-    let dz;
-    const $dropzone = $editContentZone.find('.dropzone');
-    if ($dropzone.length === 1) {
-      $dropzone.data('saved', false);
+  const setupDropzone = async ($dropzone) => {
+    if ($dropzone.length === 0) return null;
+    $dropzone.data('saved', false);
 
-      const fileUuidDict = {};
-      dz = await createDropzone($dropzone[0], {
-        url: $dropzone.data('upload-url'),
-        headers: {'X-Csrf-Token': csrfToken},
-        maxFiles: $dropzone.data('max-file'),
-        maxFilesize: $dropzone.data('max-size'),
-        acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
-        addRemoveLinks: true,
-        dictDefaultMessage: $dropzone.data('default-message'),
-        dictInvalidFileType: $dropzone.data('invalid-input-type'),
-        dictFileTooBig: $dropzone.data('file-too-big'),
-        dictRemoveFile: $dropzone.data('remove-file'),
-        timeout: 0,
-        thumbnailMethod: 'contain',
-        thumbnailWidth: 480,
-        thumbnailHeight: 480,
-        init() {
-          this.on('success', (file, data) => {
-            file.uuid = data.uuid;
-            fileUuidDict[file.uuid] = {submitted: false};
-            const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
-            $dropzone.find('.files').append(input);
+    const fileUuidDict = {};
+    const dz = await createDropzone($dropzone[0], {
+      url: $dropzone.data('upload-url'),
+      headers: {'X-Csrf-Token': csrfToken},
+      maxFiles: $dropzone.data('max-file'),
+      maxFilesize: $dropzone.data('max-size'),
+      acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'),
+      addRemoveLinks: true,
+      dictDefaultMessage: $dropzone.data('default-message'),
+      dictInvalidFileType: $dropzone.data('invalid-input-type'),
+      dictFileTooBig: $dropzone.data('file-too-big'),
+      dictRemoveFile: $dropzone.data('remove-file'),
+      timeout: 0,
+      thumbnailMethod: 'contain',
+      thumbnailWidth: 480,
+      thumbnailHeight: 480,
+      init() {
+        this.on('success', (file, data) => {
+          file.uuid = data.uuid;
+          fileUuidDict[file.uuid] = {submitted: false};
+          const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid);
+          $dropzone.find('.files').append(input);
+        });
+        this.on('removedfile', (file) => {
+          $(`#${file.uuid}`).remove();
+          if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
+            $.post($dropzone.data('remove-url'), {
+              file: file.uuid,
+              _csrf: csrfToken,
+            });
+          }
+        });
+        this.on('submit', () => {
+          $.each(fileUuidDict, (fileUuid) => {
+            fileUuidDict[fileUuid].submitted = true;
           });
-          this.on('removedfile', (file) => {
-            $(`#${file.uuid}`).remove();
-            if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) {
-              $.post($dropzone.data('remove-url'), {
-                file: file.uuid,
-                _csrf: csrfToken,
-              });
-            }
-          });
-          this.on('submit', () => {
-            $.each(fileUuidDict, (fileUuid) => {
-              fileUuidDict[fileUuid].submitted = true;
+        });
+        this.on('reload', () => {
+          $.getJSON($editContentZone.data('attachment-url'), (data) => {
+            dz.removeAllFiles(true);
+            $dropzone.find('.files').empty();
+            $.each(data, function () {
+              const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
+              dz.emit('addedfile', this);
+              dz.emit('thumbnail', this, imgSrc);
+              dz.emit('complete', this);
+              dz.files.push(this);
+              fileUuidDict[this.uuid] = {submitted: true};
+              $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
+              const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
+              $dropzone.find('.files').append(input);
             });
           });
-          this.on('reload', () => {
-            $.getJSON($editContentZone.data('attachment-url'), (data) => {
-              dz.removeAllFiles(true);
-              $dropzone.find('.files').empty();
-              $.each(data, function () {
-                const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`;
-                dz.emit('addedfile', this);
-                dz.emit('thumbnail', this, imgSrc);
-                dz.emit('complete', this);
-                dz.files.push(this);
-                fileUuidDict[this.uuid] = {submitted: true};
-                $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%');
-                const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid);
-                $dropzone.find('.files').append(input);
-              });
-            });
-          });
-        },
-      });
+        });
+      },
+    });
+    dz.emit('reload');
+    return dz;
+  };
+
+  const cancelAndReset = (dz) => {
+    showElem($renderContent);
+    hideElem($editContentZone);
+    if (dz) {
       dz.emit('reload');
     }
-    // Give new write/preview data-tab name to distinguish from others
-    const $editContentForm = $editContentZone.find('.ui.comment.form');
-    const $tabMenu = $editContentForm.find('.tabular.menu');
-    $tabMenu.attr('data-write', $editContentZone.data('write'));
-    $tabMenu.attr('data-preview', $editContentZone.data('preview'));
-    $tabMenu.find('.write.item').attr('data-tab', $editContentZone.data('write'));
-    $tabMenu.find('.preview.item').attr('data-tab', $editContentZone.data('preview'));
-    $editContentForm.find('.write').attr('data-tab', $editContentZone.data('write'));
-    $editContentForm.find('.preview').attr('data-tab', $editContentZone.data('preview'));
-    easyMDE = await createCommentEasyMDE($textarea);
+  };
 
-    initCompMarkupContentPreviewTab($editContentForm);
-    initEasyMDEImagePaste(easyMDE, $dropzone);
+  const saveAndRefresh = (dz, $dropzone) => {
+    showElem($renderContent);
+    hideElem($editContentZone);
+    const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
+      return $(this).val();
+    }).get();
+    $.post($editContentZone.data('update-url'), {
+      _csrf: csrfToken,
+      content: comboMarkdownEditor.value(),
+      context: $editContentZone.data('context'),
+      files: $attachments,
+    }, (data) => {
+      if (!data.content) {
+        $renderContent.html($('#no-content').html());
+        $rawContent.text('');
+      } else {
+        $renderContent.html(data.content);
+        $rawContent.text(comboMarkdownEditor.value());
 
-    const $saveButton = $editContentZone.find('.save.button');
-    $textarea.on('ce-quick-submit', () => {
-      $saveButton.trigger('click');
-    });
-
-    $editContentZone.find('.cancel.button').on('click', (e) => {
-      e.preventDefault();
-      showElem($renderContent);
-      hideElem($editContentZone);
-      if (dz) {
-        dz.emit('reload');
+        const refIssues = $renderContent.find('p .ref-issue');
+        attachRefIssueContextPopup(refIssues);
       }
-    });
-
-    $saveButton.on('click', () => {
-      showElem($renderContent);
-      hideElem($editContentZone);
-      const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
-        return $(this).val();
-      }).get();
-      $.post($editContentZone.data('update-url'), {
-        _csrf: csrfToken,
-        content: $textarea.val(),
-        context: $editContentZone.data('context'),
-        files: $attachments,
-      }, (data) => {
-        if (data.length === 0 || data.content.length === 0) {
-          $renderContent.html($('#no-content').html());
-          $rawContent.text('');
-        } else {
-          $renderContent.html(data.content);
-          $rawContent.text($textarea.val());
-          const refIssues = $renderContent.find('p .ref-issue');
-          attachRefIssueContextPopup(refIssues);
-        }
-        const $content = $segment;
-        if (!$content.find('.dropzone-attachments').length) {
-          if (data.attachments !== '') {
-            $content.append(`<div class="dropzone-attachments"></div>`);
-            $content.find('.dropzone-attachments').replaceWith(data.attachments);
-          }
-        } else if (data.attachments === '') {
-          $content.find('.dropzone-attachments').remove();
-        } else {
+      const $content = $segment;
+      if (!$content.find('.dropzone-attachments').length) {
+        if (data.attachments !== '') {
+          $content.append(`<div class="dropzone-attachments"></div>`);
           $content.find('.dropzone-attachments').replaceWith(data.attachments);
         }
-        if (dz) {
-          dz.emit('submit');
-          dz.emit('reload');
-        }
-        initMarkupContent();
-        initCommentContent();
-      });
+      } else if (data.attachments === '') {
+        $content.find('.dropzone-attachments').remove();
+      } else {
+        $content.find('.dropzone-attachments').replaceWith(data.attachments);
+      }
+      if (dz) {
+        dz.emit('submit');
+        dz.emit('reload');
+      }
+      initMarkupContent();
+      initCommentContent();
     });
-  } else { // use existing form
-    $textarea = $segment.find('textarea');
-    easyMDE = getAttachedEasyMDE($textarea);
+  };
+
+  if (!$editContentZone.html()) {
+    $editContentZone.html($('#issue-comment-editor-template').html());
+    comboMarkdownEditor = await initComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
+
+    const $dropzone = $editContentZone.find('.dropzone');
+    const dz = await setupDropzone($dropzone);
+    $editContentZone.find('.cancel.button').on('click', (e) => {
+      e.preventDefault();
+      cancelAndReset(dz);
+    });
+    $editContentZone.find('.save.button').on('click', (e) => {
+      e.preventDefault();
+      saveAndRefresh(dz, $dropzone);
+    });
+  } else {
+    comboMarkdownEditor = getComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
   }
 
   // Show write/preview tab and copy raw content as needed
   showElem($editContentZone);
   hideElem($renderContent);
-  if ($textarea.val().length === 0) {
-    $textarea.val($rawContent.text());
-    easyMDE.value($rawContent.text());
+  if (!comboMarkdownEditor.value()) {
+    comboMarkdownEditor.value($rawContent.text());
   }
-  requestAnimationFrame(() => {
-    $textarea.focus();
-    easyMDE.codemirror.focus();
-  });
+  comboMarkdownEditor.focus();
 }
 
 export function initRepository() {
@@ -575,7 +553,6 @@ export function initRepository() {
     initRepoIssueCommentDelete();
     initRepoIssueDependencyDelete();
     initRepoIssueCodeCommentCancel();
-    initRepoIssueStatusButton();
     initRepoPullRequestUpdate();
     initCompReactionSelector();
 
@@ -592,12 +569,6 @@ export function initRepository() {
 
       const $form = $repoComparePull.find('.pullrequest-form');
       showElem($form);
-      $form.find('textarea.edit_area').each(function() {
-        const easyMDE = getAttachedEasyMDE($(this));
-        if (easyMDE) {
-          easyMDE.codemirror.refresh();
-        }
-      });
     });
   }
 
@@ -614,24 +585,22 @@ function initRepoIssueCommentEdit() {
     const target = $(this).data('target');
     const quote = $(`#${target}`).text().replace(/\n/g, '\n> ');
     const content = `> ${quote}\n\n`;
-    let easyMDE;
+    let editor;
     if ($(this).hasClass('quote-reply-diff')) {
       const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
-      easyMDE = await handleReply($replyBtn);
+      editor = await handleReply($replyBtn);
     } else {
       // for normal issue/comment page
-      easyMDE = getAttachedEasyMDE($('#comment-form .edit_area'));
+      editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
     }
-    if (easyMDE) {
-      if (easyMDE.value() !== '') {
-        easyMDE.value(`${easyMDE.value()}\n\n${content}`);
+    if (editor) {
+      if (editor.value()) {
+        editor.value(`${editor.value()}\n\n${content}`);
       } else {
-        easyMDE.value(`${content}`);
+        editor.value(content);
       }
-      requestAnimationFrame(() => {
-        easyMDE.codemirror.focus();
-        easyMDE.codemirror.setCursor(easyMDE.codemirror.lineCount(), 0);
-      });
+      editor.focus();
+      editor.moveCursorToEnd();
     }
   });
 }
diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js
index a230d7765e..5cc6f1e3cd 100644
--- a/web_src/js/features/repo-release.js
+++ b/web_src/js/features/repo-release.js
@@ -1,9 +1,6 @@
 import $ from 'jquery';
-import {attachTribute} from './tribute.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
-import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
-import {createCommentEasyMDE} from './comp/EasyMDE.js';
 import {hideElem, showElem} from '../utils/dom.js';
+import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 
 export function initRepoRelease() {
   $(document).on('click', '.remove-rel-attach', function() {
@@ -51,17 +48,9 @@ function initTagNameEditor() {
 }
 
 function initRepoReleaseEditor() {
-  const $editor = $('.repository.new.release .content-editor');
+  const $editor = $('.repository.new.release .combo-markdown-editor');
   if ($editor.length === 0) {
     return;
   }
-
-  (async () => {
-    const $textarea = $editor.find('textarea');
-    await attachTribute($textarea.get(), {mentions: true, emoji: true});
-    const easyMDE = await createCommentEasyMDE($textarea);
-    initCompMarkupContentPreviewTab($editor);
-    const $dropzone = $editor.parent().find('.dropzone');
-    initEasyMDEImagePaste(easyMDE, $dropzone);
-  })();
+  const _promise = initComboMarkdownEditor($editor);
 }
diff --git a/web_src/js/features/repo-wiki.js b/web_src/js/features/repo-wiki.js
index 4555b32e5f..a48f63dcb1 100644
--- a/web_src/js/features/repo-wiki.js
+++ b/web_src/js/features/repo-wiki.js
@@ -1,194 +1,68 @@
 import $ from 'jquery';
 import {initMarkupContent} from '../markup/content.js';
-import {attachEasyMDEToElements, codeMirrorQuickSubmit, importEasyMDE, validateTextareaNonEmpty} from './comp/EasyMDE.js';
-import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
+import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
 
 const {csrfToken} = window.config;
 
 async function initRepoWikiFormEditor() {
-  const $editArea = $('.repository.wiki textarea#edit_area');
+  const $editArea = $('.repository.wiki .combo-markdown-editor textarea');
   if (!$editArea.length) return;
 
-  let sideBySideChanges = 0;
-  let sideBySideTimeout = null;
-  let hasEasyMDE = true;
-
   const $form = $('.repository.wiki.new .ui.form');
-  const EasyMDE = await importEasyMDE();
-  const easyMDE = new EasyMDE({
-    autoDownloadFontAwesome: false,
-    element: $editArea[0],
-    forceSync: true,
-    previewRender(plainText, preview) { // Async method
-      // FIXME: still send render request when return back to edit mode
-      const render = function () {
-        sideBySideChanges = 0;
-        if (sideBySideTimeout !== null) {
-          clearTimeout(sideBySideTimeout);
-          sideBySideTimeout = null;
-        }
-        $.post($editArea.data('url'), {
-          _csrf: csrfToken,
-          mode: 'gfm',
-          context: $editArea.data('context'),
-          text: plainText,
-          wiki: true
-        }, (data) => {
-          preview.innerHTML = `<div class="markup ui segment">${data}</div>`;
-          initMarkupContent();
-        });
-      };
+  const $editorContainer = $form.find('.combo-markdown-editor');
+  let editor;
 
-      setTimeout(() => {
-        if (!easyMDE.isSideBySideActive()) {
-          render();
-        } else {
-          // delay preview by keystroke counting
-          sideBySideChanges++;
-          if (sideBySideChanges > 10) {
-            render();
-          }
-          // or delay preview by timeout
-          if (sideBySideTimeout !== null) {
-            clearTimeout(sideBySideTimeout);
-            sideBySideTimeout = null;
-          }
-          sideBySideTimeout = setTimeout(render, 600);
-        }
-      }, 0);
-      if (!easyMDE.isSideBySideActive()) {
-        return 'Loading...';
-      }
-      return preview.innerHTML;
+  let renderRequesting = false;
+  let lastContent;
+  const renderEasyMDEPreview = function () {
+    if (renderRequesting) return;
+
+    const $previewFull = $editorContainer.find('.EasyMDEContainer .editor-preview-active');
+    const $previewSide = $editorContainer.find('.EasyMDEContainer .editor-preview-active-side');
+    const $previewTarget = $previewSide.length ? $previewSide : $previewFull;
+    const newContent = $editArea.val();
+    if (editor && $previewTarget.length && lastContent !== newContent) {
+      renderRequesting = true;
+      $.post(editor.previewUrl, {
+        _csrf: csrfToken,
+        mode: editor.previewMode,
+        context: editor.previewContext,
+        text: newContent,
+        wiki: editor.previewWiki,
+      }).done((data) => {
+        lastContent = newContent;
+        $previewTarget.html(`<div class="markup ui segment">${data}</div>`);
+        initMarkupContent();
+      }).always(() => {
+        renderRequesting = false;
+        setTimeout(renderEasyMDEPreview, 1000);
+      });
+    } else {
+      setTimeout(renderEasyMDEPreview, 1000);
+    }
+  };
+  renderEasyMDEPreview();
+
+  editor = await initComboMarkdownEditor($editorContainer, {
+    previewMode: 'gfm',
+    previewWiki: true,
+    easyMDEOptions: {
+      previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
+      toolbar: ['bold', 'italic', 'strikethrough', '|',
+        'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
+        'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
+        'unordered-list', 'ordered-list', '|',
+        'link', 'image', 'table', 'horizontal-rule', '|',
+        'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea'
+      ],
     },
-    renderingConfig: {
-      singleLineBreaks: false
-    },
-    indentWithTabs: false,
-    tabSize: 4,
-    spellChecker: false,
-    inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
-    nativeSpellcheck: true,
-    toolbar: ['bold', 'italic', 'strikethrough', '|',
-      'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
-      {
-        name: 'code-inline',
-        action(e) {
-          const cm = e.codemirror;
-          const selection = cm.getSelection();
-          cm.replaceSelection(`\`${selection}\``);
-          if (!selection) {
-            const cursorPos = cm.getCursor();
-            cm.setCursor(cursorPos.line, cursorPos.ch - 1);
-          }
-          cm.focus();
-        },
-        className: 'fa fa-angle-right',
-        title: 'Add Inline Code',
-      }, 'code', 'quote', '|', {
-        name: 'checkbox-empty',
-        action(e) {
-          const cm = e.codemirror;
-          cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`);
-          cm.focus();
-        },
-        className: 'fa fa-square-o',
-        title: 'Add Checkbox (empty)',
-      },
-      {
-        name: 'checkbox-checked',
-        action(e) {
-          const cm = e.codemirror;
-          cm.replaceSelection(`\n- [x] ${cm.getSelection()}`);
-          cm.focus();
-        },
-        className: 'fa fa-check-square-o',
-        title: 'Add Checkbox (checked)',
-      }, '|',
-      'unordered-list', 'ordered-list', '|',
-      'link', 'image', 'table', 'horizontal-rule', '|',
-      'clean-block', 'preview', 'fullscreen', 'side-by-side', '|',
-      {
-        name: 'revert-to-textarea',
-        action(e) {
-          e.toTextArea();
-          hasEasyMDE = false;
-          const $root = $form.find('.field.content');
-          const loading = $root.data('loading');
-          $root.append(`<div class="ui bottom tab markup" data-tab="preview">${loading}</div>`);
-          initCompMarkupContentPreviewTab($form);
-        },
-        className: 'fa fa-file',
-        title: 'Revert to simple textarea',
-      },
-    ]
   });
 
-  easyMDE.codemirror.setOption('extraKeys', {
-    'Cmd-Enter': codeMirrorQuickSubmit,
-    'Ctrl-Enter': codeMirrorQuickSubmit,
-  });
-
-  attachEasyMDEToElements(easyMDE);
-
   $form.on('submit', () => {
     if (!validateTextareaNonEmpty($editArea)) {
       return false;
     }
   });
-
-  setTimeout(() => {
-    const $bEdit = $('.repository.wiki.new .previewtabs a[data-tab="write"]');
-    const $bPrev = $('.repository.wiki.new .previewtabs a[data-tab="preview"]');
-    const $toolbar = $('.editor-toolbar');
-    const $bPreview = $('.editor-toolbar button.preview');
-    const $bSideBySide = $('.editor-toolbar a.fa-columns');
-    $bEdit.on('click', (e) => {
-      if (!hasEasyMDE) {
-        return false;
-      }
-      e.stopImmediatePropagation();
-      if ($toolbar.hasClass('disabled-for-preview')) {
-        $bPreview.trigger('click');
-      }
-
-      return false;
-    });
-    $bPrev.on('click', (e) => {
-      if (!hasEasyMDE) {
-        return false;
-      }
-      e.stopImmediatePropagation();
-      if (!$toolbar.hasClass('disabled-for-preview')) {
-        $bPreview.trigger('click');
-      }
-      return false;
-    });
-    $bPreview.on('click', () => {
-      setTimeout(() => {
-        if ($toolbar.hasClass('disabled-for-preview')) {
-          if ($bEdit.hasClass('active')) {
-            $bEdit.removeClass('active');
-          }
-          if (!$bPrev.hasClass('active')) {
-            $bPrev.addClass('active');
-          }
-        } else {
-          if (!$bEdit.hasClass('active')) {
-            $bEdit.addClass('active');
-          }
-          if ($bPrev.hasClass('active')) {
-            $bPrev.removeClass('active');
-          }
-        }
-      }, 0);
-
-      return false;
-    });
-    $bSideBySide.on('click', () => {
-      sideBySideChanges = 10;
-    });
-  }, 0);
 }
 
 export function initRepoWikiForm() {
diff --git a/web_src/js/features/tribute.js b/web_src/js/features/tribute.js
index 94f3512a2e..e77ba29950 100644
--- a/web_src/js/features/tribute.js
+++ b/web_src/js/features/tribute.js
@@ -1,11 +1,10 @@
 import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
-import {uniq} from '../utils.js';
 import {htmlEscape} from 'escape-goat';
 
 function makeCollections({mentions, emoji}) {
   const collections = [];
 
-  if (mentions) {
+  if (emoji) {
     collections.push({
       trigger: ':',
       requireLeadingSpace: true,
@@ -30,14 +29,14 @@ function makeCollections({mentions, emoji}) {
     });
   }
 
-  if (emoji) {
+  if (mentions) {
     collections.push({
       values: window.config.tributeValues,
       requireLeadingSpace: true,
       menuItemTemplate: (item) => {
         return `
           <div class="tribute-item">
-            <img src="${htmlEscape(item.original.avatar)}"/>
+            <img src="${htmlEscape(item.original.avatar)}" class="gt-mr-3"/>
             <span class="name">${htmlEscape(item.original.name)}</span>
             ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
           </div>
@@ -49,30 +48,10 @@ function makeCollections({mentions, emoji}) {
   return collections;
 }
 
-export async function attachTribute(elementOrNodeList, {mentions, emoji} = {}) {
-  if (!window.config.requireTribute || !elementOrNodeList) return;
-  const nodes = Array.from('length' in elementOrNodeList ? elementOrNodeList : [elementOrNodeList]);
-  if (!nodes.length) return;
-
-  const mentionNodes = nodes.filter((node) => {
-    return mentions || node.id === 'content';
-  });
-  const emojiNodes = nodes.filter((node) => {
-    return emoji || node.id === 'content' || node.classList.contains('emoji-input');
-  });
-  const uniqueNodes = uniq([...mentionNodes, ...emojiNodes]);
-  if (!uniqueNodes.length) return;
-
+export async function attachTribute(element, {mentions, emoji} = {}) {
   const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
-
-  const collections = makeCollections({
-    mentions: mentions || mentionNodes.length > 0,
-    emoji: emoji || emojiNodes.length > 0,
-  });
-
+  const collections = makeCollections({mentions, emoji});
   const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
-  for (const node of uniqueNodes) {
-    tribute.attach(node);
-  }
+  tribute.attach(element);
   return tribute;
 }
diff --git a/web_src/js/index.js b/web_src/js/index.js
index 839289e9d2..e727acfa06 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -4,7 +4,6 @@ import './bootstrap.js';
 import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
 import {initDashboardRepoList} from './components/DashboardRepoList.vue';
 
-import {attachTribute} from './features/tribute.js';
 import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
 import {initContextPopups} from './features/contextpopup.js';
 import {initRepoGraphGit} from './features/repo-graph.js';
@@ -110,8 +109,6 @@ onDomReady(() => {
   initGlobalFormDirtyLeaveConfirm();
   initGlobalLinkActions();
 
-  attachTribute(document.querySelectorAll('#content, .emoji-input'));
-
   initCommonIssue();
   initCommonOrganization();
 
diff --git a/web_src/js/utils.js b/web_src/js/utils.js
index b3ffbf2988..e72e55dc65 100644
--- a/web_src/js/utils.js
+++ b/web_src/js/utils.js
@@ -30,11 +30,6 @@ export function isDarkTheme() {
   return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
 }
 
-// removes duplicate elements in an array
-export function uniq(arr) {
-  return Array.from(new Set(arr));
-}
-
 // strip <tags> from a string
 export function stripTags(text) {
   return text.replace(/<[^>]*>?/gm, '');
diff --git a/web_src/js/utils.test.js b/web_src/js/utils.test.js
index 306acd34af..46fbb28de4 100644
--- a/web_src/js/utils.test.js
+++ b/web_src/js/utils.test.js
@@ -1,6 +1,6 @@
 import {expect, test} from 'vitest';
 import {
-  basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref,
+  basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
   prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
   toAbsoluteUrl,
 } from './utils.js';
@@ -62,10 +62,6 @@ test('isObject', () => {
   expect(isObject([])).toBeFalsy();
 });
 
-test('uniq', () => {
-  expect(uniq([1, 1, 1, 2])).toEqual([1, 2]);
-});
-
 test('stripTags', () => {
   expect(stripTags('<a>test</a>')).toEqual('test');
 });