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