mizzkey/packages/frontend/src/components/MkEmbedCodeGenDialog.vue

421 lines
12 KiB
Vue
Raw Normal View History

2024-06-25 13:18:14 +02:00
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialogEl"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="false"
2024-06-25 13:18:14 +02:00
@close="cancel()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts._embedCodeGen.title }}</template>
<div :class="$style.embedCodeGenRoot">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div
:class="$style.embedCodeGenPreviewRoot"
>
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
<div
:class="$style.embedCodeGenPreviewResizer"
:style="{ transform: iframeStyle }"
>
<iframe
ref="iframeEl"
:src="embedPreviewUrl"
:class="$style.embedCodeGenPreviewIframe"
:style="{ height: `${iframeHeight}px` }"
@load="iframeOnLoad"
></iframe>
</div>
2024-06-25 13:18:14 +02:00
</div>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode">
<template #label>{{ i18n.ts.theme }}</template>
<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
<option value="light">{{ i18n.ts.light }}</option>
<option value="dark">{{ i18n.ts.dark }}</option>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="autoload">{{ i18n.ts._embedCodeGen.autoload }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
2024-06-25 13:18:14 +02:00
</div>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
<div :class="$style.embedCodeGenResultHeadingIcon"><i class="ti ti-check"></i></div>
<div :class="$style.embedCodeGenResultHeading">{{ i18n.ts._embedCodeGen.codeGenerated }}</div>
<div :class="$style.embedCodeGenResultDescription">{{ i18n.ts._embedCodeGen.codeGeneratedDescription }}</div>
</div>
<div class="_gaps_s">
<MkCode :code="result" lang="html" :forceShow="true" :copyButton="false" :class="$style.embedCodeGenResultCode"/>
<MkButton :class="$style.embedCodeGenResultButtons" rounded primary @click="doCopy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
</div>
<MkButton :class="$style.embedCodeGenResultButtons" rounded transparent @click="close">{{ i18n.ts.close }}</MkButton>
2024-06-25 13:18:14 +02:00
</div>
</div>
</Transition>
2024-06-25 13:18:14 +02:00
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
2024-06-25 13:18:14 +02:00
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { url } from '@/config.js';
2024-07-18 11:59:32 +02:00
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
import { embedRouteWithScrollbar } from '@/scripts/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@/scripts/embed-page.js';
2024-06-25 13:18:14 +02:00
const emit = defineEmits<{
(ev: 'ok'): void;
2024-06-25 13:18:14 +02:00
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const props = defineProps<{
2024-06-25 13:18:14 +02:00
entity: EmbeddableEntity;
idOrUsername: string;
params?: EmbedParams;
}>();
2024-06-25 13:18:14 +02:00
//#region Modalの制御
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
function cancel() {
emit('cancel');
dialogEl.value?.close();
}
function close() {
2024-06-25 13:18:14 +02:00
dialogEl.value?.close();
}
const phase = ref<'input' | 'result'>('input');
2024-06-25 13:18:14 +02:00
//#endregion
//#region 埋め込みURL生成・カスタマイズ
// 本URL生成用params
const paramsForUrl = computed<EmbedParams>(() => ({
2024-06-26 04:38:18 +02:00
header: header.value,
autoload: autoload.value,
2024-06-25 13:18:14 +02:00
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
2024-06-26 04:38:18 +02:00
rounded: rounded.value,
border: border.value,
2024-06-25 13:18:14 +02:00
}));
// プレビュー用params手動で更新を掛けるのでref
const paramsForPreview = ref<EmbedParams>(props.params ?? {});
const embedPreviewUrl = computed(() => {
const _idOrUsername = props.entity === 'user-timeline' ? '@' + props.idOrUsername : props.idOrUsername;
const paramClass = new URLSearchParams(normalizeEmbedParams(paramsForPreview.value));
if (paramClass.has('maxHeight')) {
const maxHeight = parseInt(paramClass.get('maxHeight')!);
paramClass.set('maxHeight', maxHeight === 0 ? '500' : Math.min(maxHeight, 700).toString()); // プレビューであまりにも縮小されると見づらいため、700pxまでに制限
}
2024-06-29 14:12:33 +02:00
return `${url}/embed/${props.entity}/${_idOrUsername}${paramClass.toString() ? '?' + paramClass.toString() : ''}`;
2024-06-25 13:18:14 +02:00
});
const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
const header = ref(props.params?.header ?? true);
const autoload = ref(props.params?.autoload ?? false);
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500);
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
const rounded = ref(props.params?.rounded ?? true);
const border = ref(props.params?.border ?? true);
function applyToPreview() {
const currentPreviewUrl = embedPreviewUrl.value;
paramsForPreview.value = {
header: header.value,
autoload: false, // プレビューはスクロールできないので常にfalse
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
rounded: rounded.value,
border: border.value,
};
nextTick(() => {
if (currentPreviewUrl === embedPreviewUrl.value) {
// URLが変わらなくてもリロード
iframeEl.value?.contentWindow?.location.reload();
}
});
}
const result = ref('');
2024-06-29 14:24:13 +02:00
function generate() {
const _idOrUsername = props.entity === 'user-timeline' ? '@' + props.idOrUsername : props.idOrUsername;
result.value = getEmbedCode(`/embed/${props.entity}/${_idOrUsername}`, paramsForUrl.value);
phase.value = 'result';
}
function doCopy() {
2024-07-18 11:59:32 +02:00
copyToClipboard(result.value);
os.success();
}
2024-06-25 13:18:14 +02:00
//#endregion
//#region プレビューのリサイズ
const resizerRootEl = shallowRef<HTMLDivElement>();
const iframeLoading = ref(true);
const iframeEl = shallowRef<HTMLIFrameElement>();
const iframeHeight = ref(0);
const iframeScale = ref(1);
const iframeStyle = computed(() => {
return `translate(-50%, -50%) scale(${iframeScale.value})`;
});
const resizeObserver = new ResizeObserver(() => {
calcScale();
});
function iframeOnLoad() {
iframeEl.value?.contentWindow?.addEventListener('beforeunload', () => {
iframeLoading.value = true;
nextTick(() => {
iframeHeight.value = 0;
iframeScale.value = 1;
});
});
}
2024-06-25 14:08:58 +02:00
2024-06-25 13:18:14 +02:00
function windowEventHandler(event: MessageEvent) {
if (event.source !== iframeEl.value?.contentWindow) {
return;
}
if (event.data.type === 'misskey:embed:ready') {
iframeEl.value!.contentWindow?.postMessage({
type: 'misskey:embedParent:registerIframeId',
payload: {
iframeId: 'embedCodeGen', // 同じタイミングで複数のembed iframeがある際の区別用なのでここではなんでもいい
},
});
}
if (event.data.type === 'misskey:embed:changeHeight') {
iframeHeight.value = event.data.payload.height;
nextTick(() => {
calcScale();
iframeLoading.value = false; // 初回の高さ変更まで待つ
});
}
}
2024-06-25 14:08:58 +02:00
2024-06-25 13:18:14 +02:00
function calcScale() {
if (!resizerRootEl.value) return;
const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ
2024-07-06 03:53:51 +02:00
const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ
2024-06-25 13:18:14 +02:00
const iframeWidth = 500;
2024-07-06 03:53:51 +02:00
const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大はしないので1を上限に
2024-06-25 13:18:14 +02:00
iframeScale.value = scale;
}
2024-06-25 14:08:58 +02:00
2024-06-25 13:18:14 +02:00
onMounted(() => {
window.addEventListener('message', windowEventHandler);
if (!resizerRootEl.value) return;
resizeObserver.observe(resizerRootEl.value);
});
2024-06-25 14:08:58 +02:00
function reset() {
2024-06-25 13:18:14 +02:00
window.removeEventListener('message', windowEventHandler);
resizeObserver.disconnect();
// プレビューのリセット
iframeHeight.value = 0;
iframeScale.value = 1;
iframeLoading.value = true;
result.value = '';
phase.value = 'input';
}
onDeactivated(() => {
reset();
2024-06-25 13:18:14 +02:00
});
2024-06-25 14:08:58 +02:00
2024-06-25 13:18:14 +02:00
onUnmounted(() => {
reset();
2024-06-25 13:18:14 +02:00
});
//#endregion
</script>
<style module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
2024-06-25 13:18:14 +02:00
.embedCodeGenRoot {
container-type: inline-size;
height: 100%;
}
.embedCodeGenInputRoot {
2024-06-25 13:18:14 +02:00
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
background-color: var(--bg);
cursor: not-allowed;
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.embedCodeGenPreviewTitle {
width: fit-content;
flex-shrink: 0;
padding: 0 8px;
background-color: var(--panel);
border-right: 1px solid var(--divider);
border-bottom: 1px solid var(--divider);
border-bottom-right-radius: var(--radius);
height: 28px;
line-height: 28px;
box-sizing: border-box;
}
.embedCodeGenPreviewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.embedCodeGenPreviewResizerRoot {
position: relative;
flex: 1 0;
}
.embedCodeGenPreviewResizer {
position: absolute;
top: 50%;
left: 50%;
}
.embedCodeGenPreviewIframe {
border: none;
width: 500px;
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
height: 100%;
max-width: 700px;
margin: 0 auto;
display: flex;
align-items: center;
}
.embedCodeGenResultHeading {
text-align: center;
font-size: 1.2em;
}
.embedCodeGenResultHeadingIcon {
margin: 0 auto;
background-color: var(--accentedBg);
color: var(--accent);
text-align: center;
height: 64px;
width: 64px;
font-size: 24px;
line-height: 64px;
border-radius: 50%;
}
.embedCodeGenResultDescription {
text-align: center;
white-space: pre-wrap;
}
.embedCodeGenResultWrapper,
.embedCodeGenResultCode {
width: 100%;
}
.embedCodeGenResultButtons {
margin: 0 auto;
}
2024-06-25 13:18:14 +02:00
@container (max-width: 800px) {
2024-07-06 14:32:41 +02:00
.embedCodeGenInputRoot {
2024-06-25 13:18:14 +02:00
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>