diff --git a/modules/setting/repository.go b/modules/setting/repository.go index f41865335f9..a4f4c4c773c 100644 --- a/modules/setting/repository.go +++ b/modules/setting/repository.go @@ -112,7 +112,7 @@ var ( AllowedTypes string DefaultPagingNum int FileMaxSize int64 - MaxFiles int64 + MaxFiles int } `ini:"repository.release"` Signing struct { @@ -226,7 +226,7 @@ var ( AllowedTypes string DefaultPagingNum int FileMaxSize int64 - MaxFiles int64 + MaxFiles int }{ AllowedTypes: "", DefaultPagingNum: 10, diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index c3ec1353a06..f2e92443ceb 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -97,6 +97,7 @@ "locked": "Locked", "copy": "Copy", "copy_url": "Copy URL", + "copy_link": "Copy link", "copy_hash": "Copy hash", "copy_content": "Copy content", "copy_branch": "Copy branch name", @@ -1547,10 +1548,9 @@ "repo.issues.num_comments": "%d comments", "repo.issues.commented_at": "commented %s", "repo.issues.delete_comment_confirm": "Are you sure you want to delete this comment?", - "repo.issues.context.copy_link": "Copy Link", - "repo.issues.context.copy_source": "Copy Source", - "repo.issues.context.quote_reply": "Quote Reply", - "repo.issues.context.reference_issue": "Reference in New Issue", + "repo.issues.context.copy_source": "Copy source", + "repo.issues.context.quote_reply": "Quote reply", + "repo.issues.context.reference_issue": "Reference in new issue", "repo.issues.context.edit": "Edit", "repo.issues.context.delete": "Delete", "repo.issues.no_content": "No description provided.", diff --git a/services/context/upload/upload.go b/services/context/upload/upload.go index 9ac37b4a7a7..922501ebb2c 100644 --- a/services/context/upload/upload.go +++ b/services/context/upload/upload.go @@ -92,27 +92,44 @@ func Verify(buf []byte, fileName, allowedTypesStr string) error { return ErrFileTypeForbidden{Type: fullMimeType} } +type uploadOptions struct { + UploadUrl string + UploadRemoveUrl string + UploadLinkUrl string + UploadAccepts string + UploadMaxFiles int + UploadMaxSize int64 + NeedUuidLink bool // issue/comment Markdown editor needs the uuid link to be copiable +} + // AddUploadContext renders template values for dropzone func AddUploadContext(ctx *context.Context, uploadType string) { switch uploadType { case "release": - ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/releases/attachments" - ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/releases/attachments/remove" - ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/releases/attachments" - ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Release.AllowedTypes, "|", ",") - ctx.Data["UploadMaxFiles"] = setting.Repository.Release.MaxFiles - ctx.Data["UploadMaxSize"] = setting.Repository.Release.FileMaxSize + ctx.Data["UploadOptions"] = uploadOptions{ + UploadUrl: ctx.Repo.RepoLink + "/releases/attachments", + UploadRemoveUrl: ctx.Repo.RepoLink + "/releases/attachments/remove", + UploadLinkUrl: ctx.Repo.RepoLink + "/releases/attachments", + UploadAccepts: strings.ReplaceAll(setting.Repository.Release.AllowedTypes, "|", ","), + UploadMaxFiles: setting.Repository.Release.MaxFiles, + UploadMaxSize: setting.Repository.Release.FileMaxSize, + } case "comment": - ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/issues/attachments" - ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/issues/attachments/remove" + var uploadLinkUrl string if len(ctx.PathParam("index")) > 0 { - ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/" + url.PathEscape(ctx.PathParam("index")) + "/attachments" + uploadLinkUrl = ctx.Repo.RepoLink + "/issues/" + url.PathEscape(ctx.PathParam("index")) + "/attachments" } else { - ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/issues/attachments" + uploadLinkUrl = ctx.Repo.RepoLink + "/issues/attachments" + } + ctx.Data["UploadOptions"] = uploadOptions{ + UploadUrl: ctx.Repo.RepoLink + "/issues/attachments", + UploadRemoveUrl: ctx.Repo.RepoLink + "/issues/attachments/remove", + UploadLinkUrl: uploadLinkUrl, + UploadAccepts: strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ","), + UploadMaxFiles: setting.Attachment.MaxFiles, + UploadMaxSize: setting.Attachment.MaxSize, + NeedUuidLink: true, } - ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",") - ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles - ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize default: setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType) } @@ -120,10 +137,12 @@ func AddUploadContext(ctx *context.Context, uploadType string) { func AddUploadContextForRepo(ctx reqctx.RequestContext, repo *repo_model.Repository) { ctxData, repoLink := ctx.GetData(), repo.Link() - ctxData["UploadUrl"] = repoLink + "/upload-file" - ctxData["UploadRemoveUrl"] = repoLink + "/upload-remove" - ctxData["UploadLinkUrl"] = repoLink + "/upload-file" - ctxData["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",") - ctxData["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles - ctxData["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize + ctxData["UploadOptions"] = uploadOptions{ + UploadUrl: repoLink + "/upload-file", + UploadRemoveUrl: repoLink + "/upload-remove", + // UploadLinkUrl: TODO: REPO-UPLOAD-FILE-VIEW: there is no endpoint for this yet, it is in "upload" table but not "attachment" table + UploadAccepts: strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ","), + UploadMaxFiles: setting.Repository.Upload.MaxFiles, + UploadMaxSize: setting.Repository.Upload.FileMaxSize, + } } diff --git a/templates/repo/diff/box.tmpl b/templates/repo/diff/box.tmpl index 8f62fcc8c3d..49ec6a97c66 100644 --- a/templates/repo/diff/box.tmpl +++ b/templates/repo/diff/box.tmpl @@ -255,7 +255,7 @@ {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{end}}
diff --git a/templates/repo/diff/comment_form.tmpl b/templates/repo/diff/comment_form.tmpl index 0f51e4fc3d6..08c3750d013 100644 --- a/templates/repo/diff/comment_form.tmpl +++ b/templates/repo/diff/comment_form.tmpl @@ -20,7 +20,7 @@
{{if $.root.IsAttachmentEnabled}}
- {{template "repo/upload" $.root}} + {{template "repo/upload" dict "UploadOptions" ctx.RootData.UploadOptions}}
{{end}} diff --git a/templates/repo/diff/new_review.tmpl b/templates/repo/diff/new_review.tmpl index 10acc886ce4..e2c5b633a95 100644 --- a/templates/repo/diff/new_review.tmpl +++ b/templates/repo/diff/new_review.tmpl @@ -27,7 +27,7 @@ {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{end}}
diff --git a/templates/repo/editor/upload.tmpl b/templates/repo/editor/upload.tmpl index ae062565d3a..1da3c4917a7 100644 --- a/templates/repo/editor/upload.tmpl +++ b/templates/repo/editor/upload.tmpl @@ -13,7 +13,7 @@ {{template "repo/editor/common_breadcrumb" .}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{template "repo/editor/commit_form" .}} diff --git a/templates/repo/issue/comment_tab.tmpl b/templates/repo/issue/comment_tab.tmpl index 95d674a8912..734ddcbc3a6 100644 --- a/templates/repo/issue/comment_tab.tmpl +++ b/templates/repo/issue/comment_tab.tmpl @@ -16,6 +16,6 @@ {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{end}} diff --git a/templates/repo/issue/fields/textarea.tmpl b/templates/repo/issue/fields/textarea.tmpl index 0788baaa669..6041d5bffdd 100644 --- a/templates/repo/issue/fields/textarea.tmpl +++ b/templates/repo/issue/fields/textarea.tmpl @@ -17,7 +17,7 @@ {{if .root.IsAttachmentEnabled}}
{{/*TODO: need to refactor the "repo/upload" template and remove this wrapper */}} - {{template "repo/upload" .root}} + {{template "repo/upload" dict "UploadOptions" ctx.RootData.UploadOptions}}
{{end}} {{end}} diff --git a/templates/repo/issue/view_content.tmpl b/templates/repo/issue/view_content.tmpl index eaee1d87731..c236ef8fcfe 100644 --- a/templates/repo/issue/view_content.tmpl +++ b/templates/repo/issue/view_content.tmpl @@ -156,7 +156,7 @@ {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{end}} diff --git a/templates/repo/issue/view_content/context_menu.tmpl b/templates/repo/issue/view_content/context_menu.tmpl index 018e51f2efc..5a482d88bb0 100644 --- a/templates/repo/issue/view_content/context_menu.tmpl +++ b/templates/repo/issue/view_content/context_menu.tmpl @@ -10,7 +10,7 @@ {{else}} {{$referenceHTMLURL = printf "%s/files#%s" $issueHTMLURL .item.HashTag}} {{end}} -
{{ctx.Locale.Tr "repo.issues.context.copy_link"}}
+
{{ctx.Locale.Tr "copy_link"}}
{{ctx.Locale.Tr "repo.issues.context.copy_source"}}
{{if ctx.RootData.IsSigned}} {{$needDivider := false}} diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 59a4b1a38c3..1588477364b 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -94,7 +94,7 @@ {{end}} {{if .IsAttachmentEnabled}}
- {{template "repo/upload" .}} + {{template "repo/upload" dict "UploadOptions" $.UploadOptions}}
{{end}} diff --git a/templates/repo/upload.tmpl b/templates/repo/upload.tmpl index 6b29838868d..d71a8844405 100644 --- a/templates/repo/upload.tmpl +++ b/templates/repo/upload.tmpl @@ -1,15 +1,19 @@ +{{$opts := $.UploadOptions}}
diff --git a/tests/integration/editor_test.go b/tests/integration/editor_test.go index fdfd9e844cc..e9adc0a86eb 100644 --- a/tests/integration/editor_test.go +++ b/tests/integration/editor_test.go @@ -423,7 +423,7 @@ func testForkToEditFile(t *testing.T, session *TestSession, user, owner, repo, b uploadForm := htmlDoc.doc.Find(".repo-file-upload.form-fetch-action") formAction := uploadForm.AttrOr("action", "") assert.Equal(t, fmt.Sprintf("/%s/%s-1/_upload/%s/%s?from_base_branch=%s&foo=bar", user, repo, branch, filePath, branch), formAction) - uploadLink := uploadForm.Find(".dropzone").AttrOr("data-link-url", "") + uploadLink := uploadForm.Find(".dropzone").AttrOr("data-upload-url", "") assert.Equal(t, fmt.Sprintf("/%s/%s-1/upload-file", user, repo), uploadLink) newBranchName := uploadForm.Find("input[name=new_branch_name]").AttrOr("value", "") assert.Equal(t, user+"-patch-1", newBranchName) diff --git a/web_src/css/features/dropzone.css b/web_src/css/features/dropzone.css index cbc32df203c..349a99946c2 100644 --- a/web_src/css/features/dropzone.css +++ b/web_src/css/features/dropzone.css @@ -1,4 +1,5 @@ -.ui .field .dropzone { +/* because dropzone css is imported after our css, so use double class selector to override dropzone styles */ +.dropzone.dropzone { border: 2px dashed var(--color-secondary); background: none; box-shadow: none; @@ -7,16 +8,62 @@ min-height: 0; } -.ui .field .dropzone .dz-message { +.dropzone.dropzone .dz-message { margin: 10px 0; } -.dropzone .dz-button { - color: var(--color-text-light) !important; +.dropzone.dropzone .dz-preview .dz-success-mark svg, +.dropzone.dropzone .dz-preview .dz-error-mark svg { + fill: currentcolor; } -.dropzone:hover .dz-button { - color: var(--color-text) !important; +.dropzone.dropzone .dz-preview .dz-progress { + /* by default the progress-bar is vertically centered (top: 50%), it's better to put it after the "details (size, filename)", + then the layout from top to bottom is: size, filename, progress */ + top: 7em; +} + +.dropzone .dz-default.dz-message .dz-button { + color: var(--color-text-light); +} + +.dropzone .dz-default.dz-message .dz-button:hover { + color: var(--color-text); +} + +.dz-custom-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--gap-block); + margin-top: var(--gap-block); +} + +.dz-custom-buttons button { + background: transparent; + color: var(--color-text-light); + border: none; +} + +.dz-custom-buttons button:hover { + color: var(--color-primary); +} + +.dropzone a.dz-filename, +.dropzone a.dz-filename *, +.dz-custom-buttons button { + cursor: pointer !important; /* override ".dz-clickable *" */ +} + +.dropzone .dz-filename span[data-dz-name] { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.dropzone .dz-filename span[data-dz-name]:hover { + display: inline-block; + overflow: visible; } .dropzone .dz-error-message { @@ -27,7 +74,11 @@ display: flex !important; align-items: center !important; justify-content: center !important; - border-radius: 0 !important; + border-radius: var(--border-radius) !important; +} + +.dropzone .dz-file-preview .dz-image { + background: var(--color-secondary-bg) !important; } .dropzone .dz-image img { @@ -51,9 +102,3 @@ .dropzone .dz-preview:hover .dz-image img { filter: opacity(0.5) !important; } - -.ui .field .dropzone .dz-preview .dz-progress { - /* by default the progress-bar is vertically centered (top: 50%), it's better to put it after the "details (size, filename)", - then the layout from top to bottom is: size, filename, progress */ - top: 7em; -} diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts index 2b923653ed3..9e1f827869d 100644 --- a/web_src/js/features/comp/EditorUpload.ts +++ b/web_src/js/features/comp/EditorUpload.ts @@ -109,7 +109,8 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop editor.insertPlaceholder(placeholder); await uploadFile(dropzoneEl, file); // the "file" will get its "uuid" during the upload - editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(file, {width, dppx})); + const fileWithUuid = {name: file.name, uuid: (file as unknown as {uuid: string}).uuid}; + editor.replacePlaceholder(placeholder, generateMarkdownLinkForAttachment(fileWithUuid, {width, dppx})); } } diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index 76e3340462b..ab6aad0355f 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -1,15 +1,23 @@ import {svgRaw} from '../svg.ts'; import {html} from '../utils/html.ts'; -import {copyToClipboardWithFeedback} from '../modules/clipboard.ts'; import {GET, POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; -import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts'; +import {createElementFromAttrs, queryElems, showElem} from '../utils/dom.ts'; import {errorMessage} from '../modules/errors.ts'; import {isImageFile, isVideoFile} from '../utils.ts'; import type Dropzone from '@deltablot/dropzone'; -type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string}; +type CustomDropzoneFile = { + uuid: string; + + // the following fields are from Dropzone.DropzoneFile + previewElement?: HTMLElement; // will be set during the "addedfile" event + name: string; + size: number; +}; + type UploadResponse = {uuid: string}; +type FileUuidDict = Record; // dropzone has its owner event dispatcher (emitter) export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files'; @@ -24,42 +32,44 @@ async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) { return new Dropzone(el, opts); } -export function generateMarkdownLinkForAttachment(file: Partial, {width, dppx}: {width?: number, dppx?: number} = {}) { - let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`; - if (isImageFile(file)) { +export function generateMarkdownLinkForAttachment(file: {uuid: string, name: string}, {width, dppx}: {width?: number, dppx?: number} = {}) { + // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" + let fileMarkdown = `[${file.name}](attachments/${file.uuid})`; + if (isImageFile({name: file.name, type: null})) { if (width && width > 0 && dppx && dppx > 1) { // Scale down images from HiDPI monitors. This uses the tag because it's the only // method to change image size in Markdown that is supported by all implementations. - // Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}" fileMarkdown = html`${file.name}`; } else { - // Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}" - // TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments" - fileMarkdown = `![${file.name}](/attachments/${file.uuid})`; + fileMarkdown = `![${file.name}](attachments/${file.uuid})`; } - } else if (isVideoFile(file)) { + } else if (isVideoFile({name: file.name, type: null})) { fileMarkdown = html``; } return fileMarkdown; } -function addCopyLink(file: Partial) { - // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard - // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone - const copyLinkEl = createElementFromHTML(html` - - `); - copyLinkEl.addEventListener('click', async (e) => { - e.preventDefault(); - await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file)); - }); - file.previewTemplate!.append(copyLinkEl); +export function decorateAttachmentPreview(dzInst: Dropzone, file: CustomDropzoneFile, attachmentBaseLinkUrl: string) { + const el = file.previewElement!; + if (attachmentBaseLinkUrl) { + // TODO: REPO-UPLOAD-FILE-VIEW: repo file upload doesn't support viewing the uploaded file yet + const fileUrl = `${attachmentBaseLinkUrl}/${file.uuid}`; + queryElems(el, 'a[data-dz-custom-link]', (elLink) => { + elLink.target = '_blank'; + elLink.href = fileUrl; + }); + } + const needUuidLink = dzInst.element.getAttribute('data-need-uuid-link') === 'true'; + if (needUuidLink) { + // only issues and comments need to show use the UUID link + const elCopyLink = el.querySelector('button[data-dz-custom-copy-link]')!; + const markdownLink = generateMarkdownLinkForAttachment(file); + el.setAttribute('data-tooltip-content', `Name: ${file.name}\nUUID: ${file.uuid}`); + elCopyLink.setAttribute('data-clipboard-text', markdownLink); + showElem(elCopyLink); + } } -type FileUuidDict = Record; - /** * @param {HTMLElement} dropzoneEl */ @@ -72,15 +82,35 @@ export async function initDropzone(dropzoneEl: HTMLElement) { let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone const opts: Dropzone.DropzoneOptions = { url: dropzoneEl.getAttribute('data-upload-url')!, - addRemoveLinks: true, - dictDefaultMessage: dropzoneEl.getAttribute('data-default-message')!, - dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type')!, - dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big')!, - dictRemoveFile: dropzoneEl.getAttribute('data-remove-file')!, + dictInvalidFileType: dropzoneEl.getAttribute('data-text-invalid-input-type')!, + dictFileTooBig: dropzoneEl.getAttribute('data-text-file-too-big')!, timeout: 0, thumbnailMethod: 'contain', thumbnailWidth: 480, thumbnailHeight: 480, + // template reference: preview-template.js in the dropzone source code + previewTemplate: html` +
+
+ +
+
+
+
+ +
+
+ +
+
+
${svgRaw('octicon-check-circle', 54, 'tw-text-green')}
+
${svgRaw('octicon-x-circle', 54, 'tw-text-red')}
+
+ + +
+
+ `, }; const accepts = dropzoneEl.getAttribute('data-accepts')!; if (!['*/*', ''].includes(accepts)) opts.acceptedFiles = accepts; @@ -96,7 +126,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) { fileUuidDict[file.uuid] = {submitted: false}; const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid}); dropzoneEl.querySelector('.files')!.append(input); - addCopyLink(file); + decorateAttachmentPreview(dzInst, file, attachmentBaseLinkUrl); dzInst.emit(DropzoneCustomEventUploadDone, {file}); }); @@ -131,14 +161,14 @@ export async function initDropzone(dropzoneEl: HTMLElement) { for (const el of dropzoneEl.querySelectorAll('.dz-preview')) el.remove(); fileUuidDict = {}; for (const attachment of respData) { - const file = {name: attachment.name, uuid: attachment.uuid, size: attachment.size}; + const file: CustomDropzoneFile = {name: attachment.name, uuid: attachment.uuid, size: attachment.size}; dzInst.emit('addedfile', file); dzInst.emit('complete', file); - if (isImageFile(file.name)) { + if (isImageFile({name: file.name, type: null})) { const imgSrc = `${attachmentBaseLinkUrl}/${file.uuid}`; dzInst.emit('thumbnail', file, imgSrc); } - addCopyLink(file); // it is from server response, so no "type" + decorateAttachmentPreview(dzInst, file, attachmentBaseLinkUrl); // it is from server response, so no "type" fileUuidDict[file.uuid] = {submitted: true}; const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${file.uuid}`, value: file.uuid}); dropzoneEl.querySelector('.files')!.append(input); diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index 6fbaf8a6681..2434e733fc2 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -1,7 +1,7 @@ import tippy, {followCursor} from 'tippy.js'; import {isDocumentFragmentOrElementNode} from '../utils/dom.ts'; import type {Content, Instance, Placement, Props} from 'tippy.js'; -import {html} from '../utils/html.ts'; +import {html, htmlEscape} from '../utils/html.ts'; import {stripTags} from '../utils.ts'; type TippyOpts = { @@ -128,6 +128,13 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc content = content ?? target.getAttribute('data-tooltip-content'); if (!content) return null; + let allowHTML = target.getAttribute('data-tooltip-render') === 'html'; + if (!allowHTML && typeof content === 'string') { + content = htmlEscape(content); + content = content.replace(/\n/g, '
'); + allowHTML = true; + } + // when element has a clipboard target, we update the tooltip after copy // in which case it is undesirable to automatically hide it on click as // it would momentarily flash the tooltip out and in. @@ -140,7 +147,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc role: 'tooltip', theme: 'tooltip', hideOnClick, - allowHTML: target.getAttribute('data-tooltip-render') === 'html', + allowHTML, placement: target.getAttribute('data-tooltip-placement') as Placement || 'top', followCursor: target.getAttribute('data-tooltip-follow-cursor') as Props['followCursor'] || false, ...((target.getAttribute('data-tooltip-interactive') === 'true') && {interactive: true, aria: {content: 'describedby', expanded: false}}), diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts index 7397346b740..1ce0ce372ef 100644 --- a/web_src/js/svg.ts +++ b/web_src/js/svg.ts @@ -13,6 +13,7 @@ import octiconBlocked from '../../public/assets/img/svg/octicon-blocked.svg'; import octiconBold from '../../public/assets/img/svg/octicon-bold.svg'; import octiconCheck from '../../public/assets/img/svg/octicon-check.svg'; import octiconCheckbox from '../../public/assets/img/svg/octicon-checkbox.svg'; +import octiconCheckCircle from '../../public/assets/img/svg/octicon-check-circle.svg'; import octiconCheckCircleFill from '../../public/assets/img/svg/octicon-check-circle-fill.svg'; import octiconChevronDown from '../../public/assets/img/svg/octicon-chevron-down.svg'; import octiconChevronLeft from '../../public/assets/img/svg/octicon-chevron-left.svg'; @@ -86,6 +87,7 @@ import octiconTag from '../../public/assets/img/svg/octicon-tag.svg'; import octiconTrash from '../../public/assets/img/svg/octicon-trash.svg'; import octiconTriangleDown from '../../public/assets/img/svg/octicon-triangle-down.svg'; import octiconX from '../../public/assets/img/svg/octicon-x.svg'; +import octiconXCircle from '../../public/assets/img/svg/octicon-x-circle.svg'; import octiconXCircleFill from '../../public/assets/img/svg/octicon-x-circle-fill.svg'; import octiconZoomIn from '../../public/assets/img/svg/octicon-zoom-in.svg'; import octiconZoomOut from '../../public/assets/img/svg/octicon-zoom-out.svg'; @@ -103,6 +105,7 @@ const svgs = { 'octicon-blocked': octiconBlocked, 'octicon-bold': octiconBold, 'octicon-check': octiconCheck, + 'octicon-check-circle': octiconCheckCircle, 'octicon-check-circle-fill': octiconCheckCircleFill, 'octicon-checkbox': octiconCheckbox, 'octicon-chevron-down': octiconChevronDown, @@ -177,6 +180,7 @@ const svgs = { 'octicon-trash': octiconTrash, 'octicon-triangle-down': octiconTriangleDown, 'octicon-x': octiconX, + 'octicon-x-circle': octiconXCircle, 'octicon-x-circle-fill': octiconXCircleFill, 'octicon-zoom-in': octiconZoomIn, 'octicon-zoom-out': octiconZoomOut, diff --git a/web_src/js/utils.test.ts b/web_src/js/utils.test.ts index 4a4104fad0f..8c0a6c1318f 100644 --- a/web_src/js/utils.test.ts +++ b/web_src/js/utils.test.ts @@ -126,16 +126,17 @@ test('formatBytes', () => { }); test('file detection', () => { + const type = null; for (const name of ['a.avif', 'a.jpg', '/a.jpeg', '.file.png', '.webp', 'file.svg']) { - expect(isImageFile({name})).toBeTruthy(); + expect(isImageFile({name, type})).toBeTruthy(); } for (const name of ['', 'a.jpg.x', '/path.png/x', 'webp']) { - expect(isImageFile({name})).toBeFalsy(); + expect(isImageFile({name, type})).toBeFalsy(); } for (const name of ['a.mpg', '/a.mpeg', '.file.mp4', '.webm', 'file.mkv']) { - expect(isVideoFile({name})).toBeTruthy(); + expect(isVideoFile({name, type})).toBeTruthy(); } for (const name of ['', 'a.mpg.x', '/path.mp4/x', 'webm']) { - expect(isVideoFile({name})).toBeFalsy(); + expect(isVideoFile({name, type})).toBeFalsy(); } }); diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 6621e570b7f..b2a4427bc6b 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -173,11 +173,11 @@ export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -export function isImageFile({name, type}: {name?: string, type?: string}): boolean { +export function isImageFile({name, type}: {name: string | null, type: string | null}): boolean { return Boolean(/\.(avif|jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/')); } -export function isVideoFile({name, type}: {name?: string, type?: string}): boolean { +export function isVideoFile({name, type}: {name: string | null, type: string | null}): boolean { return Boolean(/\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/')); }