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>
This commit is contained in:
nullobsi 2021-05-27 17:38:09 -07:00 committed by GitHub
parent db3724cf33
commit ffb9646ce9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 315 additions and 6 deletions

View File

@ -279,6 +279,7 @@ emptyDrive: "ドライブは空です"
emptyFolder: "フォルダーは空です" emptyFolder: "フォルダーは空です"
unableToDelete: "削除できません" unableToDelete: "削除できません"
inputNewFileName: "新しいファイル名を入力してください" inputNewFileName: "新しいファイル名を入力してください"
inputNewDescription: "新しいキャプションを入力してください"
inputNewFolderName: "新しいフォルダ名を入力してください" inputNewFolderName: "新しいフォルダ名を入力してください"
circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。"
hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。" hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。"
@ -546,6 +547,8 @@ disablePlayer: "プレイヤーを閉じる"
expandTweet: "ツイートを展開する" expandTweet: "ツイートを展開する"
themeEditor: "テーマエディター" themeEditor: "テーマエディター"
description: "説明" description: "説明"
describeFile: "キャプションを付ける"
enterFileDescription: "キャプションを入力"
author: "作者" author: "作者"
leaveConfirm: "未保存の変更があります。破棄しますか?" leaveConfirm: "未保存の変更があります。破棄しますか?"
manage: "管理" manage: "管理"

View File

@ -87,6 +87,10 @@ export default defineComponent({
text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash', icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
action: this.toggleSensitive action: this.toggleSensitive
}, {
text: this.$ts.describeFile,
icon: 'fas fa-i-cursor',
action: this.describe
}, null, { }, null, {
text: this.$ts.copyUrl, text: this.$ts.copyUrl,
icon: 'fas fa-link', 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() { toggleSensitive() {
os.api('drive/files/update', { os.api('drive/files/update', {
fileId: this.file.id, fileId: this.file.id,

View File

@ -2,7 +2,7 @@
<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')"> <MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
<div class="xubzgfga"> <div class="xubzgfga">
<header>{{ image.name }}</header> <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> <footer>
<span>{{ image.type }}</span> <span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span> <span>{{ bytes(image.size) }}</span>

View File

@ -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>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="qjewsnkg" v-if="hide" @click="hide = false"> <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 class="text">
<div> <div>
<b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
@ -14,7 +14,7 @@
:title="image.name" :title="image.name"
@click.prevent="onClick" @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> <div class="gif" v-if="image.type === 'image/gif'">GIF</div>
</a> </a>
<i class="fas fa-eye-slash" @click="hide = true"></i> <i class="fas fa-eye-slash" @click="hide = true"></i>

View File

@ -89,6 +89,27 @@ export default defineComponent({
file.name = result; 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) { showFileMenu(file, ev: MouseEvent) {
if (this.menu) return; if (this.menu) return;
this.menu = os.modalMenu([{ this.menu = os.modalMenu([{
@ -99,6 +120,10 @@ export default defineComponent({
text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye',
action: () => { this.toggleSensitive(file) } action: () => { this.toggleSensitive(file) }
}, {
text: this.$ts.describeFile,
icon: 'fas fa-i-cursor',
action: () => { this.describe(file) }
}, { }, {
text: this.$ts.attachCancel, text: this.$ts.attachCancel,
icon: 'fas fa-times-circle', icon: 'fas fa-times-circle',

View File

@ -28,7 +28,7 @@ export async function createImage(actor: IRemoteUser, value: any): Promise<Drive
const instance = await fetchMeta(); const instance = await fetchMeta();
const cache = instance.cacheRemoteFiles; 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) { if (file.isLink) {
// URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、 // URLが異なっている場合、同じ画像が以前に異なるURLで登録されていたということなので、

View File

@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models';
export default (file: DriveFile) => ({ export default (file: DriveFile) => ({
type: 'Document', type: 'Document',
mediaType: file.type, mediaType: file.type,
url: DriveFiles.getPublicUrl(file) url: DriveFiles.getPublicUrl(file),
name: file.comment,
}); });

View File

@ -4,5 +4,6 @@ import { DriveFiles } from '../../../models';
export default (file: DriveFile) => ({ export default (file: DriveFile) => ({
type: 'Image', type: 'Image',
url: DriveFiles.getPublicUrl(file), url: DriveFiles.getPublicUrl(file),
sensitive: file.isSensitive sensitive: file.isSensitive,
name: file.comment
}); });

View File

@ -49,6 +49,14 @@ export const meta = {
'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか', 'ja-JP': 'このメディアが「閲覧注意」(NSFW)かどうか',
'en-US': 'Whether this media is 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.name) file.name = ps.name;
if (ps.comment !== undefined) file.comment = ps.comment;
if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive; if (ps.isSensitive !== undefined) file.isSensitive = ps.isSensitive;
if (ps.folderId !== undefined) { if (ps.folderId !== undefined) {
@ -113,6 +123,7 @@ export default define(meta, async (ps, user) => {
await DriveFiles.update(file.id, { await DriveFiles.update(file.id, {
name: file.name, name: file.name,
comment: file.comment,
folderId: file.folderId, folderId: file.folderId,
isSensitive: file.isSensitive isSensitive: file.isSensitive
}); });

View File

@ -25,6 +25,12 @@ export default async (
name = null; 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 // Create temp file
const [path, cleanup] = await createTemp(); const [path, cleanup] = await createTemp();