diff options
46 files changed, 761 insertions, 831 deletions
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); + 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); }); } 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 dz; - const $dropzone = $editContentZone.find('.dropzone'); - if ($dropzone.length === 1) { - $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); - }); - 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; + let comboMarkdownEditor; + + const setupDropzone = async ($dropzone) => { + if ($dropzone.length === 0) return null; + $dropzone.data('saved', false); + + 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('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 $saveButton = $editContentZone.find('.save.button'); - $textarea.on('ce-quick-submit', () => { - $saveButton.trigger('click'); - }); + }; + + 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()); - $editContentZone.find('.cancel.button').on('click', (e) => { - e.preventDefault(); - showElem($renderContent); - hideElem($editContentZone); + 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 { + $content.find('.dropzone-attachments').replaceWith(data.attachments); + } if (dz) { + dz.emit('submit'); dz.emit('reload'); } + initMarkupContent(); + initCommentContent(); }); + }; - $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 { - $content.find('.dropzone-attachments').replaceWith(data.attachments); - } - if (dz) { - dz.emit('submit'); - dz.emit('reload'); - } - initMarkupContent(); - 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); + }); + $editContentZone.find('.save.button').on('click', (e) => { + e.preventDefault(); + saveAndRefresh(dz, $dropzone); }); - } else { // use existing form - $textarea = $segment.find('textarea'); - easyMDE = getAttachedEasyMDE($textarea); + } 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; - }, - 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', - }, - ] - }); + let renderRequesting = false; + let lastContent; + const renderEasyMDEPreview = function () { + if (renderRequesting) return; - easyMDE.codemirror.setOption('extraKeys', { - 'Cmd-Enter': codeMirrorQuickSubmit, - 'Ctrl-Enter': codeMirrorQuickSubmit, - }); + 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(); - attachEasyMDEToElements(easyMDE); + 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' + ], + }, + }); $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'); }); |