1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 09:45:04 +02:00

Improve image viewer (#17696)

* wip

* fix

* fix

* 🎨

* fix

* fix: Video要素をpropsで渡すのをやめる

* 🎨

* fix

* wip

* fix

* fix: 画面外にドラッグできるのを修正

* fix lint

* fix

* fix

* fix

* refactor

* add comment

* fix: 画像を新しいタブで開くが機能しなくなっていた問題を修正

* fix

* clean up

* attempt to fix

* fix

* enhance: ブラウザの戻るボタンで閉じる

* Revert "fix: 画面外にドラッグできるのを修正"

This reverts commit ea2ddc2620.

* Reapply "fix: 画面外にドラッグできるのを修正"

This reverts commit 87171980a7.

* fix

* fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正

* fix: clampに余白を追加

* fix duplicated condition

* fix

* fix typo
This commit is contained in:
かっこかり
2026-07-12 00:05:40 +09:00
committed by GitHub
parent ef034dc2ab
commit 520ede4d63
15 changed files with 969 additions and 481 deletions

View File

@@ -99,6 +99,11 @@ export async function common(createVue: () => Promise<App<Element>>) {
// タッチデバイスでCSSの:hoverを機能させる
window.document.addEventListener('touchend', () => {}, { passive: true });
// URLに#pswpを含む場合は取り除く
if (window.location.hash === '#pswp') {
window.history.replaceState(null, '', window.location.href.replace('#pswp', ''));
}
// 一斉リロード
reloadChannel.addEventListener('message', path => {
if (path !== null) window.location.href = path;

View File

@@ -0,0 +1,183 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<canvas
v-show="show"
ref="canvas"
:width="canvasWidth"
:height="canvasHeight"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { watch, ref, shallowRef, useTemplateRef, onMounted, onUnmounted } from 'vue';
import { genId } from '@/utility/id.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const props = withDefaults(defineProps<{
blurhash: string | null;
onlyAvgColor?: boolean;
width?: number;
height?: number;
// v-showが何故か動作しないため
show?: boolean;
}>(), {
onlyAvgColor: false,
width: 64,
height: 64,
show: true,
});
const canvas = useTemplateRef('canvas');
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const viewId = genId();
const bitmapTmp = shallowRef<CanvasImageSource | undefined>();
watch([() => props.width, () => props.height, canvas], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
}, {
immediate: true,
});
watch(() => props.blurhash, () => {
draw();
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.blurhash != null && extractAvgColorFromBlurhash(props.blurhash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.blurhash == null) return;
drawAvg();
if (props.blurhash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.blurhash,
},
undefined,
);
} else {
try {
const { render } = await import('buraha');
render(props.blurhash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
});
</script>

View File

@@ -0,0 +1,39 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_s">
<MkKeyValue v-if="content.filename != null">
<template #key>{{ i18n.ts.fileName }}</template>
<template #value>{{ content.filename }}</template>
</MkKeyValue>
<MkKeyValue v-if="content.file != null && content.file.comment != null">
<template #key>{{ i18n.ts.description }}</template>
<template #value>
<div :class="$style.pre">{{ content.file.comment }}</div>
</template>
</MkKeyValue>
</div>
</template>
<script lang="ts" setup>
import MkKeyValue from '@/components/MkKeyValue.vue';
import { i18n } from '@/i18n.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
const props = defineProps<{
content: Content;
}>();
</script>
<style lang="scss" module>
.root {
padding: 4px 14px;
}
.pre {
white-space: pre-wrap;
}
</style>

View File

@@ -20,48 +20,89 @@ SPDX-License-Identifier: AGPL-3.0-only
@touchcancel.passive="cancelPointerGesture"
@contextmenu="cancelPointerGesture"
@wheel="onWheel"
@click="onCLick"
@click="onClick"
>
<div
:class="[$style.transformer, { [$style.transition]: enableTransition }]"
:style="{ translate: `${transform.x}px ${transform.y}px`, scale: transform.scale }"
:style="{ transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})` }"
@transitionend.self="enableTransition = false"
@transitioncancel.self="enableTransition = false"
>
<div :class="[$style.contentWrapper, { [$style.hideForFallback]: hideForFallback }]">
<img
v-if="(!originalContentLoaded || !thumbnailContentLoaded) && (content.thumbnailUrl != null)"
:class="[$style.content, $style.thumbnail]"
:src="content.thumbnailUrl"
draggable="false"
@load="thumbnailContentLoaded = true"
<div
v-if="hide"
data-gallery-click-action="hidden"
:class="[$style.hidden, {
[$style.sensitive]: content.file?.isSensitive && prefer.s.highlightSensitiveMedia,
}]"
:style="hiddenStyle"
@click.stop="onHiddenClick"
>
<template v-if="activated">
<img
v-if="content.type === 'image'"
:class="[$style.content, $style.original]"
:src="content.url"
draggable="false"
@load="originalContentLoaded = true"
>
<video
v-else-if="content.type === 'video'"
:id="videoElId"
ref="videoEl"
:class="[$style.content, $style.original]"
:src="content.url"
draggable="false"
loop
autoplay
playsinline
@loadedmetadata="originalContentLoaded = true"
></video>
</template>
<div v-if="activated && !originalContentLoaded" :class="$style.loading">
<MkLoading/>
<div :class="$style.hiddenWrapper">
<MkBlurhash
v-if="content.type === 'image' && content.file?.blurhash != null"
:class="$style.hiddenBlurhash"
:blurhash="content.file.blurhash ?? null"
:height="content?.height ?? undefined"
:width="content?.width ?? undefined"
/>
<img
v-else-if="content.type === 'video' && content.thumbnailUrl != null"
:src="content.thumbnailUrl"
:class="$style.hiddenThumbnail"
/>
<div v-else :class="$style.hiddenPlaceholder"></div>
<div :class="[$style.hiddenText, { [$style.withBlur]: content.type === 'video' && content.thumbnailUrl != null }]">
<div :class="$style.hiddenTextWrapper">
<b v-if="content.file?.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</b>
<b v-else style="display: block;"><i class="ti" :class="content.type === 'image' ? 'ti-photo' : 'ti-movie'"></i> {{ content.type === 'image' ? i18n.ts.image : i18n.ts.video }}</b>
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
</div>
</div>
</div>
</div>
<template v-else>
<img
v-if="(!originalContentLoaded || !thumbnailContentLoaded) && (content.thumbnailUrl != null)"
:class="[$style.content, $style.thumbnail]"
:src="content.thumbnailUrl"
draggable="false"
@load="thumbnailContentLoaded = true"
>
<template v-if="activated">
<img
v-if="content.type === 'image'"
:class="$style.content"
:src="content.url"
:alt="content.file?.comment ?? undefined"
draggable="false"
@load="originalContentLoaded = true"
>
<video
v-else-if="content.type === 'video'"
ref="videoEl"
data-gallery-click-action="video"
:class="$style.content"
:src="content.url"
:alt="content.file?.comment ?? undefined"
draggable="false"
:controls="prefer.s.useNativeUiForVideoAudioPlayer"
playsinline
@loadedmetadata="originalContentLoaded = true"
@click.stop="onVideoClick"
></video>
<div v-if="content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer && !isVideoPlaying" data-gallery-click-action="video" :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
</div>
</template>
<div v-if="activated && !originalContentLoaded" :class="$style.loading">
<MkLoading/>
</div>
</template>
</div>
</div>
</div>
@@ -69,22 +110,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="[$style.header, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div :class="$style.title" class="_acrylic">
<button class="_button" :class="$style.titleButton"><i class="ti ti-dots" @click="openMenu"></i></button>
<div style="flex: 1; min-width: 0;">
<MkCondensedLine :minScale="0.5">{{ content.comment ?? content.filename }}</MkCondensedLine>
<div :class="$style.titleText">
<MkCondensedLine :minScale="0.5">{{ content.filename }}</MkCondensedLine>
</div>
<button class="_button" :class="$style.titleButton"><i class="ti ti-x" @click="closeThis"></i></button>
</div>
</div>
<div :class="[$style.footer, { [$style.infoShowing]: infoShowing && !isZooming }]">
<div v-if="content.type === 'video'" :class="$style.mediaControl">
<MkVideoContol v-if="videoEl != null" :videoElId="videoElId"/>
<div v-if="content.type === 'video' && !hide && !prefer.s.useNativeUiForVideoAudioPlayer" :class="$style.mediaControl">
<MkVideoControl v-if="videoEl != null" ref="videoControl" :videoEl="videoEl"/>
</div>
</div>
</div>
</template>
<script lang="ts">
import * as Misskey from 'misskey-js';
import MkBlurhash from './MkBlurhash.vue';
type Size = {
width: number;
height: number;
@@ -103,7 +147,7 @@ export type Content = {
width?: number | null;
height?: number | null;
filename?: string | null;
comment?: string | null;
file?: Misskey.entities.DriveFile;
sourceElement?: HTMLElement | null;
};
@@ -134,18 +178,26 @@ export function calculateSourceTransform({
</script>
<script lang="ts" setup>
import { markRaw, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import MkVideoContol from './MkVideoContol.vue';
import { computed, nextTick, ref, useTemplateRef, markRaw, watch, provide } from 'vue';
import { DI } from '@/di.js';
import MkVideoControl from './MkVideoControl.vue';
import XFileInfo from './MkImageGallery.item.fileinfo.vue';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
import { makeDoubleTapDetector } from '@/utility/double-tap.js';
import { deviceKind } from '@/utility/device-kind.js';
import { isTouchUsing } from '@/utility/touch.js';
import { genId } from '@/utility/id.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import type { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
content: Content;
activated: boolean;
initiallyOpened?: boolean;
}>(), {
initiallyOpened: false,
});
const emit = defineEmits<{
@@ -159,22 +211,20 @@ const emit = defineEmits<{
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const videoEl = useTemplateRef('videoEl');
const videoElId = genId();
const videoControl = useTemplateRef('videoControl');
provide(DI.mkImageGalleryItemVideoEl, videoEl);
const originalContentLoaded = ref(false);
const thumbnailContentLoaded = ref(false);
const enableTransition = ref(false);
const infoShowing = ref(false);
const hide = ref(true);
const isVideoPlaying = computed(() => videoControl.value?.isActuallyPlaying ?? false);
let canOpenAnimation = false;
onMounted(() => {
if (rootEl.value == null) return;
rootEl.value.offsetHeight; // reflow
infoShowing.value = true;
});
const headerSize = 30;
const footerSize = props.content.type === 'video' ? 80 : 0;
const footerSize = props.content.type === 'video' && !prefer.s.useNativeUiForVideoAudioPlayer ? 80 : 0;
const padding = deviceKind === 'smartphone' ? {
top: Math.max(0, headerSize + 10),
@@ -212,18 +262,52 @@ const getContentRenderingRect = () => contentRenderingSize != null ? {
width: contentRenderingSize.width,
height: contentRenderingSize.height,
} : null;
const hiddenStyle = computed(() => {
if (contentRenderingSize == null) {
return {
width: '100%',
height: '100%',
};
}
return {
width: `${contentRenderingSize.width}px`,
height: `${contentRenderingSize.height}px`,
};
});
function shouldHideInGallery(content: Content): boolean {
if (content.file == null) return false;
const hiddenByDefault = shouldHideFileByDefault(content.file, true);
if (!hiddenByDefault) return false;
// ギャラリー起動時に最初に開いたセンシティブ画像だけは初期表示で隠さない
if (content.file.isSensitive && prefer.s.nsfw !== 'force' && props.initiallyOpened) {
return false;
}
return true;
}
function isValidRect(rect: Rect | null): rect is Rect {
return rect != null && rect.width > 0 && rect.height > 0;
}
const transform = ref({ x: 0, y: 0, scale: 1 });
// 元のimg要素の位置・サイズ(とobject-fitの設定値)を取得して、そこからneutralの位置にアニメーションするためのscaleとtranslationを計算する
function getScaleAndTranslationForSourceElement() {
const sourceElement = props.content.sourceElement;
const contentRenderingRect = getContentRenderingRect();
if (sourceElement == null || contentRenderingRect == null) return null;
if (sourceElement == null || !isValidRect(contentRenderingRect)) return null;
const sourceElementRect = sourceElement.getBoundingClientRect();
if (!isValidRect(sourceElementRect)) return null;
return calculateSourceTransform({
fit: window.getComputedStyle(sourceElement).objectFit,
contentRenderingRect,
sourceRect: sourceElement.getBoundingClientRect(),
sourceRect: sourceElementRect,
});
}
@@ -241,7 +325,30 @@ const hideForFallback = ref(!canOpenAnimation);
const isZooming = ref(false);
function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false) {
function clampZoomTransform(nextTransform: { x: number; y: number; scale: number }) {
if (mainEl.value == null || nextTransform.scale <= 1) {
return {
x: 0,
y: 0,
scale: nextTransform.scale,
};
}
const panMargin = 24;
const rect = mainEl.value.getBoundingClientRect();
const minX = rect.width - rect.width * nextTransform.scale - panMargin;
const minY = rect.height - rect.height * nextTransform.scale - panMargin;
const maxX = panMargin;
const maxY = panMargin;
return {
x: Math.min(maxX, Math.max(minX, nextTransform.x)),
y: Math.min(maxY, Math.max(minY, nextTransform.y)),
scale: nextTransform.scale,
};
}
function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false, clamp = true) {
if (mainEl.value == null) return;
const newScale = transform.value.scale * factor;
@@ -258,9 +365,17 @@ function zoomInTo(x: number, y: number, factor = 1.1, withAnimation = false) {
enableTransition.value = true;
}
transform.value.x = newTranslationX;
transform.value.y = newTranslationY;
transform.value.scale = newScale;
transform.value = clamp
? clampZoomTransform({
x: newTranslationX,
y: newTranslationY,
scale: newScale,
})
: {
x: newTranslationX,
y: newTranslationY,
scale: newScale,
};
}
function resetToNeutral() {
@@ -294,11 +409,6 @@ function closeThis() {
}
}
onMounted(() => {
rootEl.value.offsetHeight; // reflow
hideForFallback.value = false;
});
function onWheel(event: WheelEvent) {
event.preventDefault();
@@ -321,18 +431,28 @@ function onWheel(event: WheelEvent) {
}
function onZoomGesture(ev: { delta: number; centerX: number; centerY: number }) {
zoomInTo(ev.centerX, ev.centerY, 1 + ev.delta / 200);
zoomInTo(ev.centerX, ev.centerY, 1 + ev.delta / 200, false, false);
}
function onZoomGestureEnd() {
if (transform.value.scale < 1) {
isZooming.value = false;
resetToNeutral();
return;
}
const clampedTransform = clampZoomTransform(transform.value);
if (clampedTransform.x === transform.value.x && clampedTransform.y === transform.value.y && clampedTransform.scale === transform.value.scale) {
return;
}
enableTransition.value = true;
transform.value = clampedTransform;
}
let isDragging = false;
let isClick = false;
let clickAction: 'hidden' | 'video' | null = null;
let lastX = 0;
let lastY = 0;
let currentPointerId: number | null = null;
@@ -345,6 +465,17 @@ let horizontalSwipeDelta = 0;
const pointerEventCache = new Map<number, PointerEvent>();
let pointerVec = { x: 0, y: 0 };
function resolveClickAction(target: EventTarget | null): 'hidden' | 'video' | null {
if (!(target instanceof Element)) return null;
const action = target.closest('[data-gallery-click-action]')?.getAttribute('data-gallery-click-action');
if (action === 'hidden' || action === 'video') {
return action;
}
return null;
}
function onPointerdown(ev: PointerEvent) {
if (mainEl.value == null) return;
pointerEventCache.set(ev.pointerId, ev);
@@ -352,6 +483,7 @@ function onPointerdown(ev: PointerEvent) {
isDragging = true;
isClick = true;
clickAction = resolveClickAction(ev.target);
lastX = ev.clientX;
lastY = ev.clientY;
pointerVec = { x: 0, y: 0 };
@@ -406,8 +538,11 @@ function onPointermove(ev: PointerEvent) {
}
if (isZooming.value) {
transform.value.x += deltaX;
transform.value.y += deltaY;
transform.value = clampZoomTransform({
x: transform.value.x + deltaX,
y: transform.value.y + deltaY,
scale: transform.value.scale,
});
} else {
if (isVerticalSwiping) {
transform.value.y += deltaY;
@@ -496,6 +631,7 @@ function cancelPointerGesture() {
currentPointerId = null;
isDragging = false;
isClick = false;
clickAction = null;
pointerVec = { x: 0, y: 0 };
verticalSwipeDelta = 0;
horizontalSwipeDelta = 0;
@@ -508,8 +644,6 @@ function cancelPointerGesture() {
}
function onTouchstart(ev: TouchEvent) {
ev.preventDefault();
ev.stopPropagation();
doubleTapDetector.onTouchstart(ev);
}
@@ -531,8 +665,11 @@ function updateInertia(timeStamp: number) {
if (isDragging) return;
if (!isZooming.value) return;
if (Math.abs(pointerVec.x) < 0.01 && Math.abs(pointerVec.y) < 0.01) return;
transform.value.x += pointerVec.x * timeDelta;
transform.value.y += pointerVec.y * timeDelta;
transform.value = clampZoomTransform({
x: transform.value.x + pointerVec.x * timeDelta,
y: transform.value.y + pointerVec.y * timeDelta,
scale: transform.value.scale,
});
pointerVec.x *= inertiaFactor ** (timeDelta / 16.67);
pointerVec.y *= inertiaFactor ** (timeDelta / 16.67);
}
@@ -547,26 +684,60 @@ watch(isZooming, () => {
});
//#endregion
watch(thumbnailContentLoaded, () => {
function animateFromSourceToNeutral() {
if (rootEl.value == null) return;
const sourceElement = props.content.sourceElement;
if (sourceElement != null && props.activated) {
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = 0;
transform.value.y = 0;
transform.value.scale = 1;
if (sourceElement == null || !props.activated) return;
nextTick(() => {
sourceElement.style.visibility = 'hidden';
});
}
enableTransition.value = true;
rootEl.value.offsetHeight; // reflow
transform.value.x = 0;
transform.value.y = 0;
transform.value.scale = 1;
nextTick(() => {
sourceElement.style.visibility = 'hidden';
});
}
watch(thumbnailContentLoaded, () => {
animateFromSourceToNeutral();
}, { once: true });
function onCLick() {
watch([rootEl, hide], ([newRootEl, isHidden]) => {
if (newRootEl == null || !isHidden) return;
animateFromSourceToNeutral();
}, { immediate: true });
watch(props.content, (newContent) => {
hide.value = shouldHideInGallery(newContent);
}, { deep: true, immediate: true });
watch(rootEl, (newRootEl) => {
if (newRootEl == null) return;
infoShowing.value = true;
newRootEl.offsetHeight; // reflow
hideForFallback.value = false;
}, { immediate: true });
function onClick(ev: MouseEvent) {
if (!isClick) return;
const action = clickAction ?? resolveClickAction(ev.target);
clickAction = null;
if (action === 'hidden') {
void onHiddenClick();
return;
}
if (action === 'video') {
onVideoClick();
return;
}
if (!isTouchUsing) {
if (isZooming.value) {
isZooming.value = false;
@@ -577,9 +748,80 @@ function onCLick() {
}
}
function openMenu() {
// TODO
async function onHiddenClick() {
if (hide.value) {
if (props.content.file == null || await canRevealFile(props.content.file)) {
hide.value = false;
if (props.content.type === 'video' && videoEl.value != null) {
videoEl.value.play();
}
}
}
}
function onVideoClick() {
if (!prefer.s.useNativeUiForVideoAudioPlayer) {
if (videoEl.value == null) return;
if (videoEl.value.paused) {
videoEl.value.play();
} else {
videoEl.value.pause();
}
}
}
function openMenu(ev: PointerEvent) {
const menu: MenuItem[] = [];
// isTouchUsingにする
menu.push({
type: 'component',
component: markRaw(XFileInfo),
props: {
content: props.content,
},
}, {
type: 'divider',
});
menu.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
});
if (props.content.file != null) {
menu.push({ type: 'divider' });
menu.push(...getFileMenu(props.content.file));
}
os.popupMenu(menu, (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onActive() {
if (videoEl.value != null) {
videoEl.value.play();
}
}
function onDeactive() {
if (isZooming.value) {
isZooming.value = false;
resetToNeutral();
}
if (videoEl.value != null && props.activated) {
videoEl.value.pause();
}
}
defineExpose({
onActive,
onDeactive,
closeThis,
});
</script>
<style lang="scss" module>
@@ -632,7 +874,7 @@ function openMenu() {
}
.transition {
transition: translate 200ms ease, scale 200ms ease;
transition: transform 200ms ease;
}
.contentWrapper {
@@ -647,6 +889,104 @@ function openMenu() {
opacity: 0 !important;
}
.playIconWrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: grid;
place-items: center;
}
.playIcon {
display: grid;
place-items: center;
width: 50px;
height: 50px;
border-radius: 100%;
font-size: 120%;
background: var(--MI_THEME-accent);
color: var(--MI_THEME-fgOnAccent);
scale: 1;
transition: scale 100ms ease;
}
.playIconWrapper:hover .playIcon,
.playIcon:hover {
scale: 1.2;
}
.hidden {
position: absolute;
inset: 0;
margin: auto;
display: grid;
place-items: center;
width: 100%;
height: 100%;
overflow: clip;
&.sensitive::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
box-shadow: inset 0 0 0 4px var(--MI_THEME-warn);
}
}
.hiddenWrapper {
position: relative;
width: 100%;
max-height: 100%;
min-height: 0;
}
.hiddenBlurhash {
display: block;
width: 100%;
height: 100%;
filter: brightness(0.7);
}
.hiddenThumbnail {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter: brightness(0.7);
}
.hiddenPlaceholder {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
background: #000;
}
.hiddenText {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
cursor: pointer;
color: #fff;
&.withBlur {
backdrop-filter: blur(12px);
}
}
.footer {
position: absolute;
bottom: v-bind("-footerSize + 'px'");
@@ -676,16 +1016,28 @@ function openMenu() {
.title {
display: flex;
align-items: center;
width: max-content;
max-width: calc(100% - 20px);
margin: auto;
padding: 6px 0px;
box-sizing: border-box;
border-radius: 0 0 10px 10px;
font-size: 85%;
}
.titleButton {
width: 30px;
flex-shrink: 0;
width: 32px;
height: 32px;
}
.titleText {
flex-grow: 1;
height: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
padding: 6px 0px;
}
.mediaControl {

View File

@@ -27,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<div v-for="(content, i) in contents" :key="content.url" ref="itemEl" :class="$style.item">
<XItem
:ref="(comp) => { items.set(i, comp as InstanceType<typeof XItem>); }"
:content="content"
:initiallyOpened="i === (props.defaultIndex ?? 0)"
:activated="activatedIndexes.has(i)"
@close="onItemClose"
@horizontalSwipe="onHorizontalSwipe"
@@ -46,12 +48,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue';
import { ref, watch, nextTick, onBeforeUnmount, onMounted } from 'vue';
import XItem from './MkImageGallery.item.vue';
import type { Content } from './MkImageGallery.item.vue';
import type { Keymap } from '@/utility/hotkey.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { isTouchUsing } from '@/utility/touch.js';
@@ -66,10 +67,22 @@ const emit = defineEmits<{
}>();
const activatedIndexes = ref(new Set<number>());
const items = new Map<number, InstanceType<typeof XItem>>();
const currentIndex = ref(props.defaultIndex ?? 0);
watch(currentIndex, (newIndex) => {
watch(currentIndex, (newIndex, oldIndex) => {
activatedIndexes.value.add(newIndex);
nextTick(() => {
if (oldIndex != null && items.has(oldIndex)) {
items.get(oldIndex)!.onDeactive();
}
if (items.has(newIndex)) {
items.get(newIndex)!.onActive();
}
});
}, { immediate: true });
watch(currentIndex, (newIndex) => {
for (let i = 0; i < props.contents.length; i++) {
const content = props.contents[i];
@@ -89,11 +102,12 @@ const contentsOffset = ref(currentIndex.value * -window.innerWidth);
const enableSlideTransition = ref(false);
let currentScrollLeft = contentsOffset.value;
// TODO: unmountで解除
window.addEventListener('resize', () => {
function onResize() {
screenWidth.value = window.innerWidth;
scrollToCurrentIndex();
});
}
window.addEventListener('resize', onResize, { passive: true });
function onHorizontalSwipe(offset: number) {
if (currentIndex.value === 0 && offset > 0) { // これ以上戻れない
@@ -119,6 +133,14 @@ function scrollToCurrentIndex() {
contentsOffset.value = targetOffset;
}
function close() {
if (items.has(currentIndex.value)) {
items.get(currentIndex.value)!.closeThis();
} else {
showing.value = false;
}
}
function onSlideTransitionFinished(ev: TransitionEvent) {
if (ev.propertyName !== 'translate') return;
enableSlideTransition.value = false;
@@ -155,6 +177,17 @@ function onAfterLeave() {
emit('closed');
}
function onPopState() {
if (showing.value) {
close();
}
}
onMounted(() => {
window.history.pushState(null, '', '#pswp');
window.addEventListener('popstate', onPopState);
});
const keymap = {
'esc': {
allowRepeat: true,
@@ -169,6 +202,15 @@ const keymap = {
callback: () => onNext(),
},
} as const satisfies Keymap;
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
window.removeEventListener('popstate', onPopState);
});
defineExpose({
close,
});
</script>
<style lang="scss" module>

View File

@@ -14,18 +14,15 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="prefer.s.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="prefer.s.animation && props.transition?.leaveFromClass || undefined"
>
<canvas
v-show="hide"
<MkBlurhash
key="canvas"
ref="canvas"
:class="$style.canvas"
:width="canvasWidth"
:height="canvasHeight"
:title="title ?? undefined"
draggable="false"
tabindex="-1"
style="-webkit-user-drag: none;"
></canvas>
:blurhash="hash ?? null"
:height="imgHeight ?? undefined"
:width="imgWidth ?? undefined"
:onlyAvgColor="props.onlyAvgColor"
:show="hide"
/>
<img
v-show="!hide"
key="img"
@@ -47,50 +44,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
// テスト環境で Web Worker インスタンスは作成できない
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const isTest = (import.meta.env.MODE === 'test' || window.isPlaywright);
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
if (isTest) {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
return;
}
const testWorker = new TestWebGL2();
testWorker.addEventListener('message', event => {
if (event.data.result) {
const workers = new WorkerMultiDispatch(
() => new DrawBlurhash(),
Math.min(navigator.hardwareConcurrency - 1, 4),
);
resolve(workers);
} else {
const canvas = window.document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
resolve(canvas);
}
testWorker.terminate();
});
});
</script>
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, useTemplateRef, watch, ref } from 'vue';
import { render } from 'buraha';
import { genId } from '@/utility/id.js';
import { computed, nextTick, useTemplateRef, watch, ref } from 'vue';
import { prefer } from '@/preferences.js';
import MkBlurhash from '@/components/MkBlurhash.vue';
const props = withDefaults(defineProps<{
transition?: {
@@ -124,16 +81,11 @@ const props = withDefaults(defineProps<{
onlyAvgColor: false,
});
const viewId = genId();
const canvas = useTemplateRef('canvas');
const root = useTemplateRef('root');
const img = useTemplateRef('img');
const loaded = ref(false);
const canvasWidth = ref(64);
const canvasHeight = ref(64);
const imgWidth = ref(props.width);
const imgHeight = ref(props.height);
const bitmapTmp = ref<CanvasImageSource | undefined>();
const hide = computed(() => !loaded.value || props.forceBlurhash);
function waitForDecode() {
@@ -152,14 +104,6 @@ function waitForDecode() {
watch([() => props.width, () => props.height, root], () => {
const ratio = props.width / props.height;
if (ratio > 1) {
canvasWidth.value = Math.round(64 * ratio);
canvasHeight.value = 64;
} else {
canvasWidth.value = 64;
canvasHeight.value = Math.round(64 / ratio);
}
const clientWidth = root.value?.clientWidth ?? 300;
imgWidth.value = clientWidth;
imgHeight.value = Math.round(clientWidth / ratio);
@@ -167,97 +111,10 @@ watch([() => props.width, () => props.height, root], () => {
immediate: true,
});
function drawImage(bitmap: CanvasImageSource) {
// canvasがないmountedされていない場合はTmpに保存しておく
if (!canvas.value) {
bitmapTmp.value = bitmap;
return;
}
// canvasがあれば描画する
bitmapTmp.value = undefined;
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value);
}
function drawAvg() {
if (!canvas.value) return;
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColorでお茶をにごす
ctx.beginPath();
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (isTest && props.hash == null) return;
drawAvg();
if (props.hash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;
if (work instanceof WorkerMultiDispatch) {
work.postMessage(
{
id: viewId,
hash: props.hash,
},
undefined,
);
} else {
try {
render(props.hash, work);
drawImage(work);
} catch (error) {
console.error('Error occurred during drawing blurhash', error);
}
}
}
function workerOnMessage(event: MessageEvent) {
if (event.data.id !== viewId) return;
drawImage(event.data.bitmap as ImageBitmap);
}
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.addListener(workerOnMessage);
}
draw();
});
watch(() => props.src, () => {
waitForDecode();
});
watch(() => props.hash, () => {
draw();
});
onMounted(() => {
// drawImageがmountedより先に呼ばれている場合はここで描画する
if (bitmapTmp.value) {
drawImage(bitmapTmp.value);
}
waitForDecode();
});
onUnmounted(() => {
canvasPromise.then(work => {
if (work instanceof WorkerMultiDispatch) {
work.removeListener(workerOnMessage);
}
});
}, {
immediate: true,
});
</script>

View File

@@ -49,8 +49,8 @@ SPDX-License-Identifier: AGPL-3.0-only
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
<i v-if="isPlaying" class="ti ti-player-pause"></i>
<i v-else class="ti ti-player-play"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
@@ -100,6 +100,7 @@ import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { canRevealFile, shouldHideFileByDefault } from '@/utility/sensitive-file.js';
const props = defineProps<{
@@ -208,56 +209,11 @@ function showMenu(ev: MouseEvent) {
{
type: 'divider',
},
{
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
},
];
if (iAmModerator) {
menu.push({
text: props.audio.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: props.audio.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
danger: true,
action: () => toggleSensitive(props.audio),
});
}
const details: MenuItem[] = [];
if ($i?.id === props.audio.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.audio.id}`,
});
}
if (details.length > 0) {
menu.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menu.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.audio.id);
},
});
}
menu.push(...getFileMenu(props.audio, (newState) => {
hide.value = newState;
}));
menuShowing.value = true;
os.popupMenu(menu, ev.currentTarget ?? ev.target, {
@@ -268,20 +224,6 @@ function showMenu(ev: MouseEvent) {
});
}
async function toggleSensitive(file: Misskey.entities.DriveFile) {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
}
// MediaControl: Common State
const oncePlayed = ref(false);
const isReady = ref(false);

View File

@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="reveal" @contextmenu.stop="onContextmenu">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive]" @click="onClick" @contextmenu.stop="onContextmenu">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@@ -70,16 +70,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import bytes from '@/filters/bytes.js';
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { $i, iAmModerator } from '@/i.js';
import { prefer } from '@/preferences.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
const props = withDefaults(defineProps<{
image: Misskey.entities.DriveFile;
@@ -94,6 +92,10 @@ const props = withDefaults(defineProps<{
controls: true,
});
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
const hide = ref(true);
const url = computed(() => (props.raw || prefer.s.loadRawImages)
@@ -103,8 +105,9 @@ const url = computed(() => (props.raw || prefer.s.loadRawImages)
: props.image.thumbnailUrl!,
);
async function reveal(ev: PointerEvent) {
async function onClick(ev: PointerEvent) {
if (!props.controls) {
emit('mediaClick', ev);
return;
}
@@ -115,6 +118,8 @@ async function reveal(ev: PointerEvent) {
}
hide.value = false;
} else {
emit('mediaClick', ev);
}
}
@@ -126,80 +131,12 @@ watch(() => props.image, (newImage) => {
immediate: true,
});
function getMenu() {
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
});
if (iAmModerator) {
menuItems.push({
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: props.image.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: props.image.id,
isSensitive: !props.image.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === props.image.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${props.image.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(props.image.id);
},
});
}
return menuItems;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
os.popupMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getMenu(), ev);
os.contextMenu(getFileMenu(props.image, (newHide) => { hide.value = newHide; }), ev);
}
</script>

View File

@@ -20,8 +20,23 @@ SPDX-License-Identifier: AGPL-3.0-only
]"
>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media" @click="openGallery(media.id)"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :marker="`${markerId}:${media.id}`" :disableImageLink="true" :class="$style.media" :image="media" :raw="raw" @click="openGallery(media.id)"/>
<XVideo
v-if="media.type.startsWith('video')"
:key="`video:${media.id}`"
:class="$style.media"
:video="media"
@mediaClick="onMediaClick(media)"
/>
<XImage
v-else-if="media.type.startsWith('image')"
:key="`image:${media.id}`"
:marker="`${markerId}:${media.id}`"
:disableImageLink="true"
:class="$style.media"
:image="media"
:raw="raw"
@mediaClick="onMediaClick(media)"
/>
</template>
</div>
</div>
@@ -36,9 +51,9 @@ import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { focusParent } from '@/utility/focus.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
import type { Content } from '@/components/MkImageGallery.item.vue';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@@ -95,19 +110,29 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
function onMediaClick(file: Misskey.entities.DriveFile) {
if (prefer.s.imageNewTab) {
window.open(file.url, '_blank');
return;
}
openGallery(file.id);
}
async function openGallery(id?: string) {
if (id == null) {
const firstImage = props.mediaList.find(media => previewable(media));
if (firstImage == null) return;
id = firstImage.id;
}
const getElementByMarker = (marker: string) => {
if (gallery.value == null) return null;
const found = gallery.value.querySelector(`[data-marker="${marker}"]`) as HTMLElement | null;
if (found == null) return null;
return markRaw(found);
};
const contents = props.mediaList.filter(media => previewable(media)).map(media => ({
const contents = props.mediaList.filter(media => previewable(media)).map<Content>(media => ({
id: media.id,
type: media.type.startsWith('video') ? 'video' : 'image',
url: media.url,
@@ -115,9 +140,10 @@ async function openGallery(id?: string) {
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
filename: media.name,
comment: media.comment,
file: media,
sourceElement: getElementByMarker(`${markerId}:${media.id}`),
}));
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultIndex: contents.findIndex(conten => conten.id === id),
contents: contents,

View File

@@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
$style.root,
(video.isSensitive && prefer.s.highlightSensitiveMedia) && $style.sensitive,
]"
@contextmenu.stop
@contextmenu.stop="onContextmenu"
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="reveal">
@@ -22,41 +22,50 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
<div v-else :class="$style.videoRoot">
<video
ref="videoEl"
<div v-else :class="$style.videoRoot" @click="emit('mediaClick', $event)">
<img
v-if="video.thumbnailUrl"
:class="$style.video"
:src="video.thumbnailUrl"
:alt="video.comment ?? undefined"
/>
<video
v-else
:class="$style.video"
:poster="video.thumbnailUrl ?? undefined"
:alt="video.comment"
preload="metadata"
playsinline
>
<source :src="video.url">
</video>
<div :class="$style.playIconWrapper">
<div :class="$style.playIcon">
<i class="ti ti-player-play"></i>
</div>
</div>
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import * as os from '@/os.js';
import { getFileMenu } from '@/utility/get-file-menu.js';
import { shouldHideFileByDefault, canRevealFile } from '@/utility/sensitive-file.js';
const props = defineProps<{
video: Misskey.entities.DriveFile;
}>();
const emit = defineEmits<{
(event: 'mediaClick', ev: PointerEvent): void;
}>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref(shouldHideFileByDefault(props.video));
@@ -67,6 +76,14 @@ async function reveal() {
hide.value = false;
}
function showMenu(ev: PointerEvent) {
os.popupMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: PointerEvent) {
os.contextMenu(getFileMenu(props.video, (newHide) => { hide.value = newHide; }), ev);
}
</script>
<style lang="scss" module>
@@ -102,42 +119,6 @@ async function reveal() {
}
}
.indicators {
display: inline-flex;
position: absolute;
top: 10px;
left: 10px;
pointer-events: none;
opacity: .5;
gap: 6px;
}
.indicator {
/* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */
background-color: black;
border-radius: 6px;
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
display: inline-block;
font-weight: bold;
font-size: 0.8em;
padding: 2px 5px;
}
.hide {
display: block;
position: absolute;
border-radius: 6px;
background-color: var(--MI_THEME-fg);
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 12px;
right: 12px;
}
.hidden {
width: 100%;
height: 100%;
@@ -147,7 +128,7 @@ async function reveal() {
font: inherit;
color: inherit;
cursor: pointer;
padding: 60px 0;
padding: 12px 0;
display: flex;
align-items: center;
justify-content: center;
@@ -171,6 +152,7 @@ async function reveal() {
display: block;
height: 100%;
width: 100%;
object-fit: contain;
}
.playIconWrapper {
@@ -195,4 +177,37 @@ async function reveal() {
scale: 1;
transition: scale 100ms ease;
}
.menu {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 9px 0 0 0;
color: #fff;
font-size: 0.8em;
width: 28px;
height: 28px;
text-align: center;
bottom: 0;
right: 0;
}
.hide {
display: block;
position: absolute;
background-color: rgba(0, 0, 0, 0.3);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-radius: 0 0 0 9px;
color: #fff;
font-size: 12px;
opacity: .5;
padding: 5px 8px;
text-align: center;
cursor: pointer;
top: 0;
right: 0;
}
</style>

View File

@@ -39,20 +39,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, watch, onDeactivated, onActivated, onMounted, onBeforeUnmount } from 'vue';
import { ref, shallowRef, inject, computed, watch, onBeforeUnmount } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { DI } from '@/di.js';
import { hms } from '@/filters/hms.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import hasAudio from '@/utility/media-has-audio.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
import { prefer } from '@/preferences.js';
const props = defineProps<{
videoElId: string;
}>();
const videoEl = window.document.getElementById(props.videoElId) as HTMLVideoElement;
const videoEl = inject(DI.mkImageGalleryItemVideoEl, shallowRef<HTMLVideoElement | null>(null));
// Menu
const menuShowing = ref(false);
@@ -122,7 +118,8 @@ const rangePercent = computed({
return (elapsedTimeMs.value / durationMs.value) || 0;
},
set: (to) => {
videoEl.currentTime = to * durationMs.value / 1000;
if (videoEl.value == null) return;
videoEl.value.currentTime = to * durationMs.value / 1000;
},
});
const volume = ref(.25);
@@ -130,17 +127,18 @@ const speed = ref(1);
const loop = ref(false); // TODO:
const bufferedEnd = ref(0);
const bufferedDataRatio = computed(() => {
return bufferedEnd.value / videoEl.duration;
if (videoEl.value == null || videoEl.value.duration === 0) return 0;
return bufferedEnd.value / videoEl.value.duration;
});
function togglePlayPause() {
if (!isReady.value) return;
if (isPlaying.value) {
videoEl.pause();
videoEl.value?.pause();
isPlaying.value = false;
} else {
videoEl.play();
videoEl.value?.play();
isPlaying.value = true;
oncePlayed.value = true;
}
@@ -150,7 +148,7 @@ function togglePictureInPicture() {
if (window.document.pictureInPictureElement) {
window.document.exitPictureInPicture();
} else {
videoEl.requestPictureInPicture();
videoEl.value?.requestPictureInPicture();
}
}
@@ -162,30 +160,32 @@ function toggleMute() {
}
}
let onceInit = false;
let abortController: AbortController | null = null;
let mediaTickFrameId: number | null = null;
function init() {
if (onceInit) return;
onceInit = true;
if (videoEl.value == null) return;
isReady.value = true;
abortController = new AbortController();
function updateMediaTick() {
if (videoEl.value == null) return;
try {
bufferedEnd.value = videoEl.buffered.end(0);
bufferedEnd.value = videoEl.value.buffered.end(0);
} catch (err) {
bufferedEnd.value = 0;
}
elapsedTimeMs.value = videoEl.currentTime * 1000;
elapsedTimeMs.value = videoEl.value.currentTime * 1000;
if (videoEl.loop !== loop.value) {
loop.value = videoEl.loop;
if (videoEl.value.loop !== loop.value) {
loop.value = videoEl.value.loop;
}
if (videoEl.paused !== !isPlaying.value) {
isPlaying.value = !videoEl.paused;
if (videoEl.value.paused !== !isPlaying.value) {
isPlaying.value = !videoEl.value.paused;
}
mediaTickFrameId = window.requestAnimationFrame(updateMediaTick);
@@ -193,54 +193,56 @@ function init() {
updateMediaTick();
videoEl.addEventListener('play', () => {
videoEl.value.addEventListener('play', () => {
isActuallyPlaying.value = true;
});
}, { signal: abortController.signal });
videoEl.addEventListener('pause', () => {
videoEl.value.addEventListener('pause', () => {
isActuallyPlaying.value = false;
isPlaying.value = false;
});
}, { signal: abortController.signal });
videoEl.addEventListener('ended', () => {
videoEl.value.addEventListener('ended', () => {
oncePlayed.value = false;
isActuallyPlaying.value = false;
isPlaying.value = false;
});
}, { signal: abortController.signal });
durationMs.value = videoEl.duration * 1000;
videoEl.addEventListener('durationchange', () => {
durationMs.value = videoEl.duration * 1000;
});
durationMs.value = videoEl.value.duration * 1000;
videoEl.value.addEventListener('durationchange', () => {
durationMs.value = videoEl.value!.duration * 1000;
}, { signal: abortController.signal });
videoEl.volume = volume.value;
hasAudio(videoEl).then(had => {
videoEl.value.volume = volume.value;
hasAudio(videoEl.value).then(had => {
if (!had) {
videoEl.loop = videoEl.muted = true;
videoEl.play();
videoEl.value!.loop = videoEl.value!.muted = true;
videoEl.value!.play();
}
});
}
watch(volume, (to) => {
videoEl.volume = to;
if (videoEl.value == null) return;
videoEl.value.volume = to;
});
watch(speed, (to) => {
videoEl.playbackRate = to;
if (videoEl.value == null) return;
videoEl.value.playbackRate = to;
});
watch(loop, (to) => {
videoEl.loop = to;
if (videoEl.value == null) return;
videoEl.value.loop = to;
});
onMounted(() => {
watch(videoEl, () => {
if (abortController != null) {
abortController.abort();
}
init();
});
onActivated(() => {
init();
});
}, { immediate: true });
onBeforeUnmount(() => {
if (mediaTickFrameId != null) {
@@ -248,6 +250,9 @@ onBeforeUnmount(() => {
}
});
defineExpose({
isActuallyPlaying,
});
</script>
<style lang="scss" module>

View File

@@ -19,4 +19,5 @@ export const DI = {
inModal: Symbol() as InjectionKey<boolean>,
inAppSearchMarkerId: Symbol() as InjectionKey<Ref<string | null>>,
inChannel: Symbol() as InjectionKey<ComputedRef<string | null> | null>, // 現在開いているチャンネルのID
mkImageGalleryItemVideoEl: Symbol() as InjectionKey<Ref<HTMLVideoElement | null>>,
};

View File

@@ -97,6 +97,7 @@ async function deleteFile(file: Misskey.entities.DriveFile) {
globalEvents.emit('driveFilesDeleted', [file]);
}
/** 自分のドライブファイルを操作する際のメニュー */
export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] {
const _isImage = file.type.startsWith('image/');

View File

@@ -0,0 +1,83 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import * as os from '@/os.js';
import type { MenuItem } from '@/types/menu.js';
/** 添付ファイルなど、公開ファイル用のメニュー */
export function getFileMenu(file: Misskey.entities.DriveFile, onHideStateUpdated?: (newState: boolean) => void): MenuItem[] {
const menuItems: MenuItem[] = [];
if (onHideStateUpdated != null) {
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
onHideStateUpdated(true);
},
});
}
if (iAmModerator) {
menuItems.push({
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: file.isSensitive ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
os.apiWithDialog('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive,
});
},
});
}
const details: MenuItem[] = [];
if ($i?.id === file.userId) {
details.push({
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${file.id}`,
});
}
if (iAmModerator) {
details.push({
type: 'link',
text: i18n.ts.moderation,
icon: 'ti ti-photo-exclamation',
to: `/admin/file/${file.id}`,
});
}
if (details.length > 0) {
menuItems.push({ type: 'divider' }, ...details);
}
if (prefer.s.devMode) {
menuItems.push({ type: 'divider' }, {
icon: 'ti ti-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(file.id);
},
});
}
return menuItems;
}

View File

@@ -8,8 +8,8 @@ import * as os from '@/os.js';
import { prefer } from '@/preferences.js';
import { i18n } from '@/i18n.js';
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile): boolean {
if (prefer.s.nsfw === 'force' || prefer.s.dataSaver.media) {
export function shouldHideFileByDefault(file: Misskey.entities.DriveFile, ignoreDataSaver = false): boolean {
if (prefer.s.nsfw === 'force' || (!ignoreDataSaver && prefer.s.dataSaver.media)) {
return true;
}