summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-10-09 15:17:22 +0100
committerdakkar <dakkar@thenautilus.net>2024-10-09 15:17:22 +0100
commitf00576bce6b5f4086112f48046316bfe49559759 (patch)
tree9268031a42551f3bfafbb33091f925e0cb5af3aa /packages/frontend/src/components/MkEmbedCodeGenDialog.vue
parentMerge branch 'merge-requests/668' into feature/2024.9.0 (diff)
parentMerge pull request #14580 from misskey-dev/develop (diff)
downloadsharkey-f00576bce6b5f4086112f48046316bfe49559759.tar.gz
sharkey-f00576bce6b5f4086112f48046316bfe49559759.tar.bz2
sharkey-f00576bce6b5f4086112f48046316bfe49559759.zip
Merge remote-tracking branch 'misskey/master' into feature/2024.9.0
Diffstat (limited to 'packages/frontend/src/components/MkEmbedCodeGenDialog.vue')
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue414
1 files changed, 414 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
new file mode 100644
index 0000000000..c060c3a659
--- /dev/null
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -0,0 +1,414 @@
+<!--
+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"
+ @close="cancel()"
+ @closed="$emit('closed')"
+>
+ <template #header><i class="ti ti-code"></i> {{ 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="_acrylic" :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>
+ </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-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>
+ </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>
+ </div>
+ </div>
+ </Transition>
+ </div>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
+import { url } from '@@/js/config.js';
+import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
+import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
+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';
+import MkInfo from '@/components/MkInfo.vue';
+
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
+
+const emit = defineEmits<{
+ (ev: 'ok'): void;
+ (ev: 'cancel'): void;
+ (ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+ entity: EmbeddableEntity;
+ id: string;
+ params?: EmbedParams;
+}>();
+
+//#region Modalの制御
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function cancel() {
+ emit('cancel');
+ dialogEl.value?.close();
+}
+
+function close() {
+ dialogEl.value?.close();
+}
+
+const phase = ref<'input' | 'result'>('input');
+//#endregion
+
+//#region 埋め込みURL生成・カスタマイズ
+
+// 本URL生成用params
+const paramsForUrl = computed<EmbedParams>(() => ({
+ header: header.value,
+ maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
+ colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
+ rounded: rounded.value,
+ border: border.value,
+}));
+
+// プレビュー用params(手動で更新を掛けるのでref)
+const paramsForPreview = ref<EmbedParams>(props.params ?? {});
+
+const embedPreviewUrl = computed(() => {
+ 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までに制限
+ }
+ return `${url}/embed/${props.entity}/${props.id}${paramClass.toString() ? '?' + paramClass.toString() : ''}`;
+});
+
+const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
+const header = ref(props.params?.header ?? true);
+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,
+ 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('');
+
+function generate() {
+ result.value = getEmbedCode(`/embed/${props.entity}/${props.id}`, paramsForUrl.value);
+ phase.value = 'result';
+}
+
+function doCopy() {
+ copyToClipboard(result.value);
+ os.success();
+}
+//#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;
+ });
+ });
+}
+
+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; // 初回の高さ変更まで待つ
+ });
+ }
+}
+
+function calcScale() {
+ if (!resizerRootEl.value) return;
+ const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ
+ const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ
+ const iframeWidth = 500;
+ const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大はしないので1を上限に
+ iframeScale.value = scale;
+}
+
+onMounted(() => {
+ window.addEventListener('message', windowEventHandler);
+ if (!resizerRootEl.value) return;
+ resizeObserver.observe(resizerRootEl.value);
+});
+
+function reset() {
+ window.removeEventListener('message', windowEventHandler);
+ resizeObserver.disconnect();
+
+ // プレビューのリセット
+ iframeHeight.value = 0;
+ iframeScale.value = 1;
+ iframeLoading.value = true;
+ result.value = '';
+ phase.value = 'input';
+}
+
+onDeactivated(() => {
+ reset();
+});
+
+onUnmounted(() => {
+ reset();
+});
+//#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);
+}
+
+.embedCodeGenRoot {
+ container-type: inline-size;
+ height: 100%;
+}
+
+.embedCodeGenInputRoot {
+ height: 100%;
+ display: grid;
+ grid-template-columns: 1fr 400px;
+}
+
+.embedCodeGenPreviewRoot {
+ position: relative;
+ background-color: var(--bg);
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--panel) 6px, var(--panel) 12px);
+ cursor: not-allowed;
+}
+
+.embedCodeGenPreviewWrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ pointer-events: none;
+ user-select: none;
+ -webkit-user-drag: none;
+}
+
+.embedCodeGenPreviewTitle {
+ position: absolute;
+ z-index: 100;
+ top: 8px;
+ left: 8px;
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 85%;
+}
+
+.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 {
+ display: block;
+ 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;
+}
+
+@container (max-width: 800px) {
+ .embedCodeGenInputRoot {
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+}
+</style>