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 commitea2ddc2620. * Reapply "fix: 画面外にドラッグできるのを修正" This reverts commit87171980a7. * fix * fix: 存在しない要素がsourceElementのときに閉じると画像が飛んでいく問題を修正 * fix: clampに余白を追加 * fix duplicated condition * fix * fix typo
This commit is contained in:
@@ -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;
|
||||
|
||||
183
packages/frontend/src/components/MkBlurhash.vue
Normal file
183
packages/frontend/src/components/MkBlurhash.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>>,
|
||||
};
|
||||
|
||||
@@ -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/');
|
||||
|
||||
|
||||
83
packages/frontend/src/utility/get-file-menu.ts
Normal file
83
packages/frontend/src/utility/get-file-menu.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user