From 2cbe1d1210a5745787f37069ecb59b8f6c03c224 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Mon, 9 Sep 2024 20:57:36 +0900
Subject: feat(frontend): ノート・ユーザータイムライン埋め込み (#13929)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* 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

* うぃｐ

* 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>
---
 .../src/components/MkEmbedCodeGenDialog.vue        | 412 +++++++++++++++++++++
 1 file changed, 412 insertions(+)
 create mode 100644 packages/frontend/src/components/MkEmbedCodeGenDialog.vue

(limited to 'packages/frontend/src/components/MkEmbedCodeGenDialog.vue')

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>
-- 
cgit v1.2.3-freya

