enhance(client): 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にする (#10452)
* ✌️ * fix * ✌️ * 422px上限 * 334 * min-height: 130px * 64px * fix * wip * ✌️ * fix * max-height: none * MkImgWithBlurHashでratioを計算する * wip * fix * fix? * Revert "fix?" This reverts commite39d832dd1. * Revert "fix" This reverts commit15be36ba55. * Revert "wip" This reverts commitaf7d86f69d. * fix * Revert "Revert "wip"" This reverts commitbb0036ae22. * Revert "Revert "fix"" This reverts commitc1d94a45c5. * Revert "Revert "fix?"" This reverts commit9cb4fbfd96. * fix * use clamp * readable * add 1:1, 3:4 * moveComment * 3:4 → 2:3 * fix * default * fallback * Revert "fallback" This reverts commit741717dd49. * Fix?(server): Content-Dispositionのパースでエラーが発生した場合にもダウンロードが完了するように #10626
This commit is contained in:
		| @@ -1,12 +1,12 @@ | ||||
| <template> | ||||
| <div :class="[$style.root, { [$style.cover]: cover }]" :title="title"> | ||||
| 	<canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="size" :height="size" :title="title"/> | ||||
| 	<img v-if="src && !forceBlurhash" :class="$style.img" :src="src" :title="title" :alt="alt" @load="onLoad"/> | ||||
| <div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''"> | ||||
| 	<canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? ''"/> | ||||
| 	<img v-if="src && !forceBlurhash" v-show="loaded" :class="$style.img" :src="src" :title="title ?? ''" :alt="alt ?? ''" @load="onLoad"/> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted } from 'vue'; | ||||
| import { onMounted, watch } from 'vue'; | ||||
| import { decode } from 'blurhash'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| @@ -14,34 +14,55 @@ const props = withDefaults(defineProps<{ | ||||
| 	hash?: string; | ||||
| 	alt?: string | null; | ||||
| 	title?: string | null; | ||||
| 	size?: number; | ||||
| 	height?: number; | ||||
| 	width?: number; | ||||
| 	cover?: boolean; | ||||
| 	forceBlurhash?: boolean; | ||||
| }>(), { | ||||
| 	src: null, | ||||
| 	alt: '', | ||||
| 	title: null, | ||||
| 	size: 64, | ||||
| 	height: 64, | ||||
| 	width: 64, | ||||
| 	cover: true, | ||||
| 	forceBlurhash: false, | ||||
| }); | ||||
|  | ||||
| const canvas = $shallowRef<HTMLCanvasElement>(); | ||||
| let loaded = $ref(false); | ||||
|  | ||||
| function draw() { | ||||
| 	if (props.hash == null) return; | ||||
| 	const pixels = decode(props.hash, props.size, props.size); | ||||
| 	const ctx = canvas.getContext('2d'); | ||||
| 	const imageData = ctx!.createImageData(props.size, props.size); | ||||
| 	imageData.data.set(pixels); | ||||
| 	ctx!.putImageData(imageData, 0, 0); | ||||
| } | ||||
| let width = $ref(props.width); | ||||
| let height = $ref(props.height); | ||||
|  | ||||
| function onLoad() { | ||||
| 	loaded = true; | ||||
| } | ||||
|  | ||||
| watch([() => props.width, () => props.height], () => { | ||||
| 	const ratio = props.width / props.height; | ||||
| 	if (ratio > 1) { | ||||
| 		width = Math.round(64 * ratio); | ||||
| 		height = 64; | ||||
| 	} else { | ||||
| 		width = 64; | ||||
| 		height = Math.round(64 / ratio); | ||||
| 	} | ||||
| }, { | ||||
| 	immediate: true, | ||||
| }); | ||||
|  | ||||
| function draw() { | ||||
| 	if (props.hash == null) return; | ||||
| 	const pixels = decode(props.hash, width, height); | ||||
| 	const ctx = canvas.getContext('2d'); | ||||
| 	const imageData = ctx!.createImageData(width, height); | ||||
| 	imageData.data.set(pixels); | ||||
| 	ctx!.putImageData(imageData, 0, 0); | ||||
| } | ||||
|  | ||||
| watch(() => props.hash, () => { | ||||
| 	draw(); | ||||
| }); | ||||
|  | ||||
| onMounted(() => { | ||||
| 	draw(); | ||||
| }); | ||||
| @@ -54,6 +75,7 @@ onMounted(() => { | ||||
| 	height: 100%; | ||||
|  | ||||
| 	&.cover { | ||||
| 		> .canvas, | ||||
| 		> .img { | ||||
| 			object-fit: cover; | ||||
| 		} | ||||
| @@ -68,8 +90,7 @@ onMounted(() => { | ||||
| } | ||||
|  | ||||
| .canvas { | ||||
| 	position: absolute; | ||||
| 	object-fit: cover; | ||||
| 	object-fit: contain; | ||||
| } | ||||
|  | ||||
| .img { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
| <div v-if="hide" :class="$style.hidden" @click="hide = false"> | ||||
| 	<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :force-blurhash="defaultStore.state.enableDataSaverMode" /> | ||||
| 	<ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode" /> | ||||
| 	<div :class="$style.hiddenText"> | ||||
| 		<div :class="$style.hiddenTextWrapper"> | ||||
| 			<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> | ||||
| @@ -15,7 +15,7 @@ | ||||
| 		:href="image.url" | ||||
| 		:title="image.name" | ||||
| 	> | ||||
| 		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> | ||||
| 		<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/> | ||||
| 	</a> | ||||
| 	<div :class="$style.indicators"> | ||||
| 		<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> | ||||
| @@ -42,11 +42,12 @@ const props = defineProps<{ | ||||
| let hide = $ref(true); | ||||
| let darkMode: boolean = $ref(defaultStore.state.darkMode); | ||||
|  | ||||
| const url = (props.raw || defaultStore.state.loadRawImages) | ||||
| const url = $computed(() => (props.raw || defaultStore.state.loadRawImages) | ||||
| 	? props.image.url | ||||
| 	: defaultStore.state.disableShowingAnimatedImages | ||||
| 		? getStaticImageUrl(props.image.url) | ||||
| 		: props.image.thumbnailUrl; | ||||
| 		: props.image.thumbnailUrl | ||||
| ); | ||||
|  | ||||
| // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする | ||||
| watch(() => props.image, () => { | ||||
|   | ||||
| @@ -2,10 +2,17 @@ | ||||
| <div> | ||||
| 	<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> | ||||
| 	<div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> | ||||
| 		<div ref="gallery" :class="[$style.medias, count <= 4 ? $style['n' + count] : $style.nMany]"> | ||||
| 		<div | ||||
| 			ref="gallery" | ||||
| 			:class="[ | ||||
| 				$style.medias, | ||||
| 				count <= 4 ? $style['n' + count] : $style.nMany, | ||||
| 				$style[`n1${defaultStore.reactiveState.mediaListWithOneImageAppearance.value}`] | ||||
| 			]" | ||||
| 		> | ||||
| 			<template v-for="media in mediaList.filter(media => previewable(media))"> | ||||
| 				<XVideo v-if="media.type.startsWith('video')" :key="media.id" :class="$style.media" :video="media"/> | ||||
| 				<XImage v-else-if="media.type.startsWith('image')" :key="media.id" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/> | ||||
| 				<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/> | ||||
| 				<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/> | ||||
| 			</template> | ||||
| 		</div> | ||||
| 	</div> | ||||
| @@ -13,7 +20,7 @@ | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, ref, useCssModule } from 'vue'; | ||||
| import { onMounted, ref, useCssModule, watch } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import PhotoSwipeLightbox from 'photoswipe/lightbox'; | ||||
| import PhotoSwipe from 'photoswipe'; | ||||
| @@ -23,6 +30,7 @@ import XImage from '@/components/MkMediaImage.vue'; | ||||
| import XVideo from '@/components/MkMediaVideo.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const'; | ||||
| import { defaultStore } from '@/store'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	mediaList: misskey.entities.DriveFile[]; | ||||
| @@ -31,11 +39,42 @@ const props = defineProps<{ | ||||
|  | ||||
| const $style = useCssModule(); | ||||
|  | ||||
| const gallery = ref(null); | ||||
| const gallery = ref<HTMLDivElement>(); | ||||
| const pswpZIndex = os.claimZIndex('middle'); | ||||
| document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); | ||||
| const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); | ||||
|  | ||||
| function calcAspectRatio() { | ||||
| 	if (!gallery.value) return; | ||||
|  | ||||
| 	let img = props.mediaList[0]; | ||||
|  | ||||
| 	if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) { | ||||
| 		gallery.value.style.aspectRatio = ''; | ||||
| 		return; | ||||
| 	} | ||||
|  | ||||
| 	// アスペクト比上限設定では、横長の場合は高さを縮小させる | ||||
| 	const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; | ||||
|  | ||||
| 	switch (defaultStore.state.mediaListWithOneImageAppearance) { | ||||
| 		case '16_9': | ||||
| 			gallery.value.style.aspectRatio = ratioMax(16 / 9); | ||||
| 			break; | ||||
| 		case '1_1': | ||||
| 			gallery.value.style.aspectRatio = ratioMax(1); | ||||
| 			break; | ||||
| 		case '2_3': | ||||
| 			gallery.value.style.aspectRatio = ratioMax(2 / 3); | ||||
| 			break; | ||||
| 		default: | ||||
| 			gallery.value.style.aspectRatio = ''; | ||||
| 			break; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| watch([defaultStore.reactiveState.mediaListWithOneImageAppearance, gallery], () => calcAspectRatio()); | ||||
|  | ||||
| onMounted(() => { | ||||
| 	const lightbox = new PhotoSwipeLightbox({ | ||||
| 		dataSource: props.mediaList | ||||
| @@ -155,12 +194,36 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { | ||||
| 	display: grid; | ||||
| 	grid-gap: 8px; | ||||
|  | ||||
| 	// for webkit | ||||
| 	height: 100%; | ||||
|  | ||||
| 	&.n1 { | ||||
| 		aspect-ratio: 16/9; | ||||
| 		grid-template-rows: 1fr; | ||||
|  | ||||
| 		// default (expand) | ||||
| 		min-height: 64px; | ||||
| 		max-height: clamp( | ||||
| 			64px, | ||||
| 			calc(var(--containerHeight, 100svh) * 0.5), // but --containerHeight can broken (too big) | ||||
| 			min(334px, 50vh) | ||||
| 		); | ||||
|  | ||||
| 		&.n116_9 { | ||||
| 			min-height: none; | ||||
| 			max-height: none; | ||||
| 			aspect-ratio: 16 / 9; // fallback | ||||
| 		} | ||||
|  | ||||
| 		&.n11_1{ | ||||
| 			min-height: none; | ||||
| 			max-height: none; | ||||
| 			aspect-ratio: 1 / 1; // fallback | ||||
| 		} | ||||
|  | ||||
| 		&.n12_3 { | ||||
| 			min-height: none; | ||||
| 			max-height: none; | ||||
| 			aspect-ratio: 2 / 3; // fallback | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	&.n2 { | ||||
|   | ||||
| @@ -24,7 +24,7 @@ import { } from 'vue'; | ||||
| const props = defineProps<{ | ||||
| 	modelValue: any; | ||||
| 	value: any; | ||||
| 	disabled: boolean; | ||||
| 	disabled?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script lang="ts"> | ||||
| import { defineComponent, h } from 'vue'; | ||||
| import { VNode, defineComponent, h } from 'vue'; | ||||
| import MkRadio from './MkRadio.vue'; | ||||
|  | ||||
| export default defineComponent({ | ||||
| @@ -22,31 +22,33 @@ export default defineComponent({ | ||||
| 		}, | ||||
| 	}, | ||||
| 	render() { | ||||
| 		console.log(this.$slots, this.$slots.label && this.$slots.label()); | ||||
| 		if (!this.$slots.default) return null; | ||||
| 		let options = this.$slots.default(); | ||||
| 		const label = this.$slots.label && this.$slots.label(); | ||||
| 		const caption = this.$slots.caption && this.$slots.caption(); | ||||
|  | ||||
| 		// なぜかFragmentになることがあるため | ||||
| 		if (options.length === 1 && options[0].props == null) options = options[0].children; | ||||
| 		if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; | ||||
|  | ||||
| 		return h('div', { | ||||
| 			class: 'novjtcto', | ||||
| 		}, [ | ||||
| 			...(label ? [h('div', { | ||||
| 				class: 'label', | ||||
| 			}, [label])] : []), | ||||
| 			}, label)] : []), | ||||
| 			h('div', { | ||||
| 				class: 'body', | ||||
| 			}, options.map(option => h(MkRadio, { | ||||
| 				key: option.key, | ||||
| 				value: option.props.value, | ||||
| 				value: option.props?.value, | ||||
| 				modelValue: this.value, | ||||
| 				'onUpdate:modelValue': value => this.value = value, | ||||
| 			}, option.children)), | ||||
| 			}, () => option.children)), | ||||
| 			), | ||||
| 			...(caption ? [h('div', { | ||||
| 				class: 'caption', | ||||
| 			}, [caption])] : []), | ||||
| 			}, caption)] : []), | ||||
| 		]); | ||||
| 	}, | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 tamaina
					tamaina