1
0
Fork 0
mirror of https://codeberg.org/forgejo/forgejo.git synced 2024-11-21 08:31:27 -05:00

Introduce GitHub markdown editor, keep EasyMDE as fallback (#23876)

The first step of the plan

* #23290

Thanks to @silverwind for the first try in #15394 . Close #10729 and a
lot of related issues.

The EasyMDE is not removed, now it works as a fallback, users can switch
between these two editors.

Editor list:

* Issue / PR comment
* Issue / PR comment edit
* Issue / PR comment quote reply
* PR diff view, inline comment
* PR diff view, inline comment edit
* PR diff view, inline comment quote reply
* Release editor
* Wiki editor

Some editors have attached dropzone

Screenshots:

<details>


![image](https://user-images.githubusercontent.com/2114189/229363558-7e44dcd4-fb6d-48a0-92f8-bd12f57bb0a0.png)


![image](https://user-images.githubusercontent.com/2114189/229363566-781489c8-5306-4347-9714-d71af5d5b0b1.png)


![image](https://user-images.githubusercontent.com/2114189/229363771-1717bf5c-0f2a-4fc2-ba84-4f5b2a343a11.png)


![image](https://user-images.githubusercontent.com/2114189/229363793-ad362d0f-a045-47bd-8f9d-05a9a842bb39.png)

</details>

---------

Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
wxiaoguang 2023-04-03 18:06:57 +08:00 committed by GitHub
parent d67e40684f
commit 5cc0801de9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 763 additions and 833 deletions

6
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@citation-js/plugin-csl": "0.6.7", "@citation-js/plugin-csl": "0.6.7",
"@citation-js/plugin-software-formats": "0.6.1", "@citation-js/plugin-software-formats": "0.6.1",
"@claviska/jquery-minicolors": "2.3.6", "@claviska/jquery-minicolors": "2.3.6",
"@github/markdown-toolbar-element": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "18.3.0", "@primer/octicons": "18.3.0",
"@vue/compiler-sfc": "3.2.47", "@vue/compiler-sfc": "3.2.47",
@ -838,6 +839,11 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "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": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.8", "version": "0.11.8",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz",

View file

@ -12,6 +12,7 @@
"@citation-js/plugin-csl": "0.6.7", "@citation-js/plugin-csl": "0.6.7",
"@citation-js/plugin-software-formats": "0.6.1", "@citation-js/plugin-software-formats": "0.6.1",
"@claviska/jquery-minicolors": "2.3.6", "@claviska/jquery-minicolors": "2.3.6",
"@github/markdown-toolbar-element": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@primer/octicons": "18.3.0", "@primer/octicons": "18.3.0",
"@vue/compiler-sfc": "3.2.47", "@vue/compiler-sfc": "3.2.47",

View file

@ -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"))))
}

View file

@ -15,24 +15,6 @@ import (
// Markup render markup document to HTML // Markup render markup document to HTML
func Markup(ctx *context.Context) { 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) form := web.GetForm(ctx).(*api.MarkupOption)
if ctx.HasAPIError() { if ctx.HasAPIError() {

View file

@ -246,7 +246,6 @@ func Labels(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.labels") ctx.Data["Title"] = ctx.Tr("repo.labels")
ctx.Data["PageIsOrgSettings"] = true ctx.Data["PageIsOrgSettings"] = true
ctx.Data["PageIsOrgSettingsLabels"] = true ctx.Data["PageIsOrgSettingsLabels"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["LabelTemplates"] = repo_module.LabelTemplates ctx.Data["LabelTemplates"] = repo_module.LabelTemplates
ctx.HTML(http.StatusOK, tplSettingsLabels) ctx.HTML(http.StatusOK, tplSettingsLabels)
} }

View file

@ -253,7 +253,6 @@ func FileHistory(ctx *context.Context) {
// Diff show different from current commit to previous commit // Diff show different from current commit to previous commit
func Diff(ctx *context.Context) { func Diff(ctx *context.Context) {
ctx.Data["PageIsDiff"] = true ctx.Data["PageIsDiff"] = true
ctx.Data["RequireTribute"] = true
userName := ctx.Repo.Owner.Name userName := ctx.Repo.Owner.Name
repoName := ctx.Repo.Repository.Name repoName := ctx.Repo.Repository.Name

View file

@ -781,7 +781,6 @@ func CompareDiff(ctx *context.Context) {
ctx.Data["IsRepoToolbarCommits"] = true ctx.Data["IsRepoToolbarCommits"] = true
ctx.Data["IsDiffCompare"] = true ctx.Data["IsDiffCompare"] = true
ctx.Data["RequireTribute"] = true
templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates) templateErrs := setTemplateIfExists(ctx, pullRequestTemplateKey, pullRequestTemplateCandidates)
if len(templateErrs) > 0 { if len(templateErrs) > 0 {

View file

@ -538,7 +538,6 @@ func DeleteFilePost(ctx *context.Context) {
// UploadFile render upload file page // UploadFile render upload file page
func UploadFile(ctx *context.Context) { func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true ctx.Data["PageIsUpload"] = true
ctx.Data["RequireTribute"] = true
upload.AddUploadContext(ctx, "repo") upload.AddUploadContext(ctx, "repo")
canCommit := renderCommitRights(ctx) canCommit := renderCommitRights(ctx)
treePath := cleanUploadFileName(ctx.Repo.TreePath) treePath := cleanUploadFileName(ctx.Repo.TreePath)
@ -573,7 +572,6 @@ func UploadFile(ctx *context.Context) {
func UploadFilePost(ctx *context.Context) { func UploadFilePost(ctx *context.Context) {
form := web.GetForm(ctx).(*forms.UploadRepoFileForm) form := web.GetForm(ctx).(*forms.UploadRepoFileForm)
ctx.Data["PageIsUpload"] = true ctx.Data["PageIsUpload"] = true
ctx.Data["RequireTribute"] = true
upload.AddUploadContext(ctx, "repo") upload.AddUploadContext(ctx, "repo")
canCommit := renderCommitRights(ctx) canCommit := renderCommitRights(ctx)

View file

@ -849,7 +849,6 @@ func NewIssue(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.issues.new") ctx.Data["Title"] = ctx.Tr("repo.issues.new")
ctx.Data["PageIsIssueList"] = true ctx.Data["PageIsIssueList"] = true
ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks() ctx.Data["NewIssueChooseTemplate"] = ctx.HasIssueTemplatesOrContactLinks()
ctx.Data["RequireTribute"] = true
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
title := ctx.FormString("title") title := ctx.FormString("title")
ctx.Data["TitleQuery"] = title ctx.Data["TitleQuery"] = title
@ -1295,7 +1294,6 @@ func ViewIssue(ctx *context.Context) {
ctx.Data["IssueType"] = "all" ctx.Data["IssueType"] = "all"
} }
ctx.Data["RequireTribute"] = true
ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects) ctx.Data["IsProjectsEnabled"] = ctx.Repo.CanRead(unit.TypeProjects)
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment") upload.AddUploadContext(ctx, "comment")

View file

@ -28,7 +28,6 @@ func Labels(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.labels") ctx.Data["Title"] = ctx.Tr("repo.labels")
ctx.Data["PageIsIssueList"] = true ctx.Data["PageIsIssueList"] = true
ctx.Data["PageIsLabels"] = true ctx.Data["PageIsLabels"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["LabelTemplates"] = repo_module.LabelTemplates ctx.Data["LabelTemplates"] = repo_module.LabelTemplates
ctx.HTML(http.StatusOK, tplLabels) ctx.HTML(http.StatusOK, tplLabels)
} }

View file

@ -791,7 +791,6 @@ func ViewPullFiles(ctx *context.Context) {
setCompareContext(ctx, baseCommit, commit, ctx.Repo.Owner.Name, ctx.Repo.Repository.Name) 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 { if ctx.Data["Assignees"], err = repo_model.GetRepoAssignees(ctx, ctx.Repo.Repository); err != nil {
ctx.ServerError("GetAssignees", err) ctx.ServerError("GetAssignees", err)
return return
@ -1160,7 +1159,6 @@ func CompareAndPullRequestPost(ctx *context.Context) {
ctx.Data["PageIsComparePull"] = true ctx.Data["PageIsComparePull"] = true
ctx.Data["IsDiffCompare"] = true ctx.Data["IsDiffCompare"] = true
ctx.Data["IsRepoToolbarCommits"] = true ctx.Data["IsRepoToolbarCommits"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes ctx.Data["PullRequestWorkInProgressPrefixes"] = setting.Repository.PullRequest.WorkInProgressPrefixes
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "comment") upload.AddUploadContext(ctx, "comment")

View file

@ -308,7 +308,6 @@ func LatestRelease(ctx *context.Context) {
func NewRelease(ctx *context.Context) { func NewRelease(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.release.new_release") ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsReleaseList"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch ctx.Data["tag_target"] = ctx.Repo.Repository.DefaultBranch
if tagName := ctx.FormString("tag"); len(tagName) > 0 { if tagName := ctx.FormString("tag"); len(tagName) > 0 {
rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) 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) form := web.GetForm(ctx).(*forms.NewReleaseForm)
ctx.Data["Title"] = ctx.Tr("repo.release.new_release") ctx.Data["Title"] = ctx.Tr("repo.release.new_release")
ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsReleaseList"] = true
ctx.Data["RequireTribute"] = true
if ctx.HasError() { if ctx.HasError() {
ctx.HTML(http.StatusOK, tplReleaseNew) 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["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsReleaseList"] = true
ctx.Data["PageIsEditRelease"] = true ctx.Data["PageIsEditRelease"] = true
ctx.Data["RequireTribute"] = true
ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled
upload.AddUploadContext(ctx, "release") upload.AddUploadContext(ctx, "release")
@ -514,7 +511,6 @@ func EditReleasePost(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.release.edit_release") ctx.Data["Title"] = ctx.Tr("repo.release.edit_release")
ctx.Data["PageIsReleaseList"] = true ctx.Data["PageIsReleaseList"] = true
ctx.Data["PageIsEditRelease"] = true ctx.Data["PageIsEditRelease"] = true
ctx.Data["RequireTribute"] = true
tagName := ctx.Params("*") tagName := ctx.Params("*")
rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName) rel, err := repo_model.GetRelease(ctx.Repo.Repository.ID, tagName)

View file

@ -27,6 +27,7 @@ import (
"code.gitea.io/gitea/modules/web/routing" "code.gitea.io/gitea/modules/web/routing"
"code.gitea.io/gitea/routers/web/admin" "code.gitea.io/gitea/routers/web/admin"
"code.gitea.io/gitea/routers/web/auth" "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/events"
"code.gitea.io/gitea/routers/web/explore" "code.gitea.io/gitea/routers/web/explore"
"code.gitea.io/gitea/routers/web/feed" "code.gitea.io/gitea/routers/web/feed"
@ -1491,6 +1492,12 @@ func RegisterRoutes(m *web.Route) {
if setting.API.EnableSwagger { if setting.API.EnableSwagger {
m.Get("/swagger.v1.json", SwaggerV1Json) 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) { m.NotFound(func(w http.ResponseWriter, req *http.Request) {
ctx := context.GetContext(req) ctx := context.GetContext(req)
ctx.NotFound("", nil) ctx.NotFound("", nil)

View file

@ -15,23 +15,19 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
useServiceWorker: {{UseServiceWorker}}, useServiceWorker: {{UseServiceWorker}},
csrfToken: '{{.CsrfToken}}', csrfToken: '{{.CsrfToken}}',
pageData: {{.PageData}}, pageData: {{.PageData}},
requireTribute: {{.RequireTribute}},
notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}} notificationSettings: {{NotificationSettings}}, {{/*a map provided by NewFuncMap in helper.go*/}}
enableTimeTracking: {{EnableTimetracking}}, enableTimeTracking: {{EnableTimetracking}},
{{if .RequireTribute}} {{if or .Participants .Assignees .MentionableTeams}}
tributeValues: Array.from(new Map([ tributeValues: Array.from(new Map([
{{range .Participants}} {{- range .Participants -}}
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', ['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}], {{- end -}}
{{end}} {{- range .Assignees -}}
{{range .Assignees}} ['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}],
['{{.Name}}', {key: '{{.Name}} {{.FullName}}', value: '{{.Name}}', {{- end -}}
name: '{{.Name}}', fullname: '{{.FullName}}', avatar: '{{.AvatarLink $.Context}}'}], {{- range .MentionableTeams -}}
{{end}} ['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}', name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
{{range .MentionableTeams}} {{- end -}}
['{{$.MentionableTeamsOrg}}/{{.Name}}', {key: '{{$.MentionableTeamsOrg}}/{{.Name}}', value: '{{$.MentionableTeamsOrg}}/{{.Name}}',
name: '{{$.MentionableTeamsOrg}}/{{.Name}}', avatar: '{{$.MentionableTeamsOrgAvatar}}'}],
{{end}}
]).values()), ]).values()),
{{end}} {{end}}
mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}}, mermaidMaxSourceCharacters: {{MermaidMaxSourceCharacters}},

View file

@ -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" .}}

View file

@ -0,0 +1,5 @@
<ul>
{{range .SubNames}}
<li><a href="{{AppSubUrl}}/devtest/{{.}}">{{.}}</a></li>
{{end}}
</ul>

View file

@ -198,24 +198,21 @@
</div> </div>
{{if not $.Repository.IsArchived}} {{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 comment form">
<div class="ui top attached tabular menu"> {{template "shared/combomarkdowneditor" (dict
<a class="active write item">{{$.locale.Tr "write"}}</a> "locale" $.locale
<a class="preview item" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a> "MarkdownPreviewUrl" (print $.Repository.Link "/markup")
</div> "MarkdownPreviewContext" $.RepoLink
<div class="ui bottom attached active write tab segment"> "TextareaName" "content"
<textarea class="review-textarea js-quick-submit" tabindex="1" name="content"></textarea> "DropzoneParentContainer" ".ui.form"
</div> )}}
<div class="ui bottom attached tab preview segment markup">
{{$.locale.Tr "loading"}}
</div>
<div class="text right edit buttons"> <div class="text right edit buttons">
<button class="ui basic primary cancel button" tabindex="3">{{.locale.Tr "repo.issues.cancel"}}</button> <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> <button class="ui green save button" tabindex="2">{{.locale.Tr "repo.issues.save"}}</button>
</div> </div>
</div> </div>
</div> </template>
{{end}} {{end}}
{{template "repo/issue/view_content/reference_issue_dialog" .}} {{template "repo/issue/view_content/reference_issue_dialog" .}}

View file

@ -9,18 +9,16 @@
<input type="hidden" name="diff_start_cid"> <input type="hidden" name="diff_start_cid">
<input type="hidden" name="diff_end_cid"> <input type="hidden" name="diff_end_cid">
<input type="hidden" name="diff_base_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> {{template "shared/combomarkdowneditor" (dict
<a class="item" data-tab="preview" data-url="{{$.root.Repository.Link}}/markup" data-context="{{$.root.RepoLink}}">{{$.root.locale.Tr "preview"}}</a> "locale" $.root.locale
</div> "MarkdownPreviewUrl" (print $.root.Repository.Link "/markup")
<div class="field"> "MarkdownPreviewContext" $.root.RepoLink
<div class="ui active tab" data-tab="write"> "TextareaName" "content"
<textarea name="content" placeholder="{{$.root.locale.Tr "repo.diff.comment.placeholder"}}"></textarea> "TextareaPlaceholder" ($.locale.Tr "repo.diff.comment.placeholder")
</div> "DropzoneParentContainer" "form"
<div class="ui tab markup" data-tab="preview"> )}}
{{.locale.Tr "loading"}}
</div>
</div>
<div class="field footer gt-mx-3"> <div class="field footer gt-mx-3">
<span class="markup-info">{{svg "octicon-markup"}} {{$.root.locale.Tr "repo.diff.comment.markdown_info"}}</span> <span class="markup-info">{{svg "octicon-markup"}} {{$.root.locale.Tr "repo.diff.comment.markdown_info"}}</span>
<div class="ui right"> <div class="ui right">

View file

@ -14,7 +14,12 @@
<a class="muted close gt-px-3">{{svg "octicon-x" 16}}</a> <a class="muted close gt-px-3">{{svg "octicon-x" 16}}</a>
</div> </div>
<div class="ui field"> <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> </div>
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">

View file

@ -1,17 +1,17 @@
<div class="ui top tabular menu" data-write="write" data-preview="preview"> {{$textareaContent := .BodyQuery}}
<a class="active item" data-tab="write">{{.locale.Tr "write"}}</a> {{if not $textareaContent}}{{$textareaContent = .IssueTemplate}}{{end}}
<a class="item" data-tab="preview" data-url="{{.Repository.Link}}/markup" data-context="{{.RepoLink}}">{{.locale.Tr "preview"}}</a> {{if not $textareaContent}}{{$textareaContent = .PullRequestTemplate}}{{end}}
</div> {{if not $textareaContent}}{{$textareaContent = .content}}{{end}}
<div class="field">
<div class="ui bottom active tab" data-tab="write"> {{template "shared/combomarkdowneditor" (dict
<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}}"> "locale" $.locale
{{- if .BodyQuery}}{{.BodyQuery}}{{else if .IssueTemplate}}{{.IssueTemplate}}{{else if .PullRequestTemplate}}{{.PullRequestTemplate}}{{else}}{{.content}}{{end -}} "MarkdownPreviewUrl" (print .Repository.Link "/markup")
</textarea> "MarkdownPreviewContext" .RepoLink
</div> "TextareaName" "content"
<div class="ui bottom tab markup" data-tab="preview"> "TextareaContent" $textareaContent
{{.locale.Tr "loading"}} "DropzoneParentContainer" "form, .ui.form"
</div> )}}
</div>
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
{{template "repo/upload" .}} {{template "repo/upload" .}}

View file

@ -2,5 +2,5 @@
{{template "repo/issue/fields/header" .}} {{template "repo/issue/fields/header" .}}
{{/* FIXME: preview markdown result */}} {{/* FIXME: preview markdown result */}}
{{/* FIXME: required validation for markdown editor */}} {{/* 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> </div>

View file

@ -20,7 +20,7 @@
<div class="required field"> <div class="required field">
<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label> <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
<div class="ui small input"> <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> </div>
<div class="field label-exclusive-input-field"> <div class="field label-exclusive-input-field">

View file

@ -8,7 +8,7 @@
<div class="required field"> <div class="required field">
<label for="name">{{.locale.Tr "repo.issues.label_title"}}</label> <label for="name">{{.locale.Tr "repo.issues.label_title"}}</label>
<div class="ui small input"> <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> </div>
<div class="field label-exclusive-input-field"> <div class="field label-exclusive-input-field">

View file

@ -164,25 +164,22 @@
{{template "repo/issue/view_content/sidebar" .}} {{template "repo/issue/view_content/sidebar" .}}
</div> </div>
<div class="gt-hidden" id="edit-content-form"> <template id="issue-comment-editor-template">
<div class="ui comment form"> <div class="ui comment form">
<div class="ui top tabular menu"> {{template "shared/combomarkdowneditor" (dict
<a class="active write item">{{$.locale.Tr "write"}}</a> "locale" $.locale
<a class="preview item" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a> "MarkdownPreviewUrl" (print .Repository.Link "/markup")
</div> "MarkdownPreviewContext" .RepoLink
<div class="field"> "TextareaName" "content"
<div class="ui bottom active tab write"> "DropzoneParentContainer" ".ui.form"
<textarea tabindex="1" name="content" class="js-quick-submit"></textarea> )}}
</div>
<div class="ui bottom tab preview markup">
{{$.locale.Tr "loading"}}
</div>
</div>
{{if .IsAttachmentEnabled}} {{if .IsAttachmentEnabled}}
<div class="field"> <div class="field">
{{template "repo/upload" .}} {{template "repo/upload" .}}
</div> </div>
{{end}} {{end}}
<div class="field footer"> <div class="field footer">
<div class="text right edit"> <div class="text right edit">
<button class="ui basic secondary cancel button" tabindex="3">{{.locale.Tr "repo.issues.cancel"}}</button> <button class="ui basic secondary cancel button" tabindex="3">{{.locale.Tr "repo.issues.cancel"}}</button>
@ -190,7 +187,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </template>
{{template "repo/issue/view_content/reference_issue_dialog" .}} {{template "repo/issue/view_content/reference_issue_dialog" .}}

View file

@ -49,18 +49,17 @@
<label>{{.locale.Tr "repo.release.title"}}</label> <label>{{.locale.Tr "repo.release.title"}}</label>
<input name="title" placeholder="{{.locale.Tr "repo.release.title"}}" value="{{.title}}" autofocus required maxlength="255"> <input name="title" placeholder="{{.locale.Tr "repo.release.title"}}" value="{{.title}}" autofocus required maxlength="255">
</div> </div>
<div class="field content-editor"> <div class="field">
<label>{{.locale.Tr "repo.release.content"}}</label> <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> {{template "shared/combomarkdowneditor" (dict
<a class="preview item" data-tab="preview" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a> "locale" $.locale
</div> "MarkdownPreviewUrl" (print .Repository.Link "/markup")
<div class="ui bottom active tab" data-tab="write"> "MarkdownPreviewContext" .RepoLink
<textarea name="content">{{.content}}</textarea> "TextareaName" "content"
</div> "TextareaContent" .content
<div class="ui bottom tab markup" data-tab="preview"> "DropzoneParentContainer" "form"
{{$.locale.Tr "loading"}} )}}
</div>
</div> </div>
{{range .attachments}} {{range .attachments}}
<div class="field" id="attachment-{{.ID}}"> <div class="field" id="attachment-{{.ID}}">

View file

@ -19,15 +19,18 @@
<div class="help"> <div class="help">
{{.locale.Tr "repo.wiki.page_name_desc"}} {{.locale.Tr "repo.wiki.page_name_desc"}}
</div> </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> {{$content := .content}}
<a class="item" data-tab="preview" data-url="{{$.Repository.Link}}/markup" data-context="{{$.RepoLink}}">{{$.locale.Tr "preview"}}</a> {{if not .PageIsWikiEdit}}
</div> {{$content = .locale.Tr "repo.wiki.welcome"}}
<div class="field content" data-loading="{{.locale.Tr "loading"}}"> {{end}}
<div class="ui bottom active tab" data-tab="write"> {{template "shared/combomarkdowneditor" (dict
<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> "locale" $.locale
</div> "MarkdownPreviewUrl" (print .Repository.Link "/markup")
</div> "MarkdownPreviewContext" .RepoLink
"TextareaName" "content"
)}}
<div class="field"> <div class="field">
<input name="message" placeholder="{{.locale.Tr "repo.wiki.default_commit_message"}}"> <input name="message" placeholder="{{.locale.Tr "repo.wiki.default_commit_message"}}">
</div> </div>

View file

@ -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>

View file

@ -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;
}

View file

@ -13,7 +13,6 @@
} }
.editor-toolbar { .editor-toolbar {
max-width: calc(100vw - 80px);
border-color: var(--color-secondary); border-color: var(--color-secondary);
} }

View file

@ -29,6 +29,7 @@
@import "./form.css"; @import "./form.css";
@import "./repository.css"; @import "./repository.css";
@import "./editor.css"; @import "./editor.css";
@import "./editor-markdown.css";
@import "./organization.css"; @import "./organization.css";
@import "./user.css"; @import "./user.css";
@import "./dashboard.css"; @import "./dashboard.css";

View file

@ -2116,10 +2116,6 @@
height: 48px; height: 48px;
} }
.repository.wiki.new .ui.attached.tabular.menu.previewtabs {
margin-bottom: 15px;
}
.repository.wiki.view > .markup { .repository.wiki.view > .markup {
padding: 15px 30px; padding: 15px 30px;
} }

View file

@ -248,6 +248,11 @@ a.blob-excerpt:hover {
} }
} }
.review-box-panel .combo-markdown-editor textarea {
width: 730px;
max-width: calc(100vw - 70px);
}
#review-box { #review-box {
position: relative; position: relative;
} }

View file

@ -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;
}

View file

@ -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());
}

View file

@ -88,13 +88,13 @@ class CodeMirrorEditor {
} }
export function initEasyMDEImagePaste(easyMDE, $dropzone) { const uploadClipboardImage = async (editor, dropzone, e) => {
const $dropzone = $(dropzone);
const uploadUrl = $dropzone.attr('data-upload-url'); const uploadUrl = $dropzone.attr('data-upload-url');
const $files = $dropzone.find('.files'); const $files = $dropzone.find('.files');
if (!uploadUrl || !$files.length) return; if (!uploadUrl || !$files.length) return;
const uploadClipboardImage = async (editor, e) => {
const pastedImages = clipboardPastedImages(e); const pastedImages = clipboardPastedImages(e);
if (!pastedImages || pastedImages.length === 0) { if (!pastedImages || pastedImages.length === 0) {
return; return;
@ -115,11 +115,16 @@ export function initEasyMDEImagePaste(easyMDE, $dropzone) {
} }
}; };
export function initEasyMDEImagePaste(easyMDE, dropzone) {
if (!dropzone) return;
easyMDE.codemirror.on('paste', async (_, e) => { easyMDE.codemirror.on('paste', async (_, e) => {
return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), e); return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e);
}); });
}
$(easyMDE.element).on('paste', async (e) => {
return uploadClipboardImage(new TextareaEditor(easyMDE.element), e.originalEvent); export function initTextareaImagePaste(textarea, dropzone) {
if (!dropzone) return;
$(textarea).on('paste', async (e) => {
return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e.originalEvent);
}); });
} }

View file

@ -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();
});
});
}

View file

@ -10,17 +10,16 @@ export function initContextPopups() {
} }
export function attachRefIssueContextPopup(refIssues) { export function attachRefIssueContextPopup(refIssues) {
if (!refIssues.length) return; for (const refIssue of refIssues) {
refIssues.each(function () { if (refIssue.classList.contains('ref-external-issue')) {
if ($(this).hasClass('ref-external-issue')) {
return; return;
} }
const {owner, repo, index} = parseIssueHref($(this).attr('href')); const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
if (!owner) return; if (!owner) return;
const el = document.createElement('div'); const el = document.createElement('div');
this.parentNode.insertBefore(el, this.nextSibling); refIssue.parentNode.insertBefore(el, refIssue.nextSibling);
const view = createApp(ContextPopup); const view = createApp(ContextPopup);
@ -31,7 +30,7 @@ export function attachRefIssueContextPopup(refIssues) {
el.textContent = 'ContextPopup failed to load'; el.textContent = 'ContextPopup failed to load';
} }
createTippy(this, { createTippy(refIssue, {
content: el, content: el,
placement: 'top-start', placement: 'top-start',
interactive: true, interactive: true,
@ -40,5 +39,5 @@ export function attachRefIssueContextPopup(refIssues) {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}})); el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
} }
}); });
}); }
} }

View file

@ -1,8 +1,8 @@
import $ from 'jquery'; import $ from 'jquery';
import {initCompReactionSelector} from './comp/ReactionSelector.js'; import {initCompReactionSelector} from './comp/ReactionSelector.js';
import {initRepoIssueContentHistory} from './repo-issue-content.js'; import {initRepoIssueContentHistory} from './repo-issue-content.js';
import {validateTextareaNonEmpty} from './comp/EasyMDE.js';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js'; import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles} from './pull-view-file.js';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.js';
const {csrfToken} = window.config; const {csrfToken} = window.config;

View file

@ -1,12 +1,9 @@
import $ from 'jquery'; import $ from 'jquery';
import {htmlEscape} from 'escape-goat'; 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 {showTemporaryTooltip, createTippy} from '../modules/tippy.js';
import {hideElem, showElem, toggleElem} from '../utils/dom.js'; import {hideElem, showElem, toggleElem} from '../utils/dom.js';
import {setFileFolding} from './file-fold.js'; import {setFileFolding} from './file-fold.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
const {appSubUrl, csrfToken} = window.config; 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() { export function initRepoPullRequestUpdate() {
// Pull Request update button // Pull Request update button
const $pullUpdateButton = $('.update-button > 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) { export async function handleReply($el) {
hideElem($el); hideElem($el);
const form = $el.closest('.comment-code-cloud').find('.comment-form'); const form = $el.closest('.comment-code-cloud').find('.comment-form');
form.removeClass('gt-hidden'); form.removeClass('gt-hidden');
const $textarea = form.find('textarea'); const $textarea = form.find('textarea');
let easyMDE = getAttachedEasyMDE($textarea); let editor = getComboMarkdownEditor($textarea);
if (!easyMDE) { if (!editor) {
await attachTribute($textarea.get(), {mentions: true, emoji: true}); editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor'));
easyMDE = await createCommentEasyMDE($textarea);
} }
$textarea.focus(); editor.focus();
easyMDE.codemirror.focus(); return editor;
assignMenuAttributes(form.find('.menu'));
return easyMDE;
} }
export function initRepoPullRequestReview() { export function initRepoPullRequestReview() {
@ -494,14 +459,7 @@ export function initRepoPullRequestReview() {
const $reviewBox = $('.review-box-panel'); const $reviewBox = $('.review-box-panel');
if ($reviewBox.length === 1) { if ($reviewBox.length === 1) {
(async () => { const _promise = initComboMarkdownEditor($reviewBox.find('.combo-markdown-editor'));
// 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'));
})();
} }
// The following part is only for diff views // The following part is only for diff views
@ -565,20 +523,16 @@ export function initRepoPullRequestReview() {
} }
const td = ntr.find(`.add-comment-${side}`); 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) { 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')); const html = await $.get($(this).closest('[data-new-comment-url]').attr('data-new-comment-url'));
td.html(data); td.html(html);
commentCloud = td.find('.comment-code-cloud');
assignMenuAttributes(commentCloud.find('.menu'));
td.find("input[name='line']").val(idx); td.find("input[name='line']").val(idx);
td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed');
td.find("input[name='path']").val(path); td.find("input[name='path']").val(path);
const $textarea = commentCloud.find('textarea');
await attachTribute($textarea.get(), {mentions: true, emoji: true}); const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor'));
const easyMDE = await createCommentEasyMDE($textarea); editor.focus();
$textarea.focus();
easyMDE.codemirror.focus();
} }
}); });
} }

View file

@ -1,11 +1,8 @@
import $ from 'jquery'; import $ from 'jquery';
import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js';
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
import {initEasyMDEImagePaste} from './comp/ImagePaste.js';
import { import {
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle, initRepoIssueTitleEdit, initRepoIssueWipToggle,
initRepoPullRequestUpdate, updateIssuesMeta, handleReply initRepoPullRequestUpdate, updateIssuesMeta, handleReply
} from './repo-issue.js'; } from './repo-issue.js';
import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; import {initUnicodeEscapeButton} from './repo-unicode-escape.js';
@ -19,27 +16,27 @@ import {
import {initCitationFileCopyContent} from './citation.js'; import {initCitationFileCopyContent} from './citation.js';
import {initCompLabelEdit} from './comp/LabelEdit.js'; import {initCompLabelEdit} from './comp/LabelEdit.js';
import {initRepoDiffConversationNav} from './repo-diff.js'; import {initRepoDiffConversationNav} from './repo-diff.js';
import {attachTribute} from './tribute.js';
import {createDropzone} from './dropzone.js'; import {createDropzone} from './dropzone.js';
import {initCommentContent, initMarkupContent} from '../markup/content.js'; import {initCommentContent, initMarkupContent} from '../markup/content.js';
import {initCompReactionSelector} from './comp/ReactionSelector.js'; import {initCompReactionSelector} from './comp/ReactionSelector.js';
import {initRepoSettingBranches} from './repo-settings.js'; import {initRepoSettingBranches} from './repo-settings.js';
import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js'; import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js';
import {hideElem, showElem} from '../utils/dom.js'; import {hideElem, showElem} from '../utils/dom.js';
import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {attachRefIssueContextPopup} from './contextpopup.js'; import {attachRefIssueContextPopup} from './contextpopup.js';
const {csrfToken} = window.config; 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() { function reloadConfirmDraftComment() {
const commentTextareas = [ const commentTextareas = [
document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'), document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'),
document.querySelector('.edit_area'), document.querySelector('#comment-form textarea'),
]; ];
for (const textarea of commentTextareas) { 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. // 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(); textarea.parentElement.scrollIntoView();
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) { if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
return; return;
@ -85,25 +82,20 @@ export function initRepoCommentForm() {
}); });
} }
(async () => {
const $statusButton = $('#status-button'); const $statusButton = $('#status-button');
for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) { $statusButton.on('click', (e) => {
// Don't initialize EasyMDE for the dormant #edit-content-form e.preventDefault();
if (textarea.closest('#edit-content-form')) { $('#status').val($statusButton.data('status-val'));
continue; $('#comment-form').trigger('submit');
} });
const easyMDE = await createCommentEasyMDE(textarea, {
'onChange': () => { const _promise = initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), {
const value = easyMDE?.value().trim(); onContentChanged(editor) {
$statusButton.text($statusButton.attr(value.length === 0 ? 'data-status' : 'data-status-and-comment')); $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status'));
}, },
}); });
initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone'));
}
})();
initBranchSelector(); initBranchSelector();
initCompMarkupContentPreviewTab($commentForm);
// List submits // List submits
function initListSubmits(selector, outerSelector) { function initListSubmits(selector, outerSelector) {
@ -275,7 +267,7 @@ export function initRepoCommentForm() {
} else if (input_id === '#project_id') { } else if (input_id === '#project_id') {
icon = svg('octicon-project', 18, 'gt-mr-3'); icon = svg('octicon-project', 18, 'gt-mr-3');
} else if (input_id === '#assignee_id') { } 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(` $list.find('.selected').html(`
@ -322,22 +314,15 @@ async function onEditContent(event) {
const $editContentZone = $segment.find('.edit-content-zone'); const $editContentZone = $segment.find('.edit-content-zone');
const $renderContent = $segment.find('.render-content'); const $renderContent = $segment.find('.render-content');
const $rawContent = $segment.find('.raw-content'); const $rawContent = $segment.find('.raw-content');
let $textarea;
let easyMDE;
// Setup new form let comboMarkdownEditor;
if ($editContentZone.html().length === 0) {
$editContentZone.html($('#edit-content-form').html());
$textarea = $editContentZone.find('textarea');
await attachTribute($textarea.get(), {mentions: true, emoji: true});
let dz; const setupDropzone = async ($dropzone) => {
const $dropzone = $editContentZone.find('.dropzone'); if ($dropzone.length === 0) return null;
if ($dropzone.length === 1) {
$dropzone.data('saved', false); $dropzone.data('saved', false);
const fileUuidDict = {}; const fileUuidDict = {};
dz = await createDropzone($dropzone[0], { const dz = await createDropzone($dropzone[0], {
url: $dropzone.data('upload-url'), url: $dropzone.data('upload-url'),
headers: {'X-Csrf-Token': csrfToken}, headers: {'X-Csrf-Token': csrfToken},
maxFiles: $dropzone.data('max-file'), maxFiles: $dropzone.data('max-file'),
@ -393,36 +378,18 @@ async function onEditContent(event) {
}, },
}); });
dz.emit('reload'); dz.emit('reload');
} return dz;
// 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); const cancelAndReset = (dz) => {
initEasyMDEImagePaste(easyMDE, $dropzone);
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); showElem($renderContent);
hideElem($editContentZone); hideElem($editContentZone);
if (dz) { if (dz) {
dz.emit('reload'); dz.emit('reload');
} }
}); };
$saveButton.on('click', () => { const saveAndRefresh = (dz, $dropzone) => {
showElem($renderContent); showElem($renderContent);
hideElem($editContentZone); hideElem($editContentZone);
const $attachments = $dropzone.find('.files').find('[name=files]').map(function () { const $attachments = $dropzone.find('.files').find('[name=files]').map(function () {
@ -430,16 +397,17 @@ async function onEditContent(event) {
}).get(); }).get();
$.post($editContentZone.data('update-url'), { $.post($editContentZone.data('update-url'), {
_csrf: csrfToken, _csrf: csrfToken,
content: $textarea.val(), content: comboMarkdownEditor.value(),
context: $editContentZone.data('context'), context: $editContentZone.data('context'),
files: $attachments, files: $attachments,
}, (data) => { }, (data) => {
if (data.length === 0 || data.content.length === 0) { if (!data.content) {
$renderContent.html($('#no-content').html()); $renderContent.html($('#no-content').html());
$rawContent.text(''); $rawContent.text('');
} else { } else {
$renderContent.html(data.content); $renderContent.html(data.content);
$rawContent.text($textarea.val()); $rawContent.text(comboMarkdownEditor.value());
const refIssues = $renderContent.find('p .ref-issue'); const refIssues = $renderContent.find('p .ref-issue');
attachRefIssueContextPopup(refIssues); attachRefIssueContextPopup(refIssues);
} }
@ -461,23 +429,33 @@ async function onEditContent(event) {
initMarkupContent(); initMarkupContent();
initCommentContent(); initCommentContent();
}); });
};
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);
}); });
} else { // use existing form $editContentZone.find('.save.button').on('click', (e) => {
$textarea = $segment.find('textarea'); e.preventDefault();
easyMDE = getAttachedEasyMDE($textarea); saveAndRefresh(dz, $dropzone);
});
} else {
comboMarkdownEditor = getComboMarkdownEditor($editContentZone.find('.combo-markdown-editor'));
} }
// Show write/preview tab and copy raw content as needed // Show write/preview tab and copy raw content as needed
showElem($editContentZone); showElem($editContentZone);
hideElem($renderContent); hideElem($renderContent);
if ($textarea.val().length === 0) { if (!comboMarkdownEditor.value()) {
$textarea.val($rawContent.text()); comboMarkdownEditor.value($rawContent.text());
easyMDE.value($rawContent.text());
} }
requestAnimationFrame(() => { comboMarkdownEditor.focus();
$textarea.focus();
easyMDE.codemirror.focus();
});
} }
export function initRepository() { export function initRepository() {
@ -575,7 +553,6 @@ export function initRepository() {
initRepoIssueCommentDelete(); initRepoIssueCommentDelete();
initRepoIssueDependencyDelete(); initRepoIssueDependencyDelete();
initRepoIssueCodeCommentCancel(); initRepoIssueCodeCommentCancel();
initRepoIssueStatusButton();
initRepoPullRequestUpdate(); initRepoPullRequestUpdate();
initCompReactionSelector(); initCompReactionSelector();
@ -592,12 +569,6 @@ export function initRepository() {
const $form = $repoComparePull.find('.pullrequest-form'); const $form = $repoComparePull.find('.pullrequest-form');
showElem($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 target = $(this).data('target');
const quote = $(`#${target}`).text().replace(/\n/g, '\n> '); const quote = $(`#${target}`).text().replace(/\n/g, '\n> ');
const content = `> ${quote}\n\n`; const content = `> ${quote}\n\n`;
let easyMDE; let editor;
if ($(this).hasClass('quote-reply-diff')) { if ($(this).hasClass('quote-reply-diff')) {
const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply'); const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply');
easyMDE = await handleReply($replyBtn); editor = await handleReply($replyBtn);
} else { } else {
// for normal issue/comment page // for normal issue/comment page
easyMDE = getAttachedEasyMDE($('#comment-form .edit_area')); editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor'));
} }
if (easyMDE) { if (editor) {
if (easyMDE.value() !== '') { if (editor.value()) {
easyMDE.value(`${easyMDE.value()}\n\n${content}`); editor.value(`${editor.value()}\n\n${content}`);
} else { } else {
easyMDE.value(`${content}`); editor.value(content);
} }
requestAnimationFrame(() => { editor.focus();
easyMDE.codemirror.focus(); editor.moveCursorToEnd();
easyMDE.codemirror.setCursor(easyMDE.codemirror.lineCount(), 0);
});
} }
}); });
} }

View file

@ -1,9 +1,6 @@
import $ from 'jquery'; 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 {hideElem, showElem} from '../utils/dom.js';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
export function initRepoRelease() { export function initRepoRelease() {
$(document).on('click', '.remove-rel-attach', function() { $(document).on('click', '.remove-rel-attach', function() {
@ -51,17 +48,9 @@ function initTagNameEditor() {
} }
function initRepoReleaseEditor() { function initRepoReleaseEditor() {
const $editor = $('.repository.new.release .content-editor'); const $editor = $('.repository.new.release .combo-markdown-editor');
if ($editor.length === 0) { if ($editor.length === 0) {
return; return;
} }
const _promise = initComboMarkdownEditor($editor);
(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);
})();
} }

View file

@ -1,194 +1,68 @@
import $ from 'jquery'; import $ from 'jquery';
import {initMarkupContent} from '../markup/content.js'; import {initMarkupContent} from '../markup/content.js';
import {attachEasyMDEToElements, codeMirrorQuickSubmit, importEasyMDE, validateTextareaNonEmpty} from './comp/EasyMDE.js'; import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js';
import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js';
const {csrfToken} = window.config; const {csrfToken} = window.config;
async function initRepoWikiFormEditor() { async function initRepoWikiFormEditor() {
const $editArea = $('.repository.wiki textarea#edit_area'); const $editArea = $('.repository.wiki .combo-markdown-editor textarea');
if (!$editArea.length) return; if (!$editArea.length) return;
let sideBySideChanges = 0;
let sideBySideTimeout = null;
let hasEasyMDE = true;
const $form = $('.repository.wiki.new .ui.form'); const $form = $('.repository.wiki.new .ui.form');
const EasyMDE = await importEasyMDE(); const $editorContainer = $form.find('.combo-markdown-editor');
const easyMDE = new EasyMDE({ let editor;
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();
});
};
setTimeout(() => { let renderRequesting = false;
if (!easyMDE.isSideBySideActive()) { let lastContent;
render(); 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 { } else {
// delay preview by keystroke counting setTimeout(renderEasyMDEPreview, 1000);
sideBySideChanges++;
if (sideBySideChanges > 10) {
render();
} }
// or delay preview by timeout };
if (sideBySideTimeout !== null) { renderEasyMDEPreview();
clearTimeout(sideBySideTimeout);
sideBySideTimeout = null; editor = await initComboMarkdownEditor($editorContainer, {
} previewMode: 'gfm',
sideBySideTimeout = setTimeout(render, 600); previewWiki: true,
} easyMDEOptions: {
}, 0); previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
if (!easyMDE.isSideBySideActive()) {
return 'Loading...';
}
return preview.innerHTML;
},
renderingConfig: {
singleLineBreaks: false
},
indentWithTabs: false,
tabSize: 4,
spellChecker: false,
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
nativeSpellcheck: true,
toolbar: ['bold', 'italic', 'strikethrough', '|', toolbar: ['bold', 'italic', 'strikethrough', '|',
'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', 'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|',
{ 'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|',
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', '|', 'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', 'horizontal-rule', '|', 'link', 'image', 'table', 'horizontal-rule', '|',
'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea'
{ ],
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', () => { $form.on('submit', () => {
if (!validateTextareaNonEmpty($editArea)) { if (!validateTextareaNonEmpty($editArea)) {
return false; 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() { export function initRepoWikiForm() {

View file

@ -1,11 +1,10 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.js'; import {emojiKeys, emojiHTML, emojiString} from './emoji.js';
import {uniq} from '../utils.js';
import {htmlEscape} from 'escape-goat'; import {htmlEscape} from 'escape-goat';
function makeCollections({mentions, emoji}) { function makeCollections({mentions, emoji}) {
const collections = []; const collections = [];
if (mentions) { if (emoji) {
collections.push({ collections.push({
trigger: ':', trigger: ':',
requireLeadingSpace: true, requireLeadingSpace: true,
@ -30,14 +29,14 @@ function makeCollections({mentions, emoji}) {
}); });
} }
if (emoji) { if (mentions) {
collections.push({ collections.push({
values: window.config.tributeValues, values: window.config.tributeValues,
requireLeadingSpace: true, requireLeadingSpace: true,
menuItemTemplate: (item) => { menuItemTemplate: (item) => {
return ` return `
<div class="tribute-item"> <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> <span class="name">${htmlEscape(item.original.name)}</span>
${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''} ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
</div> </div>
@ -49,30 +48,10 @@ function makeCollections({mentions, emoji}) {
return collections; return collections;
} }
export async function attachTribute(elementOrNodeList, {mentions, emoji} = {}) { export async function attachTribute(element, {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;
const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs'); const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
const collections = makeCollections({mentions, emoji});
const collections = makeCollections({
mentions: mentions || mentionNodes.length > 0,
emoji: emoji || emojiNodes.length > 0,
});
const tribute = new Tribute({collection: collections, noMatchTemplate: ''}); const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
for (const node of uniqueNodes) { tribute.attach(element);
tribute.attach(node);
}
return tribute; return tribute;
} }

View file

@ -4,7 +4,6 @@ import './bootstrap.js';
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue'; import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
import {initDashboardRepoList} from './components/DashboardRepoList.vue'; import {initDashboardRepoList} from './components/DashboardRepoList.vue';
import {attachTribute} from './features/tribute.js';
import {initGlobalCopyToClipboardListener} from './features/clipboard.js'; import {initGlobalCopyToClipboardListener} from './features/clipboard.js';
import {initContextPopups} from './features/contextpopup.js'; import {initContextPopups} from './features/contextpopup.js';
import {initRepoGraphGit} from './features/repo-graph.js'; import {initRepoGraphGit} from './features/repo-graph.js';
@ -110,8 +109,6 @@ onDomReady(() => {
initGlobalFormDirtyLeaveConfirm(); initGlobalFormDirtyLeaveConfirm();
initGlobalLinkActions(); initGlobalLinkActions();
attachTribute(document.querySelectorAll('#content, .emoji-input'));
initCommonIssue(); initCommonIssue();
initCommonOrganization(); initCommonOrganization();

View file

@ -30,11 +30,6 @@ export function isDarkTheme() {
return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true'; 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 // strip <tags> from a string
export function stripTags(text) { export function stripTags(text) {
return text.replace(/<[^>]*>?/gm, ''); return text.replace(/<[^>]*>?/gm, '');

View file

@ -1,6 +1,6 @@
import {expect, test} from 'vitest'; import {expect, test} from 'vitest';
import { import {
basename, extname, isObject, uniq, stripTags, joinPaths, parseIssueHref, basename, extname, isObject, stripTags, joinPaths, parseIssueHref,
prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI, prettyNumber, parseUrl, translateMonth, translateDay, blobToDataURI,
toAbsoluteUrl, toAbsoluteUrl,
} from './utils.js'; } from './utils.js';
@ -62,10 +62,6 @@ test('isObject', () => {
expect(isObject([])).toBeFalsy(); expect(isObject([])).toBeFalsy();
}); });
test('uniq', () => {
expect(uniq([1, 1, 1, 2])).toEqual([1, 2]);
});
test('stripTags', () => { test('stripTags', () => {
expect(stripTags('<a>test</a>')).toEqual('test'); expect(stripTags('<a>test</a>')).toEqual('test');
}); });