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>     </details> --------- Co-authored-by: silverwind <me@silverwind.io>
This commit is contained in:
		
							parent
							
								
									d67e40684f
								
							
						
					
					
						commit
						5cc0801de9
					
				|  | @ -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", | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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")))) | ||||
| } | ||||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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) | ||||
| 
 | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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") | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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}}, | ||||
|  |  | |||
|  | @ -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" .}} | ||||
|  | @ -0,0 +1,5 @@ | |||
| <ul> | ||||
| 	{{range .SubNames}} | ||||
| 	<li><a href="{{AppSubUrl}}/devtest/{{.}}">{{.}}</a></li> | ||||
| 	{{end}} | ||||
| </ul> | ||||
|  | @ -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" .}} | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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" .}} | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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" .}} | ||||
| 
 | ||||
|  |  | |||
|  | @ -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}}"> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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> | ||||
|  | @ -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; | ||||
| } | ||||
|  | @ -13,7 +13,6 @@ | |||
| } | ||||
| 
 | ||||
| .editor-toolbar { | ||||
|   max-width: calc(100vw - 80px); | ||||
|   border-color: var(--color-secondary); | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -2116,10 +2116,6 @@ | |||
|   height: 48px; | ||||
| } | ||||
| 
 | ||||
| .repository.wiki.new .ui.attached.tabular.menu.previewtabs { | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
| 
 | ||||
| .repository.wiki.view > .markup { | ||||
|   padding: 15px 30px; | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  | @ -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()); | ||||
| } | ||||
|  | @ -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 = ``; | ||||
|       editor.insertPlaceholder(placeholder); | ||||
|       const data = await uploadFile(img, uploadUrl); | ||||
|       editor.replacePlaceholder(placeholder, ``); | ||||
|     const placeholder = ``; | ||||
|     editor.insertPlaceholder(placeholder); | ||||
|     const data = await uploadFile(img, uploadUrl); | ||||
|     editor.replacePlaceholder(placeholder, ``); | ||||
| 
 | ||||
|       const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid); | ||||
|       $files.append($input); | ||||
|     } | ||||
|   }; | ||||
|     const $input = $(`<input name="files" type="hidden">`).attr('id', data.uuid).val(data.uuid); | ||||
|     $files.append($input); | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| export function initEasyMDEImagePaste(easyMDE, dropzone) { | ||||
|   if (!dropzone) return; | ||||
|   easyMDE.codemirror.on('paste', async (_, e) => { | ||||
|     return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), e); | ||||
|   }); | ||||
| 
 | ||||
|   $(easyMDE.element).on('paste', async (e) => { | ||||
|     return uploadClipboardImage(new TextareaEditor(easyMDE.element), e.originalEvent); | ||||
|     return uploadClipboardImage(new CodeMirrorEditor(easyMDE.codemirror), dropzone, e); | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function initTextareaImagePaste(textarea, dropzone) { | ||||
|   if (!dropzone) return; | ||||
|   $(textarea).on('paste', async (e) => { | ||||
|     return uploadClipboardImage(new TextareaEditor(textarea), dropzone, e.originalEvent); | ||||
|   }); | ||||
| } | ||||
|  |  | |||
|  | @ -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(); | ||||
|     }); | ||||
|   }); | ||||
| } | ||||
|  | @ -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}})); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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(); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  |  | |||
|  | @ -1,11 +1,8 @@ | |||
| import $ from 'jquery'; | ||||
| import {createCommentEasyMDE, getAttachedEasyMDE} from './comp/EasyMDE.js'; | ||||
| import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js'; | ||||
| import {initEasyMDEImagePaste} from './comp/ImagePaste.js'; | ||||
| import { | ||||
|   initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete, | ||||
|   initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue, | ||||
|   initRepoIssueStatusButton, initRepoIssueTitleEdit, initRepoIssueWipToggle, | ||||
|   initRepoIssueTitleEdit, initRepoIssueWipToggle, | ||||
|   initRepoPullRequestUpdate, updateIssuesMeta, handleReply | ||||
| } from './repo-issue.js'; | ||||
| import {initUnicodeEscapeButton} from './repo-unicode-escape.js'; | ||||
|  | @ -19,27 +16,27 @@ import { | |||
| import {initCitationFileCopyContent} from './citation.js'; | ||||
| import {initCompLabelEdit} from './comp/LabelEdit.js'; | ||||
| import {initRepoDiffConversationNav} from './repo-diff.js'; | ||||
| import {attachTribute} from './tribute.js'; | ||||
| import {createDropzone} from './dropzone.js'; | ||||
| import {initCommentContent, initMarkupContent} from '../markup/content.js'; | ||||
| import {initCompReactionSelector} from './comp/ReactionSelector.js'; | ||||
| import {initRepoSettingBranches} from './repo-settings.js'; | ||||
| import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.js'; | ||||
| import {hideElem, showElem} from '../utils/dom.js'; | ||||
| import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; | ||||
| import {attachRefIssueContextPopup} from './contextpopup.js'; | ||||
| 
 | ||||
| const {csrfToken} = window.config; | ||||
| 
 | ||||
| // if there are draft comments (more than 20 chars), confirm before reloading, to avoid losing comments
 | ||||
| // if there are draft comments, confirm before reloading, to avoid losing comments
 | ||||
| function reloadConfirmDraftComment() { | ||||
|   const commentTextareas = [ | ||||
|     document.querySelector('.edit-content-zone:not(.gt-hidden) textarea'), | ||||
|     document.querySelector('.edit_area'), | ||||
|     document.querySelector('#comment-form textarea'), | ||||
|   ]; | ||||
|   for (const textarea of commentTextareas) { | ||||
|     // Most users won't feel too sad if they lose a comment with 10 or 20 chars, they can re-type these in seconds.
 | ||||
|     // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
 | ||||
|     // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
 | ||||
|     if (textarea && textarea.value.trim().length > 20) { | ||||
|     if (textarea && textarea.value.trim().length > 10) { | ||||
|       textarea.parentElement.scrollIntoView(); | ||||
|       if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) { | ||||
|         return; | ||||
|  | @ -85,25 +82,20 @@ export function initRepoCommentForm() { | |||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   (async () => { | ||||
|     const $statusButton = $('#status-button'); | ||||
|     for (const textarea of $commentForm.find('textarea:not(.review-textarea, .no-easymde)')) { | ||||
|       // Don't initialize EasyMDE for the dormant #edit-content-form
 | ||||
|       if (textarea.closest('#edit-content-form')) { | ||||
|         continue; | ||||
|       } | ||||
|       const easyMDE = await createCommentEasyMDE(textarea, { | ||||
|         'onChange': () => { | ||||
|           const value = easyMDE?.value().trim(); | ||||
|           $statusButton.text($statusButton.attr(value.length === 0 ? 'data-status' : 'data-status-and-comment')); | ||||
|         }, | ||||
|       }); | ||||
|       initEasyMDEImagePaste(easyMDE, $commentForm.find('.dropzone')); | ||||
|     } | ||||
|   })(); | ||||
|   const $statusButton = $('#status-button'); | ||||
|   $statusButton.on('click', (e) => { | ||||
|     e.preventDefault(); | ||||
|     $('#status').val($statusButton.data('status-val')); | ||||
|     $('#comment-form').trigger('submit'); | ||||
|   }); | ||||
| 
 | ||||
|   const _promise = initComboMarkdownEditor($commentForm.find('.combo-markdown-editor'), { | ||||
|     onContentChanged(editor) { | ||||
|       $statusButton.text($statusButton.attr(editor.value().trim() ? 'data-status-and-comment' : 'data-status')); | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   initBranchSelector(); | ||||
|   initCompMarkupContentPreviewTab($commentForm); | ||||
| 
 | ||||
|   // List submits
 | ||||
|   function initListSubmits(selector, outerSelector) { | ||||
|  | @ -275,7 +267,7 @@ export function initRepoCommentForm() { | |||
|       } else if (input_id === '#project_id') { | ||||
|         icon = svg('octicon-project', 18, 'gt-mr-3'); | ||||
|       } else if (input_id === '#assignee_id') { | ||||
|         icon = `<img class="ui avatar image gt-mr-3" src=${$(this).data('avatar')}>`; | ||||
|         icon = `<img class="ui avatar image gt-mr-3" alt="avatar" src=${$(this).data('avatar')}>`; | ||||
|       } | ||||
| 
 | ||||
|       $list.find('.selected').html(` | ||||
|  | @ -322,162 +314,148 @@ async function onEditContent(event) { | |||
|   const $editContentZone = $segment.find('.edit-content-zone'); | ||||
|   const $renderContent = $segment.find('.render-content'); | ||||
|   const $rawContent = $segment.find('.raw-content'); | ||||
|   let $textarea; | ||||
|   let easyMDE; | ||||
| 
 | ||||
|   // Setup new form
 | ||||
|   if ($editContentZone.html().length === 0) { | ||||
|     $editContentZone.html($('#edit-content-form').html()); | ||||
|     $textarea = $editContentZone.find('textarea'); | ||||
|     await attachTribute($textarea.get(), {mentions: true, emoji: true}); | ||||
|   let comboMarkdownEditor; | ||||
| 
 | ||||
|     let dz; | ||||
|     const $dropzone = $editContentZone.find('.dropzone'); | ||||
|     if ($dropzone.length === 1) { | ||||
|       $dropzone.data('saved', false); | ||||
|   const setupDropzone = async ($dropzone) => { | ||||
|     if ($dropzone.length === 0) return null; | ||||
|     $dropzone.data('saved', false); | ||||
| 
 | ||||
|       const fileUuidDict = {}; | ||||
|       dz = await createDropzone($dropzone[0], { | ||||
|         url: $dropzone.data('upload-url'), | ||||
|         headers: {'X-Csrf-Token': csrfToken}, | ||||
|         maxFiles: $dropzone.data('max-file'), | ||||
|         maxFilesize: $dropzone.data('max-size'), | ||||
|         acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), | ||||
|         addRemoveLinks: true, | ||||
|         dictDefaultMessage: $dropzone.data('default-message'), | ||||
|         dictInvalidFileType: $dropzone.data('invalid-input-type'), | ||||
|         dictFileTooBig: $dropzone.data('file-too-big'), | ||||
|         dictRemoveFile: $dropzone.data('remove-file'), | ||||
|         timeout: 0, | ||||
|         thumbnailMethod: 'contain', | ||||
|         thumbnailWidth: 480, | ||||
|         thumbnailHeight: 480, | ||||
|         init() { | ||||
|           this.on('success', (file, data) => { | ||||
|             file.uuid = data.uuid; | ||||
|             fileUuidDict[file.uuid] = {submitted: false}; | ||||
|             const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); | ||||
|             $dropzone.find('.files').append(input); | ||||
|     const fileUuidDict = {}; | ||||
|     const dz = await createDropzone($dropzone[0], { | ||||
|       url: $dropzone.data('upload-url'), | ||||
|       headers: {'X-Csrf-Token': csrfToken}, | ||||
|       maxFiles: $dropzone.data('max-file'), | ||||
|       maxFilesize: $dropzone.data('max-size'), | ||||
|       acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), | ||||
|       addRemoveLinks: true, | ||||
|       dictDefaultMessage: $dropzone.data('default-message'), | ||||
|       dictInvalidFileType: $dropzone.data('invalid-input-type'), | ||||
|       dictFileTooBig: $dropzone.data('file-too-big'), | ||||
|       dictRemoveFile: $dropzone.data('remove-file'), | ||||
|       timeout: 0, | ||||
|       thumbnailMethod: 'contain', | ||||
|       thumbnailWidth: 480, | ||||
|       thumbnailHeight: 480, | ||||
|       init() { | ||||
|         this.on('success', (file, data) => { | ||||
|           file.uuid = data.uuid; | ||||
|           fileUuidDict[file.uuid] = {submitted: false}; | ||||
|           const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); | ||||
|           $dropzone.find('.files').append(input); | ||||
|         }); | ||||
|         this.on('removedfile', (file) => { | ||||
|           $(`#${file.uuid}`).remove(); | ||||
|           if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) { | ||||
|             $.post($dropzone.data('remove-url'), { | ||||
|               file: file.uuid, | ||||
|               _csrf: csrfToken, | ||||
|             }); | ||||
|           } | ||||
|         }); | ||||
|         this.on('submit', () => { | ||||
|           $.each(fileUuidDict, (fileUuid) => { | ||||
|             fileUuidDict[fileUuid].submitted = true; | ||||
|           }); | ||||
|           this.on('removedfile', (file) => { | ||||
|             $(`#${file.uuid}`).remove(); | ||||
|             if ($dropzone.data('remove-url') && !fileUuidDict[file.uuid].submitted) { | ||||
|               $.post($dropzone.data('remove-url'), { | ||||
|                 file: file.uuid, | ||||
|                 _csrf: csrfToken, | ||||
|               }); | ||||
|             } | ||||
|           }); | ||||
|           this.on('submit', () => { | ||||
|             $.each(fileUuidDict, (fileUuid) => { | ||||
|               fileUuidDict[fileUuid].submitted = true; | ||||
|         }); | ||||
|         this.on('reload', () => { | ||||
|           $.getJSON($editContentZone.data('attachment-url'), (data) => { | ||||
|             dz.removeAllFiles(true); | ||||
|             $dropzone.find('.files').empty(); | ||||
|             $.each(data, function () { | ||||
|               const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`; | ||||
|               dz.emit('addedfile', this); | ||||
|               dz.emit('thumbnail', this, imgSrc); | ||||
|               dz.emit('complete', this); | ||||
|               dz.files.push(this); | ||||
|               fileUuidDict[this.uuid] = {submitted: true}; | ||||
|               $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%'); | ||||
|               const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid); | ||||
|               $dropzone.find('.files').append(input); | ||||
|             }); | ||||
|           }); | ||||
|           this.on('reload', () => { | ||||
|             $.getJSON($editContentZone.data('attachment-url'), (data) => { | ||||
|               dz.removeAllFiles(true); | ||||
|               $dropzone.find('.files').empty(); | ||||
|               $.each(data, function () { | ||||
|                 const imgSrc = `${$dropzone.data('link-url')}/${this.uuid}`; | ||||
|                 dz.emit('addedfile', this); | ||||
|                 dz.emit('thumbnail', this, imgSrc); | ||||
|                 dz.emit('complete', this); | ||||
|                 dz.files.push(this); | ||||
|                 fileUuidDict[this.uuid] = {submitted: true}; | ||||
|                 $dropzone.find(`img[src='${imgSrc}']`).css('max-width', '100%'); | ||||
|                 const input = $(`<input id="${this.uuid}" name="files" type="hidden">`).val(this.uuid); | ||||
|                 $dropzone.find('.files').append(input); | ||||
|               }); | ||||
|             }); | ||||
|           }); | ||||
|         }, | ||||
|       }); | ||||
|         }); | ||||
|       }, | ||||
|     }); | ||||
|     dz.emit('reload'); | ||||
|     return dz; | ||||
|   }; | ||||
| 
 | ||||
|   const cancelAndReset = (dz) => { | ||||
|     showElem($renderContent); | ||||
|     hideElem($editContentZone); | ||||
|     if (dz) { | ||||
|       dz.emit('reload'); | ||||
|     } | ||||
|     // Give new write/preview data-tab name to distinguish from others
 | ||||
|     const $editContentForm = $editContentZone.find('.ui.comment.form'); | ||||
|     const $tabMenu = $editContentForm.find('.tabular.menu'); | ||||
|     $tabMenu.attr('data-write', $editContentZone.data('write')); | ||||
|     $tabMenu.attr('data-preview', $editContentZone.data('preview')); | ||||
|     $tabMenu.find('.write.item').attr('data-tab', $editContentZone.data('write')); | ||||
|     $tabMenu.find('.preview.item').attr('data-tab', $editContentZone.data('preview')); | ||||
|     $editContentForm.find('.write').attr('data-tab', $editContentZone.data('write')); | ||||
|     $editContentForm.find('.preview').attr('data-tab', $editContentZone.data('preview')); | ||||
|     easyMDE = await createCommentEasyMDE($textarea); | ||||
|   }; | ||||
| 
 | ||||
|     initCompMarkupContentPreviewTab($editContentForm); | ||||
|     initEasyMDEImagePaste(easyMDE, $dropzone); | ||||
|   const saveAndRefresh = (dz, $dropzone) => { | ||||
|     showElem($renderContent); | ||||
|     hideElem($editContentZone); | ||||
|     const $attachments = $dropzone.find('.files').find('[name=files]').map(function () { | ||||
|       return $(this).val(); | ||||
|     }).get(); | ||||
|     $.post($editContentZone.data('update-url'), { | ||||
|       _csrf: csrfToken, | ||||
|       content: comboMarkdownEditor.value(), | ||||
|       context: $editContentZone.data('context'), | ||||
|       files: $attachments, | ||||
|     }, (data) => { | ||||
|       if (!data.content) { | ||||
|         $renderContent.html($('#no-content').html()); | ||||
|         $rawContent.text(''); | ||||
|       } else { | ||||
|         $renderContent.html(data.content); | ||||
|         $rawContent.text(comboMarkdownEditor.value()); | ||||
| 
 | ||||
|     const $saveButton = $editContentZone.find('.save.button'); | ||||
|     $textarea.on('ce-quick-submit', () => { | ||||
|       $saveButton.trigger('click'); | ||||
|     }); | ||||
| 
 | ||||
|     $editContentZone.find('.cancel.button').on('click', (e) => { | ||||
|       e.preventDefault(); | ||||
|       showElem($renderContent); | ||||
|       hideElem($editContentZone); | ||||
|       if (dz) { | ||||
|         dz.emit('reload'); | ||||
|         const refIssues = $renderContent.find('p .ref-issue'); | ||||
|         attachRefIssueContextPopup(refIssues); | ||||
|       } | ||||
|     }); | ||||
| 
 | ||||
|     $saveButton.on('click', () => { | ||||
|       showElem($renderContent); | ||||
|       hideElem($editContentZone); | ||||
|       const $attachments = $dropzone.find('.files').find('[name=files]').map(function () { | ||||
|         return $(this).val(); | ||||
|       }).get(); | ||||
|       $.post($editContentZone.data('update-url'), { | ||||
|         _csrf: csrfToken, | ||||
|         content: $textarea.val(), | ||||
|         context: $editContentZone.data('context'), | ||||
|         files: $attachments, | ||||
|       }, (data) => { | ||||
|         if (data.length === 0 || data.content.length === 0) { | ||||
|           $renderContent.html($('#no-content').html()); | ||||
|           $rawContent.text(''); | ||||
|         } else { | ||||
|           $renderContent.html(data.content); | ||||
|           $rawContent.text($textarea.val()); | ||||
|           const refIssues = $renderContent.find('p .ref-issue'); | ||||
|           attachRefIssueContextPopup(refIssues); | ||||
|         } | ||||
|         const $content = $segment; | ||||
|         if (!$content.find('.dropzone-attachments').length) { | ||||
|           if (data.attachments !== '') { | ||||
|             $content.append(`<div class="dropzone-attachments"></div>`); | ||||
|             $content.find('.dropzone-attachments').replaceWith(data.attachments); | ||||
|           } | ||||
|         } else if (data.attachments === '') { | ||||
|           $content.find('.dropzone-attachments').remove(); | ||||
|         } else { | ||||
|       const $content = $segment; | ||||
|       if (!$content.find('.dropzone-attachments').length) { | ||||
|         if (data.attachments !== '') { | ||||
|           $content.append(`<div class="dropzone-attachments"></div>`); | ||||
|           $content.find('.dropzone-attachments').replaceWith(data.attachments); | ||||
|         } | ||||
|         if (dz) { | ||||
|           dz.emit('submit'); | ||||
|           dz.emit('reload'); | ||||
|         } | ||||
|         initMarkupContent(); | ||||
|         initCommentContent(); | ||||
|       }); | ||||
|       } else if (data.attachments === '') { | ||||
|         $content.find('.dropzone-attachments').remove(); | ||||
|       } else { | ||||
|         $content.find('.dropzone-attachments').replaceWith(data.attachments); | ||||
|       } | ||||
|       if (dz) { | ||||
|         dz.emit('submit'); | ||||
|         dz.emit('reload'); | ||||
|       } | ||||
|       initMarkupContent(); | ||||
|       initCommentContent(); | ||||
|     }); | ||||
|   } else { // use existing form
 | ||||
|     $textarea = $segment.find('textarea'); | ||||
|     easyMDE = getAttachedEasyMDE($textarea); | ||||
|   }; | ||||
| 
 | ||||
|   if (!$editContentZone.html()) { | ||||
|     $editContentZone.html($('#issue-comment-editor-template').html()); | ||||
|     comboMarkdownEditor = await initComboMarkdownEditor($editContentZone.find('.combo-markdown-editor')); | ||||
| 
 | ||||
|     const $dropzone = $editContentZone.find('.dropzone'); | ||||
|     const dz = await setupDropzone($dropzone); | ||||
|     $editContentZone.find('.cancel.button').on('click', (e) => { | ||||
|       e.preventDefault(); | ||||
|       cancelAndReset(dz); | ||||
|     }); | ||||
|     $editContentZone.find('.save.button').on('click', (e) => { | ||||
|       e.preventDefault(); | ||||
|       saveAndRefresh(dz, $dropzone); | ||||
|     }); | ||||
|   } else { | ||||
|     comboMarkdownEditor = getComboMarkdownEditor($editContentZone.find('.combo-markdown-editor')); | ||||
|   } | ||||
| 
 | ||||
|   // Show write/preview tab and copy raw content as needed
 | ||||
|   showElem($editContentZone); | ||||
|   hideElem($renderContent); | ||||
|   if ($textarea.val().length === 0) { | ||||
|     $textarea.val($rawContent.text()); | ||||
|     easyMDE.value($rawContent.text()); | ||||
|   if (!comboMarkdownEditor.value()) { | ||||
|     comboMarkdownEditor.value($rawContent.text()); | ||||
|   } | ||||
|   requestAnimationFrame(() => { | ||||
|     $textarea.focus(); | ||||
|     easyMDE.codemirror.focus(); | ||||
|   }); | ||||
|   comboMarkdownEditor.focus(); | ||||
| } | ||||
| 
 | ||||
| export function initRepository() { | ||||
|  | @ -575,7 +553,6 @@ export function initRepository() { | |||
|     initRepoIssueCommentDelete(); | ||||
|     initRepoIssueDependencyDelete(); | ||||
|     initRepoIssueCodeCommentCancel(); | ||||
|     initRepoIssueStatusButton(); | ||||
|     initRepoPullRequestUpdate(); | ||||
|     initCompReactionSelector(); | ||||
| 
 | ||||
|  | @ -592,12 +569,6 @@ export function initRepository() { | |||
| 
 | ||||
|       const $form = $repoComparePull.find('.pullrequest-form'); | ||||
|       showElem($form); | ||||
|       $form.find('textarea.edit_area').each(function() { | ||||
|         const easyMDE = getAttachedEasyMDE($(this)); | ||||
|         if (easyMDE) { | ||||
|           easyMDE.codemirror.refresh(); | ||||
|         } | ||||
|       }); | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|  | @ -614,24 +585,22 @@ function initRepoIssueCommentEdit() { | |||
|     const target = $(this).data('target'); | ||||
|     const quote = $(`#${target}`).text().replace(/\n/g, '\n> '); | ||||
|     const content = `> ${quote}\n\n`; | ||||
|     let easyMDE; | ||||
|     let editor; | ||||
|     if ($(this).hasClass('quote-reply-diff')) { | ||||
|       const $replyBtn = $(this).closest('.comment-code-cloud').find('button.comment-form-reply'); | ||||
|       easyMDE = await handleReply($replyBtn); | ||||
|       editor = await handleReply($replyBtn); | ||||
|     } else { | ||||
|       // for normal issue/comment page
 | ||||
|       easyMDE = getAttachedEasyMDE($('#comment-form .edit_area')); | ||||
|       editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); | ||||
|     } | ||||
|     if (easyMDE) { | ||||
|       if (easyMDE.value() !== '') { | ||||
|         easyMDE.value(`${easyMDE.value()}\n\n${content}`); | ||||
|     if (editor) { | ||||
|       if (editor.value()) { | ||||
|         editor.value(`${editor.value()}\n\n${content}`); | ||||
|       } else { | ||||
|         easyMDE.value(`${content}`); | ||||
|         editor.value(content); | ||||
|       } | ||||
|       requestAnimationFrame(() => { | ||||
|         easyMDE.codemirror.focus(); | ||||
|         easyMDE.codemirror.setCursor(easyMDE.codemirror.lineCount(), 0); | ||||
|       }); | ||||
|       editor.focus(); | ||||
|       editor.moveCursorToEnd(); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  |  | |||
|  | @ -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); | ||||
| } | ||||
|  |  | |||
|  | @ -1,194 +1,68 @@ | |||
| import $ from 'jquery'; | ||||
| import {initMarkupContent} from '../markup/content.js'; | ||||
| import {attachEasyMDEToElements, codeMirrorQuickSubmit, importEasyMDE, validateTextareaNonEmpty} from './comp/EasyMDE.js'; | ||||
| import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js'; | ||||
| import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; | ||||
| 
 | ||||
| const {csrfToken} = window.config; | ||||
| 
 | ||||
| async function initRepoWikiFormEditor() { | ||||
|   const $editArea = $('.repository.wiki textarea#edit_area'); | ||||
|   const $editArea = $('.repository.wiki .combo-markdown-editor textarea'); | ||||
|   if (!$editArea.length) return; | ||||
| 
 | ||||
|   let sideBySideChanges = 0; | ||||
|   let sideBySideTimeout = null; | ||||
|   let hasEasyMDE = true; | ||||
| 
 | ||||
|   const $form = $('.repository.wiki.new .ui.form'); | ||||
|   const EasyMDE = await importEasyMDE(); | ||||
|   const easyMDE = new EasyMDE({ | ||||
|     autoDownloadFontAwesome: false, | ||||
|     element: $editArea[0], | ||||
|     forceSync: true, | ||||
|     previewRender(plainText, preview) { // Async method
 | ||||
|       // FIXME: still send render request when return back to edit mode
 | ||||
|       const render = function () { | ||||
|         sideBySideChanges = 0; | ||||
|         if (sideBySideTimeout !== null) { | ||||
|           clearTimeout(sideBySideTimeout); | ||||
|           sideBySideTimeout = null; | ||||
|         } | ||||
|         $.post($editArea.data('url'), { | ||||
|           _csrf: csrfToken, | ||||
|           mode: 'gfm', | ||||
|           context: $editArea.data('context'), | ||||
|           text: plainText, | ||||
|           wiki: true | ||||
|         }, (data) => { | ||||
|           preview.innerHTML = `<div class="markup ui segment">${data}</div>`; | ||||
|           initMarkupContent(); | ||||
|         }); | ||||
|       }; | ||||
|   const $editorContainer = $form.find('.combo-markdown-editor'); | ||||
|   let editor; | ||||
| 
 | ||||
|       setTimeout(() => { | ||||
|         if (!easyMDE.isSideBySideActive()) { | ||||
|           render(); | ||||
|         } else { | ||||
|           // delay preview by keystroke counting
 | ||||
|           sideBySideChanges++; | ||||
|           if (sideBySideChanges > 10) { | ||||
|             render(); | ||||
|           } | ||||
|           // or delay preview by timeout
 | ||||
|           if (sideBySideTimeout !== null) { | ||||
|             clearTimeout(sideBySideTimeout); | ||||
|             sideBySideTimeout = null; | ||||
|           } | ||||
|           sideBySideTimeout = setTimeout(render, 600); | ||||
|         } | ||||
|       }, 0); | ||||
|       if (!easyMDE.isSideBySideActive()) { | ||||
|         return 'Loading...'; | ||||
|       } | ||||
|       return preview.innerHTML; | ||||
|   let renderRequesting = false; | ||||
|   let lastContent; | ||||
|   const renderEasyMDEPreview = function () { | ||||
|     if (renderRequesting) return; | ||||
| 
 | ||||
|     const $previewFull = $editorContainer.find('.EasyMDEContainer .editor-preview-active'); | ||||
|     const $previewSide = $editorContainer.find('.EasyMDEContainer .editor-preview-active-side'); | ||||
|     const $previewTarget = $previewSide.length ? $previewSide : $previewFull; | ||||
|     const newContent = $editArea.val(); | ||||
|     if (editor && $previewTarget.length && lastContent !== newContent) { | ||||
|       renderRequesting = true; | ||||
|       $.post(editor.previewUrl, { | ||||
|         _csrf: csrfToken, | ||||
|         mode: editor.previewMode, | ||||
|         context: editor.previewContext, | ||||
|         text: newContent, | ||||
|         wiki: editor.previewWiki, | ||||
|       }).done((data) => { | ||||
|         lastContent = newContent; | ||||
|         $previewTarget.html(`<div class="markup ui segment">${data}</div>`); | ||||
|         initMarkupContent(); | ||||
|       }).always(() => { | ||||
|         renderRequesting = false; | ||||
|         setTimeout(renderEasyMDEPreview, 1000); | ||||
|       }); | ||||
|     } else { | ||||
|       setTimeout(renderEasyMDEPreview, 1000); | ||||
|     } | ||||
|   }; | ||||
|   renderEasyMDEPreview(); | ||||
| 
 | ||||
|   editor = await initComboMarkdownEditor($editorContainer, { | ||||
|     previewMode: 'gfm', | ||||
|     previewWiki: true, | ||||
|     easyMDEOptions: { | ||||
|       previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
 | ||||
|       toolbar: ['bold', 'italic', 'strikethrough', '|', | ||||
|         'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', | ||||
|         'gitea-code-inline', 'code', 'quote', '|', 'gitea-checkbox-empty', 'gitea-checkbox-checked', '|', | ||||
|         'unordered-list', 'ordered-list', '|', | ||||
|         'link', 'image', 'table', 'horizontal-rule', '|', | ||||
|         'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea' | ||||
|       ], | ||||
|     }, | ||||
|     renderingConfig: { | ||||
|       singleLineBreaks: false | ||||
|     }, | ||||
|     indentWithTabs: false, | ||||
|     tabSize: 4, | ||||
|     spellChecker: false, | ||||
|     inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
 | ||||
|     nativeSpellcheck: true, | ||||
|     toolbar: ['bold', 'italic', 'strikethrough', '|', | ||||
|       'heading-1', 'heading-2', 'heading-3', 'heading-bigger', 'heading-smaller', '|', | ||||
|       { | ||||
|         name: 'code-inline', | ||||
|         action(e) { | ||||
|           const cm = e.codemirror; | ||||
|           const selection = cm.getSelection(); | ||||
|           cm.replaceSelection(`\`${selection}\``); | ||||
|           if (!selection) { | ||||
|             const cursorPos = cm.getCursor(); | ||||
|             cm.setCursor(cursorPos.line, cursorPos.ch - 1); | ||||
|           } | ||||
|           cm.focus(); | ||||
|         }, | ||||
|         className: 'fa fa-angle-right', | ||||
|         title: 'Add Inline Code', | ||||
|       }, 'code', 'quote', '|', { | ||||
|         name: 'checkbox-empty', | ||||
|         action(e) { | ||||
|           const cm = e.codemirror; | ||||
|           cm.replaceSelection(`\n- [ ] ${cm.getSelection()}`); | ||||
|           cm.focus(); | ||||
|         }, | ||||
|         className: 'fa fa-square-o', | ||||
|         title: 'Add Checkbox (empty)', | ||||
|       }, | ||||
|       { | ||||
|         name: 'checkbox-checked', | ||||
|         action(e) { | ||||
|           const cm = e.codemirror; | ||||
|           cm.replaceSelection(`\n- [x] ${cm.getSelection()}`); | ||||
|           cm.focus(); | ||||
|         }, | ||||
|         className: 'fa fa-check-square-o', | ||||
|         title: 'Add Checkbox (checked)', | ||||
|       }, '|', | ||||
|       'unordered-list', 'ordered-list', '|', | ||||
|       'link', 'image', 'table', 'horizontal-rule', '|', | ||||
|       'clean-block', 'preview', 'fullscreen', 'side-by-side', '|', | ||||
|       { | ||||
|         name: 'revert-to-textarea', | ||||
|         action(e) { | ||||
|           e.toTextArea(); | ||||
|           hasEasyMDE = false; | ||||
|           const $root = $form.find('.field.content'); | ||||
|           const loading = $root.data('loading'); | ||||
|           $root.append(`<div class="ui bottom tab markup" data-tab="preview">${loading}</div>`); | ||||
|           initCompMarkupContentPreviewTab($form); | ||||
|         }, | ||||
|         className: 'fa fa-file', | ||||
|         title: 'Revert to simple textarea', | ||||
|       }, | ||||
|     ] | ||||
|   }); | ||||
| 
 | ||||
|   easyMDE.codemirror.setOption('extraKeys', { | ||||
|     'Cmd-Enter': codeMirrorQuickSubmit, | ||||
|     'Ctrl-Enter': codeMirrorQuickSubmit, | ||||
|   }); | ||||
| 
 | ||||
|   attachEasyMDEToElements(easyMDE); | ||||
| 
 | ||||
|   $form.on('submit', () => { | ||||
|     if (!validateTextareaNonEmpty($editArea)) { | ||||
|       return false; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   setTimeout(() => { | ||||
|     const $bEdit = $('.repository.wiki.new .previewtabs a[data-tab="write"]'); | ||||
|     const $bPrev = $('.repository.wiki.new .previewtabs a[data-tab="preview"]'); | ||||
|     const $toolbar = $('.editor-toolbar'); | ||||
|     const $bPreview = $('.editor-toolbar button.preview'); | ||||
|     const $bSideBySide = $('.editor-toolbar a.fa-columns'); | ||||
|     $bEdit.on('click', (e) => { | ||||
|       if (!hasEasyMDE) { | ||||
|         return false; | ||||
|       } | ||||
|       e.stopImmediatePropagation(); | ||||
|       if ($toolbar.hasClass('disabled-for-preview')) { | ||||
|         $bPreview.trigger('click'); | ||||
|       } | ||||
| 
 | ||||
|       return false; | ||||
|     }); | ||||
|     $bPrev.on('click', (e) => { | ||||
|       if (!hasEasyMDE) { | ||||
|         return false; | ||||
|       } | ||||
|       e.stopImmediatePropagation(); | ||||
|       if (!$toolbar.hasClass('disabled-for-preview')) { | ||||
|         $bPreview.trigger('click'); | ||||
|       } | ||||
|       return false; | ||||
|     }); | ||||
|     $bPreview.on('click', () => { | ||||
|       setTimeout(() => { | ||||
|         if ($toolbar.hasClass('disabled-for-preview')) { | ||||
|           if ($bEdit.hasClass('active')) { | ||||
|             $bEdit.removeClass('active'); | ||||
|           } | ||||
|           if (!$bPrev.hasClass('active')) { | ||||
|             $bPrev.addClass('active'); | ||||
|           } | ||||
|         } else { | ||||
|           if (!$bEdit.hasClass('active')) { | ||||
|             $bEdit.addClass('active'); | ||||
|           } | ||||
|           if ($bPrev.hasClass('active')) { | ||||
|             $bPrev.removeClass('active'); | ||||
|           } | ||||
|         } | ||||
|       }, 0); | ||||
| 
 | ||||
|       return false; | ||||
|     }); | ||||
|     $bSideBySide.on('click', () => { | ||||
|       sideBySideChanges = 10; | ||||
|     }); | ||||
|   }, 0); | ||||
| } | ||||
| 
 | ||||
| export function initRepoWikiForm() { | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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, ''); | ||||
|  |  | |||
|  | @ -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'); | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue