summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-09-09 20:57:36 +0900
committerGitHub <noreply@github.com>2024-09-09 20:57:36 +0900
commit2cbe1d1210a5745787f37069ecb59b8f6c03c224 (patch)
tree9acb1e675d2ae85f7f1f0f34f6acdc4965bff3f9 /packages/frontend/src/components/MkEmbedCodeGenDialog.vue
parentrefactor(misskey-js): warnを除去 (#14520) (diff)
downloadsharkey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.tar.gz
sharkey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.tar.bz2
sharkey-2cbe1d1210a5745787f37069ecb59b8f6c03c224.zip
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>
Diffstat (limited to 'packages/frontend/src/components/MkEmbedCodeGenDialog.vue')
-rw-r--r--packages/frontend/src/components/MkEmbedCodeGenDialog.vue412
1 files changed, 412 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..51630c427c
--- /dev/null
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -0,0 +1,412 @@
+<!--
+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>