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

413 lines
12 KiB
Vue
Raw Normal View History

feat(frontend): ノート・ユーザータイムライン埋め込み (#13929) * fix * navhookをbootに移動 * サーバーサイドのbootも分けるように * 埋め込みページかどうかの判定は最初の一回だけに * tooltipは出せるように * fix design * 埋め込み独自のtooltipを削除 * ロジックの分岐が多かったMkNoteDetailedを分離 * fix indent * プレビュー用iframeにフォーカスが当たるのを修正 * popupの制御を出す側で行うように * パラメータが逆になっていたのを修正 * Update MkEmbedCodeGenDialog.vue * fix * eliminate misskey-js lint warns * fix * add appropriate attributes to embed html * enhance: サーバーサイドのembed系をさらに分離 * enhance: embed routerを分離(route定義をboot時に変更できるようにする改修を含む) * type * lint * fix indent * server-side styleを完全に分離 * Revert "refactor: 画面サイズのしきい値をconstにまとめる" This reverts commit 05ca36f400889456981e89489ae0ae242fa09b67. * fix * revert all changes in base.pug * embedドメインをまとめた * embedドメインをまとめた * prevent calling contextmenu in embed page by stopping at the caller * fix import * fix import * improve directory structure * fix import * register timeline ui as a container * wa- * rename * wa- * Update EmMediaList.vue * Update EmMediaList.vue * Update EmMediaList.vue * Update EmMediaImage.vue * Update EmNote.vue * revert mkmedialist changes * 戻し漏れ * wip * tweak embed media ui * revert original media components * Update boot.embed.js * rename * wip * Update MkNote.vue * wip * Update MkSubNoteContent.vue * Update EmNote.vue * Update packages/frontend/src/router/definition.ts * Revert "Update packages/frontend/src/router/definition.ts" This reverts commit 937ae44521cdb0f250796943b20142b65f8ed944. * refactor EmMediaImage * fix import * remove unused imports * Update router.ts * wip * Update boot.ts * wip * wip * wip * wip * Update EmNote.vue * Update EmNote.vue * Create EmA.vue * Create EmAvatar.vue * Update EmAvatar.vue * wip * wip * wip * Create EmImgWithBlurhash.vue * Update EmImgWithBlurhash.vue * Create EmPagination.vue * wip * Update boot.ts * wip * wip * wi@p * wip * wip * wiop * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update boot.ts * wip * Update MkMisskeyFlavoredMarkdown.ts * wip * wip * wip * wip * wip * Update post-message.ts * wip * Update EmNoteDetailed.vue * Update EmNoteDetailed.vue * Create instance.ts * Update EmNoteDetailed.vue * wip * Update EmNoteDetailed.vue * wip * wip * wip * Update pnpm-lock.yaml * wip * wip * wp * wip * Update ClientServerService.ts * wip * Update boot.ts * Update vite.config.local-dev.ts * Update vite.config.ts * Create index.html * wa- * wip * Update boot.ts * wip * wip * wip * wip * wip * wip * wip * wip * wip * Create EmLink.vue * Create EmMention.vue * Update EmMfm.ts * wip * wip * wip * wip * Update vite.config.ts * Update boot.ts * Update EmA.vue * うぃp * wip * wip * Create EmError.vue * wip * Update MkEmbedCodeGenDialog.vue * Update EmNote.vue * wip * wip * Update user-timeline.vue * Update check-spdx-license-id.yml * wip * wip * style(frontend-shared): lint fixes on build.js * fix(frontend-shared): include `*.{js,json}` files in js-built * wip * use alias * refactor * refactor * Update scroll.ts * refactor * refactor * refactor * wip * wip * wip * wip * Update roles.vue * Update branding.vue * wip * wip * wip * Update page.vue * wip * fix import * add missing css variables * 絵文字をtwemojiに変更 クライアントデフォルトにあわせるため * force empoll readonly * fix compiler error * fix broken imports * tweak button style * run api extractor * fix storybook theme preloads * fix storybook instance imports * Update preview.ts * Update preview.ts * Update preview.ts * Revert "Update preview.ts" This reverts commit 12bab1c6fbd3baf753515df760ff19d027b85155. * Revert "Update preview.ts" This reverts commit 5c0ce01dbdf2194ffe94aba950f747a9968f29c4. * Revert "Update preview.ts" This reverts commit f4863524d7e5ca0f25470808849c24a72bea000a. * Revert "fix storybook instance imports" This reverts commit ed8eabb246edf731d31adffbe3c77c539e53ae9e. * Revert "wip" This reverts commit d3c1926519878155193a1654f49141e515d49683. * Revert "Update page.vue" This reverts commit 27c7900b0c1ae296b56075e8a9c22585d9cd744b. * Revert "Update branding.vue" This reverts commit c08ccb65ba66774c3e2b3dcfc6153004b5c0aa16. * Revert "Update roles.vue" This reverts commit 1488b670660cb1803d17d8f5c78f2d79e59fa52d. * Revert "wip" This reverts commit aab1c769814b08c257cad3025422a0eea3bfba4f. * refactor: use common media proxy * fix imports * fix * fix: MediaProxyの初期化を保証する(storybook対策?) * enhance(frontend-embed): improve embedParams provide * fix(backend): MK_DEV_PREFER=backendのときにembed viteが読み込めないのを修正 * fix * embed-pageを共通化 * fix import * fix import * fix import * const.jsを共通化 (たぶんrevertしすぎた) * fix type error * fix duplicated import * fix lint * fix * コメントとして残す * sharedとembedをlint対象にする * lint * attempt to fix eslint (frontend-shared) * lint fixes --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com>
2024-09-09 06:57:36 -05: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"
@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="_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 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 { url } from '@/config.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.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);
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>