mirror of
				https://github.com/go-gitea/gitea.git
				synced 2025-10-31 21:28:11 +09:00 
			
		
		
		
	Add attachment support for code review comments (#29220)
Fixes #27960, #24411, #12183 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
		| @@ -855,6 +855,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment | ||||
| 	// Check comment type. | ||||
| 	switch opts.Type { | ||||
| 	case CommentTypeCode: | ||||
| 		if err = updateAttachments(ctx, opts, comment); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
| 		if comment.ReviewID != 0 { | ||||
| 			if comment.Review == nil { | ||||
| 				if err := comment.loadReview(ctx); err != nil { | ||||
| @@ -872,22 +875,9 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment | ||||
| 		} | ||||
| 		fallthrough | ||||
| 	case CommentTypeReview: | ||||
| 		// Check attachments | ||||
| 		attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) | ||||
| 		if err != nil { | ||||
| 			return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) | ||||
| 		if err = updateAttachments(ctx, opts, comment); err != nil { | ||||
| 			return err | ||||
| 		} | ||||
|  | ||||
| 		for i := range attachments { | ||||
| 			attachments[i].IssueID = opts.Issue.ID | ||||
| 			attachments[i].CommentID = comment.ID | ||||
| 			// No assign value could be 0, so ignore AllCols(). | ||||
| 			if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil { | ||||
| 				return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err) | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		comment.Attachments = attachments | ||||
| 	case CommentTypeReopen, CommentTypeClose: | ||||
| 		if err = repo_model.UpdateRepoIssueNumbers(ctx, opts.Issue.RepoID, opts.Issue.IsPull, true); err != nil { | ||||
| 			return err | ||||
| @@ -897,6 +887,23 @@ func updateCommentInfos(ctx context.Context, opts *CreateCommentOptions, comment | ||||
| 	return UpdateIssueCols(ctx, opts.Issue, "updated_unix") | ||||
| } | ||||
|  | ||||
| func updateAttachments(ctx context.Context, opts *CreateCommentOptions, comment *Comment) error { | ||||
| 	attachments, err := repo_model.GetAttachmentsByUUIDs(ctx, opts.Attachments) | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("getAttachmentsByUUIDs [uuids: %v]: %w", opts.Attachments, err) | ||||
| 	} | ||||
| 	for i := range attachments { | ||||
| 		attachments[i].IssueID = opts.Issue.ID | ||||
| 		attachments[i].CommentID = comment.ID | ||||
| 		// No assign value could be 0, so ignore AllCols(). | ||||
| 		if _, err = db.GetEngine(ctx).ID(attachments[i].ID).Update(attachments[i]); err != nil { | ||||
| 			return fmt.Errorf("update attachment [%d]: %w", attachments[i].ID, err) | ||||
| 		} | ||||
| 	} | ||||
| 	comment.Attachments = attachments | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| func createDeadlineComment(ctx context.Context, doer *user_model.User, issue *Issue, newDeadlineUnix timeutil.TimeStamp) (*Comment, error) { | ||||
| 	var content string | ||||
| 	var commentType CommentType | ||||
|   | ||||
| @@ -362,6 +362,7 @@ func CreatePullReview(ctx *context.APIContext) { | ||||
| 			true, // pending review | ||||
| 			0,    // no reply | ||||
| 			opts.CommitID, | ||||
| 			nil, | ||||
| 		); err != nil { | ||||
| 			ctx.Error(http.StatusInternalServerError, "CreateCodeComment", err) | ||||
| 			return | ||||
|   | ||||
| @@ -1718,6 +1718,10 @@ func ViewIssue(ctx *context.Context) { | ||||
| 			for _, codeComments := range comment.Review.CodeComments { | ||||
| 				for _, lineComments := range codeComments { | ||||
| 					for _, c := range lineComments { | ||||
| 						if err := c.LoadAttachments(ctx); err != nil { | ||||
| 							ctx.ServerError("LoadAttachments", err) | ||||
| 							return | ||||
| 						} | ||||
| 						// Check tag. | ||||
| 						role, ok = marked[c.PosterID] | ||||
| 						if ok { | ||||
|   | ||||
| @@ -970,6 +970,19 @@ func viewPullFiles(ctx *context.Context, specifiedStartCommit, specifiedEndCommi | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	for _, file := range diff.Files { | ||||
| 		for _, section := range file.Sections { | ||||
| 			for _, line := range section.Lines { | ||||
| 				for _, comment := range line.Comments { | ||||
| 					if err := comment.LoadAttachments(ctx); err != nil { | ||||
| 						ctx.ServerError("LoadAttachments", err) | ||||
| 						return | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	pb, err := git_model.GetFirstMatchProtectedBranchRule(ctx, pull.BaseRepoID, pull.BaseBranch) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("LoadProtectedBranch", err) | ||||
|   | ||||
| @@ -15,6 +15,7 @@ import ( | ||||
| 	"code.gitea.io/gitea/modules/json" | ||||
| 	"code.gitea.io/gitea/modules/log" | ||||
| 	"code.gitea.io/gitea/modules/setting" | ||||
| 	"code.gitea.io/gitea/modules/upload" | ||||
| 	"code.gitea.io/gitea/modules/web" | ||||
| 	"code.gitea.io/gitea/services/forms" | ||||
| 	pull_service "code.gitea.io/gitea/services/pull" | ||||
| @@ -50,6 +51,8 @@ func RenderNewCodeCommentForm(ctx *context.Context) { | ||||
| 		return | ||||
| 	} | ||||
| 	ctx.Data["AfterCommitID"] = pullHeadCommitID | ||||
| 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled | ||||
| 	upload.AddUploadContext(ctx, "comment") | ||||
| 	ctx.HTML(http.StatusOK, tplNewComment) | ||||
| } | ||||
|  | ||||
| @@ -75,6 +78,11 @@ func CreateCodeComment(ctx *context.Context) { | ||||
| 		signedLine *= -1 | ||||
| 	} | ||||
|  | ||||
| 	var attachments []string | ||||
| 	if setting.Attachment.Enabled { | ||||
| 		attachments = form.Files | ||||
| 	} | ||||
|  | ||||
| 	comment, err := pull_service.CreateCodeComment(ctx, | ||||
| 		ctx.Doer, | ||||
| 		ctx.Repo.GitRepo, | ||||
| @@ -85,6 +93,7 @@ func CreateCodeComment(ctx *context.Context) { | ||||
| 		!form.SingleReview, | ||||
| 		form.Reply, | ||||
| 		form.LatestCommitID, | ||||
| 		attachments, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		ctx.ServerError("CreateCodeComment", err) | ||||
| @@ -168,6 +177,16 @@ func renderConversation(ctx *context.Context, comment *issues_model.Comment, ori | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	for _, c := range comments { | ||||
| 		if err := c.LoadAttachments(ctx); err != nil { | ||||
| 			ctx.ServerError("LoadAttachments", err) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	ctx.Data["IsAttachmentEnabled"] = setting.Attachment.Enabled | ||||
| 	upload.AddUploadContext(ctx, "comment") | ||||
|  | ||||
| 	ctx.Data["comments"] = comments | ||||
| 	if ctx.Data["CanMarkConversation"], err = issues_model.CanMarkConversation(ctx, comment.Issue, ctx.Doer); err != nil { | ||||
| 		ctx.ServerError("CanMarkConversation", err) | ||||
|   | ||||
| @@ -39,7 +39,7 @@ func TestRenderConversation(t *testing.T) { | ||||
|  | ||||
| 	var preparedComment *issues_model.Comment | ||||
| 	run("prepare", func(t *testing.T, ctx *context.Context, resp *httptest.ResponseRecorder) { | ||||
| 		comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID) | ||||
| 		comment, err := pull.CreateCodeComment(ctx, pr.Issue.Poster, ctx.Repo.GitRepo, pr.Issue, 1, "content", "", false, 0, pr.HeadCommitID, nil) | ||||
| 		if !assert.NoError(t, err) { | ||||
| 			return | ||||
| 		} | ||||
|   | ||||
| @@ -626,6 +626,7 @@ type CodeCommentForm struct { | ||||
| 	SingleReview   bool   `form:"single_review"` | ||||
| 	Reply          int64  `form:"reply"` | ||||
| 	LatestCommitID string | ||||
| 	Files          []string | ||||
| } | ||||
|  | ||||
| // Validate validates the fields | ||||
|   | ||||
| @@ -130,6 +130,7 @@ func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *u | ||||
| 				false, // not pending review but a single review | ||||
| 				comment.ReviewID, | ||||
| 				"", | ||||
| 				nil, | ||||
| 			) | ||||
| 			if err != nil { | ||||
| 				return fmt.Errorf("CreateCodeComment failed: %w", err) | ||||
|   | ||||
| @@ -71,7 +71,7 @@ func InvalidateCodeComments(ctx context.Context, prs issues_model.PullRequestLis | ||||
| } | ||||
|  | ||||
| // CreateCodeComment creates a comment on the code line | ||||
| func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string) (*issues_model.Comment, error) { | ||||
| func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git.Repository, issue *issues_model.Issue, line int64, content, treePath string, pendingReview bool, replyReviewID int64, latestCommitID string, attachments []string) (*issues_model.Comment, error) { | ||||
| 	var ( | ||||
| 		existsReview bool | ||||
| 		err          error | ||||
| @@ -104,6 +104,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. | ||||
| 			treePath, | ||||
| 			line, | ||||
| 			replyReviewID, | ||||
| 			attachments, | ||||
| 		) | ||||
| 		if err != nil { | ||||
| 			return nil, err | ||||
| @@ -144,6 +145,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. | ||||
| 		treePath, | ||||
| 		line, | ||||
| 		review.ID, | ||||
| 		attachments, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| @@ -162,7 +164,7 @@ func CreateCodeComment(ctx context.Context, doer *user_model.User, gitRepo *git. | ||||
| } | ||||
|  | ||||
| // createCodeComment creates a plain code comment at the specified line / path | ||||
| func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64) (*issues_model.Comment, error) { | ||||
| func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_model.Repository, issue *issues_model.Issue, content, treePath string, line, reviewID int64, attachments []string) (*issues_model.Comment, error) { | ||||
| 	var commitID, patch string | ||||
| 	if err := issue.LoadPullRequest(ctx); err != nil { | ||||
| 		return nil, fmt.Errorf("LoadPullRequest: %w", err) | ||||
| @@ -260,6 +262,7 @@ func createCodeComment(ctx context.Context, doer *user_model.User, repo *repo_mo | ||||
| 		ReviewID:    reviewID, | ||||
| 		Patch:       patch, | ||||
| 		Invalidated: invalidated, | ||||
| 		Attachments: attachments, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -237,6 +237,11 @@ | ||||
| 					"TextareaName" "content" | ||||
| 					"DropzoneParentContainer" ".ui.form" | ||||
| 				)}} | ||||
| 				{{if .IsAttachmentEnabled}} | ||||
| 					<div class="field"> | ||||
| 						{{template "repo/upload" .}} | ||||
| 					</div> | ||||
| 				{{end}} | ||||
| 				<div class="text right edit buttons"> | ||||
| 					<button class="ui cancel button">{{ctx.Locale.Tr "repo.issues.cancel"}}</button> | ||||
| 					<button class="ui primary save button">{{ctx.Locale.Tr "repo.issues.save"}}</button> | ||||
|   | ||||
| @@ -19,6 +19,12 @@ | ||||
| 			"DisableAutosize" "true" | ||||
| 		)}} | ||||
|  | ||||
| 		{{if $.root.IsAttachmentEnabled}} | ||||
| 			<div class="field"> | ||||
| 				{{template "repo/upload" $.root}} | ||||
| 			</div> | ||||
| 		{{end}} | ||||
|  | ||||
| 		<div class="field footer gt-mx-3"> | ||||
| 			<span class="markup-info">{{svg "octicon-markup"}} {{ctx.Locale.Tr "repo.diff.comment.markdown_info"}}</span> | ||||
| 			<div class="gt-text-right"> | ||||
|   | ||||
| @@ -61,7 +61,10 @@ | ||||
| 			{{end}} | ||||
| 			</div> | ||||
| 			<div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div> | ||||
| 			<div class="edit-content-zone gt-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-context="{{$.root.RepoLink}}"></div> | ||||
| 			<div class="edit-content-zone gt-hidden" data-update-url="{{$.root.RepoLink}}/comments/{{.ID}}" data-context="{{$.root.RepoLink}}" data-attachment-url="{{$.root.RepoLink}}/comments/{{.ID}}/attachments"></div> | ||||
| 			{{if .Attachments}} | ||||
| 				{{template "repo/issue/view_content/attachments" dict "ctxData" $ "Attachments" .Attachments "Content" .RenderedContent}} | ||||
| 			{{end}} | ||||
| 		</div> | ||||
| 		{{$reactions := .Reactions.GroupByType}} | ||||
| 		{{if $reactions}} | ||||
|   | ||||
| @@ -94,6 +94,9 @@ | ||||
| 							</div> | ||||
| 							<div id="issuecomment-{{.ID}}-raw" class="raw-content gt-hidden">{{.Content}}</div> | ||||
| 							<div class="edit-content-zone gt-hidden" data-update-url="{{$.RepoLink}}/comments/{{.ID}}" data-context="{{$.RepoLink}}" data-attachment-url="{{$.RepoLink}}/comments/{{.ID}}/attachments"></div> | ||||
| 							{{if .Attachments}} | ||||
| 								{{template "repo/issue/view_content/attachments" dict "ctxData" $ "Attachments" .Attachments "Content" .RenderedContent}} | ||||
| 							{{end}} | ||||
| 						</div> | ||||
| 						{{$reactions := .Reactions.GroupByType}} | ||||
| 						{{if $reactions}} | ||||
|   | ||||
| @@ -200,65 +200,68 @@ export function initGlobalCommon() { | ||||
| } | ||||
|  | ||||
| export function initGlobalDropzone() { | ||||
|   // Dropzone | ||||
|   for (const el of document.querySelectorAll('.dropzone')) { | ||||
|     const $dropzone = $(el); | ||||
|     const _promise = createDropzone(el, { | ||||
|       url: $dropzone.data('upload-url'), | ||||
|       headers: {'X-Csrf-Token': csrfToken}, | ||||
|       maxFiles: $dropzone.data('max-file'), | ||||
|       maxFilesize: $dropzone.data('max-size'), | ||||
|       acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), | ||||
|       addRemoveLinks: true, | ||||
|       dictDefaultMessage: $dropzone.data('default-message'), | ||||
|       dictInvalidFileType: $dropzone.data('invalid-input-type'), | ||||
|       dictFileTooBig: $dropzone.data('file-too-big'), | ||||
|       dictRemoveFile: $dropzone.data('remove-file'), | ||||
|       timeout: 0, | ||||
|       thumbnailMethod: 'contain', | ||||
|       thumbnailWidth: 480, | ||||
|       thumbnailHeight: 480, | ||||
|       init() { | ||||
|         this.on('success', (file, data) => { | ||||
|           file.uuid = data.uuid; | ||||
|           const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); | ||||
|           $dropzone.find('.files').append(input); | ||||
|           // Create a "Copy Link" element, to conveniently copy the image | ||||
|           // or file link as Markdown to the clipboard | ||||
|           const copyLinkElement = document.createElement('div'); | ||||
|           copyLinkElement.className = 'gt-text-center'; | ||||
|           // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone | ||||
|           copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`; | ||||
|           copyLinkElement.addEventListener('click', async (e) => { | ||||
|             e.preventDefault(); | ||||
|             let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; | ||||
|             if (file.type.startsWith('image/')) { | ||||
|               fileMarkdown = `!${fileMarkdown}`; | ||||
|             } else if (file.type.startsWith('video/')) { | ||||
|               fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`; | ||||
|             } | ||||
|             const success = await clippie(fileMarkdown); | ||||
|             showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); | ||||
|           }); | ||||
|           file.previewTemplate.append(copyLinkElement); | ||||
|         }); | ||||
|         this.on('removedfile', (file) => { | ||||
|           $(`#${file.uuid}`).remove(); | ||||
|           if ($dropzone.data('remove-url')) { | ||||
|             POST($dropzone.data('remove-url'), { | ||||
|               data: new URLSearchParams({file: file.uuid}), | ||||
|             }); | ||||
|           } | ||||
|         }); | ||||
|         this.on('error', function (file, message) { | ||||
|           showErrorToast(message); | ||||
|           this.removeFile(file); | ||||
|         }); | ||||
|       }, | ||||
|     }); | ||||
|     initDropzone(el); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export function initDropzone(el) { | ||||
|   const $dropzone = $(el); | ||||
|   const _promise = createDropzone(el, { | ||||
|     url: $dropzone.data('upload-url'), | ||||
|     headers: {'X-Csrf-Token': csrfToken}, | ||||
|     maxFiles: $dropzone.data('max-file'), | ||||
|     maxFilesize: $dropzone.data('max-size'), | ||||
|     acceptedFiles: (['*/*', ''].includes($dropzone.data('accepts'))) ? null : $dropzone.data('accepts'), | ||||
|     addRemoveLinks: true, | ||||
|     dictDefaultMessage: $dropzone.data('default-message'), | ||||
|     dictInvalidFileType: $dropzone.data('invalid-input-type'), | ||||
|     dictFileTooBig: $dropzone.data('file-too-big'), | ||||
|     dictRemoveFile: $dropzone.data('remove-file'), | ||||
|     timeout: 0, | ||||
|     thumbnailMethod: 'contain', | ||||
|     thumbnailWidth: 480, | ||||
|     thumbnailHeight: 480, | ||||
|     init() { | ||||
|       this.on('success', (file, data) => { | ||||
|         file.uuid = data.uuid; | ||||
|         const input = $(`<input id="${data.uuid}" name="files" type="hidden">`).val(data.uuid); | ||||
|         $dropzone.find('.files').append(input); | ||||
|         // Create a "Copy Link" element, to conveniently copy the image | ||||
|         // or file link as Markdown to the clipboard | ||||
|         const copyLinkElement = document.createElement('div'); | ||||
|         copyLinkElement.className = 'gt-text-center'; | ||||
|         // The a element has a hardcoded cursor: pointer because the default is overridden by .dropzone | ||||
|         copyLinkElement.innerHTML = `<a href="#" style="cursor: pointer;">${svg('octicon-copy', 14, 'copy link')} Copy link</a>`; | ||||
|         copyLinkElement.addEventListener('click', async (e) => { | ||||
|           e.preventDefault(); | ||||
|           let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; | ||||
|           if (file.type.startsWith('image/')) { | ||||
|             fileMarkdown = `!${fileMarkdown}`; | ||||
|           } else if (file.type.startsWith('video/')) { | ||||
|             fileMarkdown = `<video src="/attachments/${file.uuid}" title="${htmlEscape(file.name)}" controls></video>`; | ||||
|           } | ||||
|           const success = await clippie(fileMarkdown); | ||||
|           showTemporaryTooltip(e.target, success ? i18n.copy_success : i18n.copy_error); | ||||
|         }); | ||||
|         file.previewTemplate.append(copyLinkElement); | ||||
|       }); | ||||
|       this.on('removedfile', (file) => { | ||||
|         $(`#${file.uuid}`).remove(); | ||||
|         if ($dropzone.data('remove-url')) { | ||||
|           POST($dropzone.data('remove-url'), { | ||||
|             data: new URLSearchParams({file: file.uuid}), | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|       this.on('error', function (file, message) { | ||||
|         showErrorToast(message); | ||||
|         this.removeFile(file); | ||||
|       }); | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
|  | ||||
| async function linkAction(e) { | ||||
|   // A "link-action" can post AJAX request to its "data-url" | ||||
|   // Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading. | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import {hideElem, showElem, toggleElem} from '../utils/dom.js'; | ||||
| import {setFileFolding} from './file-fold.js'; | ||||
| import {getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.js'; | ||||
| import {toAbsoluteUrl} from '../utils.js'; | ||||
| import {initDropzone} from './common-global.js'; | ||||
|  | ||||
| const {appSubUrl, csrfToken} = window.config; | ||||
|  | ||||
| @@ -382,6 +383,11 @@ export async function handleReply($el) { | ||||
|   const $textarea = form.find('textarea'); | ||||
|   let editor = getComboMarkdownEditor($textarea); | ||||
|   if (!editor) { | ||||
|     // FIXME: the initialization of the dropzone is not consistent. | ||||
|     // When the page is loaded, the dropzone is initialized by initGlobalDropzone, but the editor is not initialized. | ||||
|     // When the form is submitted and partially reload, none of them is initialized. | ||||
|     const dropzone = form.find('.dropzone')[0]; | ||||
|     if (!dropzone.dropzone) initDropzone(dropzone); | ||||
|     editor = await initComboMarkdownEditor(form.find('.combo-markdown-editor')); | ||||
|   } | ||||
|   editor.focus(); | ||||
| @@ -511,6 +517,7 @@ export function initRepoPullRequestReview() { | ||||
|       td.find("input[name='side']").val(side === 'left' ? 'previous' : 'proposed'); | ||||
|       td.find("input[name='path']").val(path); | ||||
|  | ||||
|       initDropzone(td.find('.dropzone')[0]); | ||||
|       const editor = await initComboMarkdownEditor(td.find('.combo-markdown-editor')); | ||||
|       editor.focus(); | ||||
|     } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user