1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-25 07:25:04 +02:00
This commit is contained in:
syuilo
2026-07-09 16:13:16 +09:00
parent 059fbaa92b
commit 75e97da41e
9 changed files with 308 additions and 153 deletions

View File

@@ -61,7 +61,6 @@ it('Composition API (standard)', () => {
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const _sfc_main = /* @__PURE__ */ defineComponent({
@@ -179,7 +178,6 @@ export { index_photos as default };
import { c as api, d as store, i as i18n, aD as notePage, bN as ImgWithBlurhash, bY as getStaticImageUrl, _ as _export_sfc } from './app-!~{001}~.js';
import { M as MkContainer } from './MkContainer-!~{03M}~.js';
import { b as defineComponent, a as ref, e as onMounted, z as resolveComponent, g as openBlock, h as createBlock, i as withCtx, K as createTextVNode, E as toDisplayString, u as unref, l as createBaseVNode, q as normalizeClass, B as createCommentVNode, k as createElementBlock, F as Fragment, C as renderList, A as createVNode } from './vue-!~{002}~.js';
import './photoswipe-!~{003}~.js';
const _hoisted_1 = /* @__PURE__ */ createBaseVNode("i", { class: "ti ti-photo" }, null, -1);
const index_photos = /* @__PURE__ */ defineComponent({

View File

@@ -56,7 +56,6 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",

View File

@@ -99,11 +99,6 @@ 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,209 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="$style.root" :style="{ transform: `translate3d(${translation.x}px, ${translation.y}px, 0)` }">
<img
ref="imageEl"
:class="$style.image"
:src="image.src"
:width="size.width"
:height="size.height"
@wheel="onWheel"
@touchstart="onTouchstart"
@pointerdown="onPointerdown"
>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
type Image = {
id: string;
src: string;
width: number;
height: number;
sourceElement?: HTMLElement;
};
const props = withDefaults(defineProps<{
image: Image;
}>(), {
});
const rootEl = useTemplateRef('rootEl');
const imageEl = useTemplateRef('imageEl');
const padding = 30;
function calcDefaultSize(image: Image) {
const maxWidth = window.innerWidth - padding * 2;
const maxHeight = window.innerHeight - padding * 2;
let width = image.width;
let height = image.height;
if (width > maxWidth) {
height = height * (maxWidth / width);
width = maxWidth;
}
if (height > maxHeight) {
width = width * (maxHeight / height);
height = maxHeight;
}
return { width, height };
}
function calcDefaultTranslation(image: Image) {
const defaultSize = calcDefaultSize(image);
const x = (window.innerWidth - defaultSize.width) / 2;
const y = (window.innerHeight - defaultSize.height) / 2;
return { x, y };
}
const defaultSize = calcDefaultSize(props.image);
const defaultTranslation = calcDefaultTranslation(props.image);
const size = ref({ width: defaultSize.width, height: defaultSize.height });
const translation = ref({ x: defaultTranslation.x, y: defaultTranslation.y });
const isZooming = ref(false);
function zoomInTo(x: number, y: number, factor = 1.1) {
const newWidth = size.value.width * factor;
const newHeight = size.value.height * factor;
size.value.width = newWidth;
size.value.height = newHeight;
// Center the image on the cursor
const rect = imageEl.value?.getBoundingClientRect();
if (!rect) return;
const offsetX = x - rect.left;
const offsetY = y - rect.top;
translation.value.x -= offsetX * (factor - 1);
translation.value.y -= offsetY * (factor - 1);
isZooming.value = true;
}
function onWheel(event: WheelEvent) {
event.preventDefault();
const delta = event.deltaY;
const scaleFactor = 1.1;
const scale = delta > 0 ? 1 / scaleFactor : scaleFactor;
const newWidth = size.value.width * scale;
const newHeight = size.value.height * scale;
if (newWidth < defaultSize.width || newHeight < defaultSize.height) {
size.value.width = defaultSize.width;
size.value.height = defaultSize.height;
translation.value.x = defaultTranslation.x;
translation.value.y = defaultTranslation.y;
isZooming.value = false;
return;
}
zoomInTo(event.clientX, event.clientY, scale);
}
let lastTapTime = 0;
function onTouchstart(event: TouchEvent) {
if (isZooming.value) {
event.preventDefault();
}
if (event.touches.length !== 1) return;
const touch = event.touches[0];
let tapTimeout: number | null = null;
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTapTime;
if (tapLength < 300 && tapLength > 0) { // ダブルタップ
event.preventDefault();
if (isZooming.value) {
size.value.width = defaultSize.width;
size.value.height = defaultSize.height;
translation.value.x = defaultTranslation.x;
translation.value.y = defaultTranslation.y;
isZooming.value = false;
} else {
zoomInTo(touch.clientX, touch.clientY, 2);
}
}
lastTapTime = currentTime;
if (tapTimeout) clearTimeout(tapTimeout);
tapTimeout = window.setTimeout(() => {
tapTimeout = null;
}, 300);
}
// ズーム中、ドラッグされたら画像を移動する
let isDragging = false;
let lastX = 0;
let lastY = 0;
function onPointerdown(event: PointerEvent) {
if (!isZooming.value) return;
isDragging = true;
lastX = event.clientX;
lastY = event.clientY;
const onPointerMove = (moveEvent: PointerEvent) => {
if (!isDragging) return;
const deltaX = moveEvent.clientX - lastX;
const deltaY = moveEvent.clientY - lastY;
translation.value.x += deltaX;
translation.value.y += deltaY;
lastX = moveEvent.clientX;
lastY = moveEvent.clientY;
};
const onPointerUp = () => {
isDragging = false;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
}
</script>
<style lang="scss" module>
.root {
//transition: transform 0.2s ease;
touch-action: none;
}
.image {
display: block;
//transition: width 0.2s ease, height 0.2s ease;
}
</style>

View File

@@ -0,0 +1,84 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }">
<div :class="[$style.bg]"></div>
<div ref="mainEl" :class="$style.main">
<div :class="$style.items">
<div v-for="image in images" :key="image.src" :class="$style.item">
<XItem :image="image"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import XItem from './MkImageGallery.item.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
type Image = {
id: string;
src: string;
width: number;
height: number;
sourceElement?: HTMLElement;
};
const props = withDefaults(defineProps<{
defaultId?: string;
images: Image[];
}>(), {
});
const rootEl = useTemplateRef('rootEl');
const mainEl = useTemplateRef('mainEl');
const zIndex = os.claimZIndex('high');
</script>
<style lang="scss" module>
.root {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.bg {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #0f08;
}
.main {
position: absolute;
}
.items {
display: flex;
width: 100dvw;
height: 100dvh;
overflow-x: auto;
overflow-y: clip;
scrollbar-width: none;
}
.item {
width: 100dvw;
height: 100dvh;
overflow: clip;
contain: strict;
flex-shrink: 0;
}
</style>

View File

@@ -21,7 +21,7 @@ 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"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/>
<XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :disableImageLink="true" :class="$style.media" :data-id="media.id" :image="media" :raw="raw" @click="openGallery(media.id)"/>
</template>
</div>
</div>
@@ -31,9 +31,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import 'photoswipe/style.css';
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
@@ -48,18 +45,7 @@ const props = defineProps<{
}>();
const gallery = useTemplateRef('gallery');
const pswpZIndex = os.claimZIndex('middle');
window.document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
let lightbox: PhotoSwipeLightbox | null = null;
let activeEl: HTMLElement | null = null;
const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
lightbox.pswp.close();
}
};
async function calcAspectRatio() {
if (!gallery.value) return;
@@ -96,121 +82,9 @@ onMounted(() => {
calcAspectRatio();
if (gallery.value == null) return; // TSを黙らすため
lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList
.filter(media => {
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
})
.map(media => {
const item = {
src: media.url,
w: media.properties.width,
h: media.properties.height,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
alt: media.comment || media.name,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
comment: media.comment || media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
}
return item;
}),
gallery: gallery.value,
mainClass: 'pswp',
children: '.image',
thumbSelector: '.image',
loop: false,
padding: window.innerWidth > 500 ? {
top: 32,
bottom: 90,
left: 32,
right: 32,
} : {
top: 0,
bottom: 78,
left: 0,
right: 0,
},
imageClickAction: 'close',
tapAction: 'close',
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
lightbox.addFilter('itemData', (itemData) => {
// element is children
const { element } = itemData;
const id = element?.dataset.id;
const file = props.mediaList.find(media => media.id === id);
if (!file) return itemData;
itemData.src = file.url;
itemData.w = Number(file.properties.width);
itemData.h = Number(file.properties.height);
if (file.properties.orientation != null && file.properties.orientation >= 5) {
[itemData.w, itemData.h] = [itemData.h, itemData.w];
}
itemData.msrc = file.thumbnailUrl ?? undefined;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.alt = file.comment || file.name;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
itemData.comment = file.comment || file.name;
itemData.thumbCropped = true;
return itemData;
});
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pswp) => {
const textBox = window.document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});
lightbox.on('afterInit', () => {
activeEl = window.document.activeElement instanceof HTMLElement ? window.document.activeElement : null;
focusParent(activeEl, true, true);
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
window.history.pushState(null, '', '#pswp');
});
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
window.history.back();
}
});
window.addEventListener('popstate', popstateHandler);
lightbox.init();
});
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
@@ -219,11 +93,20 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
};
async function openGallery(id: string) {
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkImageGallery.vue').then(x => x.default), {
defaultId: id,
images: props.mediaList.filter(media => previewable(media)).map(media => ({
id: media.id,
src: media.url,
width: media.properties.width ?? 0,
height: media.properties.height ?? 0,
sourceElement: gallery.value?.querySelector(`.image[data-id="${media.id}"]`) as HTMLElement | undefined,
})),
}, {
closed: () => dispose(),
});
}
defineExpose({
openGallery,

View File

@@ -53,7 +53,6 @@ let latestHotkey: Pattern & { callback: CallbackFunction } | null = null;
export const makeHotkey = (keymap: Keymap, ignoreElements = IGNORE_ELEMENTS) => {
const actions = parseKeymap(keymap);
return (ev: KeyboardEvent) => {
if ('pswp' in window && window.pswp != null) return;
if (window.document.activeElement != null) {
if (ignoreElements.includes(window.document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(window.document.activeElement)?.isContentEditable) return;

View File

@@ -220,9 +220,6 @@ export function getConfig(): UserConfig {
groups: [{
name: 'vue',
test: /node_modules[\\/]vue/,
}, {
name: 'photoswipe',
test: /node_modules[\\/]photoswipe/,
}, {
// split i18n related module to distinct module
name: 'i18n',

9
pnpm-lock.yaml generated
View File

@@ -646,9 +646,6 @@ importers:
misskey-reversi:
specifier: workspace:*
version: link:../misskey-reversi
photoswipe:
specifier: 5.4.4
version: 5.4.4
punycode.js:
specifier: 2.3.1
version: 2.3.1
@@ -7237,10 +7234,6 @@ packages:
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
photoswipe@5.4.4:
resolution: {integrity: sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA==}
engines: {node: '>= 0.12.0'}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -15637,8 +15630,6 @@ snapshots:
dependencies:
split2: 4.2.0
photoswipe@5.4.4: {}
picocolors@1.1.1: {}
picomatch@2.3.2: {}