From ffb9646ce9c3d2326a3e922e58702674eb65646c Mon Sep 17 00:00:00 2001 From: nullobsi <me@nullob.si> Date: Thu, 27 May 2021 17:38:09 -0700 Subject: [PATCH] Add image description support (#7518) * recieve image descriptions under the name property * fix other components * use comment for alt and title * allow editing of file comment * allow editing of file comment in note dialog * federate note comments * use file instead of this * backend should accept comment on update * update now actually accepts comment * allow multiline descriptions * image should also have description attached * Update locales/ja-JP.yml Co-authored-by: rinsuki <428rinsuki+git@gmail.com> * Use custom component with side-by-side image * improve usability on mobile devices * revert changes * Update post-form-attaches.vue * Update drive.file.vue * Update media-caption.vue Co-authored-by: rinsuki <428rinsuki+git@gmail.com> Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> --- locales/ja-JP.yml | 3 + src/client/components/drive.file.vue | 24 ++ src/client/components/image-viewer.vue | 2 +- src/client/components/media-caption.vue | 238 ++++++++++++++++++ src/client/components/media-image.vue | 4 +- src/client/components/post-form-attaches.vue | 25 ++ src/remote/activitypub/models/image.ts | 2 +- src/remote/activitypub/renderer/document.ts | 3 +- src/remote/activitypub/renderer/image.ts | 3 +- .../api/endpoints/drive/files/update.ts | 11 + src/services/drive/upload-from-url.ts | 6 + 11 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/client/components/media-caption.vue diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e869f5b015..23f3bf7296 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -279,6 +279,7 @@ emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" unableToDelete: "削除できません" inputNewFileName: "新しいファイル名を入力してください" +inputNewDescription: "新しいキャプションを入力してください" inputNewFolderName: "新しいフォルダ名を入力してください" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。" hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。" @@ -546,6 +547,8 @@ disablePlayer: "プレイヤーを閉じる" expandTweet: "ツイートを展開する" themeEditor: "テーマエディター" description: "説明" +describeFile: "キャプションを付ける" +enterFileDescription: "キャプションを入力" author: "作者" leaveConfirm: "未保存の変更があります。破棄しますか?" manage: "管理" diff --git a/src/client/components/drive.file.vue b/src/client/components/drive.file.vue index 37b1afc1b3..3d20de23e9 100644 --- a/src/client/components/drive.file.vue +++ b/src/client/components/drive.file.vue @@ -87,6 +87,10 @@ export default defineComponent({ text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', action: this.toggleSensitive + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: this.describe }, null, { text: this.$ts.copyUrl, icon: 'fas fa-link', @@ -150,6 +154,26 @@ export default defineComponent({ }); }, + describe() { + os.popup(import('@client/components/media-caption.vue'), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: this.file.comment !== null ? this.file.comment : '', + }, + image: this.file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: this.file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + toggleSensitive() { os.api('drive/files/update', { fileId: this.file.id, diff --git a/src/client/components/image-viewer.vue b/src/client/components/image-viewer.vue index ec22bd98ec..7701ae926f 100644 --- a/src/client/components/image-viewer.vue +++ b/src/client/components/image-viewer.vue @@ -2,7 +2,7 @@ <MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> <div class="xubzgfga"> <header>{{ image.name }}</header> - <img :src="image.url" :alt="image.name" :title="image.name" @click="$refs.modal.close()"/> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> <footer> <span>{{ image.type }}</span> <span>{{ bytes(image.size) }}</span> diff --git a/src/client/components/media-caption.vue b/src/client/components/media-caption.vue new file mode 100644 index 0000000000..690927d4c5 --- /dev/null +++ b/src/client/components/media-caption.vue @@ -0,0 +1,238 @@ +<template> + <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')"> + <div class="container"> + <div class="fullwidth top-caption"> + <div class="mk-dialog"> + <header v-if="title"><Mfm :text="title"/></header> + <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea> + <div class="buttons" v-if="(showOkButton || showCancelButton)"> + <MkButton inline @click="ok" primary>{{ $ts.ok }}</MkButton> + <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton> + </div> + </div> + </div> + <div class="hdrwpsaf fullwidth"> + <header>{{ image.name }}</header> + <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/> + <footer> + <span>{{ image.type }}</span> + <span>{{ bytes(image.size) }}</span> + <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span> + </footer> + </div> + </div> + </MkModal> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkModal from '@client/components/ui/modal.vue'; +import MkButton from '@client/components/ui/button.vue'; +import bytes from '@client/filters/bytes'; +import number from '@client/filters/number'; + +export default defineComponent({ + components: { + MkModal, + MkButton, + }, + + props: { + image: { + type: Object, + required: true, + }, + title: { + type: String, + required: false + }, + input: { + required: true + }, + showOkButton: { + type: Boolean, + default: true + }, + showCancelButton: { + type: Boolean, + default: true + }, + cancelableByBgClick: { + type: Boolean, + default: true + }, + }, + + emits: ['done', 'closed'], + + data() { + return { + inputValue: this.input.default ? this.input.default : null + }; + }, + + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeydown); + }, + + methods: { + bytes, + number, + + done(canceled, result?) { + this.$emit('done', { canceled, result }); + this.$refs.modal.close(); + }, + + async ok() { + if (!this.showOkButton) return; + + const result = this.inputValue; + this.done(false, result); + }, + + cancel() { + this.done(true); + }, + + onBgClick() { + if (this.cancelableByBgClick) { + this.cancel(); + } + }, + + onKeydown(e) { + if (e.which === 27) { // ESC + this.cancel(); + } + }, + + onInputKeydown(e) { + if (e.which === 13) { // Enter + if (e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } + } +}); +</script> + +<style lang="scss" scoped> +.container { + display: flex; + width: 100%; + height: 100%; + flex-direction: row; +} +@media (max-width: 850px) { + .container { + flex-direction: column; + } + .top-caption { + padding-bottom: 8px; + } +} +.fullwidth { + width: 100%; + margin: auto; +} +.mk-dialog { + position: relative; + padding: 32px; + min-width: 320px; + max-width: 480px; + box-sizing: border-box; + text-align: center; + background: var(--panel); + border-radius: var(--radius); + margin: auto; + + > header { + margin: 0 0 8px 0; + font-weight: bold; + font-size: 20px; + } + + > .buttons { + margin-top: 16px; + + > * { + margin: 0 8px; + } + } + + > textarea { + display: block; + box-sizing: border-box; + padding: 0 24px; + margin: 0; + width: 100%; + font-size: 16px; + border: none; + border-radius: 0; + background: transparent; + color: var(--fg); + font-family: inherit; + max-width: 100%; + min-width: 100%; + min-height: 90px; + + &:focus { + outline: none; + } + + &:disabled { + opacity: 0.5; + } + } +} +.hdrwpsaf { + display: flex; + flex-direction: column; + height: 100%; + + > header, + > footer { + align-self: center; + display: inline-block; + padding: 6px 9px; + font-size: 90%; + background: rgba(0, 0, 0, 0.5); + border-radius: 6px; + color: #fff; + } + + > header { + margin-bottom: 8px; + opacity: 0.9; + } + + > img { + display: block; + flex: 1; + min-height: 0; + object-fit: contain; + width: 100%; + cursor: zoom-out; + image-orientation: from-image; + } + + > footer { + margin-top: 8px; + opacity: 0.8; + + > span + span { + margin-left: 0.5em; + padding-left: 0.5em; + border-left: solid 1px rgba(255, 255, 255, 0.5); + } + } +} +</style> diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue index 267e4debd2..863eb10272 100644 --- a/src/client/components/media-image.vue +++ b/src/client/components/media-image.vue @@ -1,6 +1,6 @@ <template> <div class="qjewsnkg" v-if="hide" @click="hide = false"> - <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.name"/> + <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <div class="text"> <div> <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> @@ -14,7 +14,7 @@ :title="image.name" @click.prevent="onClick" > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.name" :title="image.name" :cover="false"/> + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> <div class="gif" v-if="image.type === 'image/gif'">GIF</div> </a> <i class="fas fa-eye-slash" @click="hide = true"></i> diff --git a/src/client/components/post-form-attaches.vue b/src/client/components/post-form-attaches.vue index f832ea87b5..27e20fdfa8 100644 --- a/src/client/components/post-form-attaches.vue +++ b/src/client/components/post-form-attaches.vue @@ -89,6 +89,27 @@ export default defineComponent({ file.name = result; }); }, + + async describe(file) { + os.popup(import("@client/components/media-caption.vue"), { + title: this.$ts.describeFile, + input: { + placeholder: this.$ts.inputNewDescription, + default: file.comment !== null ? file.comment : "", + }, + image: file + }, { + done: result => { + if (!result || result.canceled) return; + let comment = result.result; + os.api('drive/files/update', { + fileId: file.id, + comment: comment.length == 0 ? null : comment + }); + } + }, 'closed'); + }, + showFileMenu(file, ev: MouseEvent) { if (this.menu) return; this.menu = os.modalMenu([{ @@ -99,6 +120,10 @@ export default defineComponent({ text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', action: () => { this.toggleSensitive(file) } + }, { + text: this.$ts.describeFile, + icon: 'fas fa-i-cursor', + action: () => { this.describe(file) } }, { text: this.$ts.attachCancel, icon: 'fas fa-times-circle', diff --git a/src/remote/activitypub/models/image.ts b/src/remote/activitypub/models/image.ts index 79fc2bf4a6..7bec1d6030 100644 --- a/src/remote/activitypub/models/image.ts +++ b/src/remote/activitypub/models/image.ts @@ -28,7 +28,7 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<Drive const instance = await fetchMeta(); const cache = instance.cacheRemoteFiles; - let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache); + let file = await uploadFromUrl(image.url, actor, null, image.url, image.sensitive, false, !cache, image.name); if (file.isLink) { // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 diff --git a/src/remote/activitypub/renderer/document.ts b/src/remote/activitypub/renderer/document.ts index 4f6ea8c4ee..f6e9dca45d 100644 --- a/src/remote/activitypub/renderer/document.ts +++ b/src/remote/activitypub/renderer/document.ts @@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models'; export default (file: DriveFile) => ({ type: 'Document', mediaType: file.type, - url: DriveFiles.getPublicUrl(file) + url: DriveFiles.getPublicUrl(file), + name: file.comment, }); diff --git a/src/remote/activitypub/renderer/image.ts b/src/remote/activitypub/renderer/image.ts index ce98f98c62..cbd4fbbe68 100644 --- a/src/remote/activitypub/renderer/image.ts +++ b/src/remote/activitypub/renderer/image.ts @@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models'; export default (file: DriveFile) => ({ type: 'Image', url: DriveFiles.getPublicUrl(file), - sensitive: file.isSensitive + sensitive: file.isSensitive, + name: file.comment }); diff --git a/src/server/api/endpoints/drive/files/update.ts b/src/server/api/endpoints/drive/files/update.ts index 6eda83967b..f740fea67e 100644 --- a/src/server/api/endpoints/drive/files/update.ts +++ b/src/server/api/endpoints/drive/files/update.ts @@ -49,6 +49,14 @@ export const meta = { 'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか', 'en-US': 'Whether this media is NSFW' } + }, + + comment: { + validator: $.optional.nullable.str, + default: undefined as any, + desc: { + 'ja-JP': 'コメント' + } } }, @@ -92,6 +100,8 @@ export default define(meta, async (ps, user) => { if (ps.name) file.name = ps.name; + if (ps.comment !== undefined) file.comment = ps.comment; + if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; if (ps.folderId !== undefined) { @@ -113,6 +123,7 @@ export default define(meta, async (ps, user) => { await DriveFiles.update(file.id, { name: file.name, + comment: file.comment, folderId: file.folderId, isSensitive: file.isSensitive }); diff --git a/src/services/drive/upload-from-url.ts b/src/services/drive/upload-from-url.ts index 2f4c5aeeaf..2f660d9035 100644 --- a/src/services/drive/upload-from-url.ts +++ b/src/services/drive/upload-from-url.ts @@ -25,6 +25,12 @@ export default async ( name = null; } + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name == comment) { + comment = null; + } + // Create temp file const [path, cleanup] = await createTemp();