From 3bd055b045d26e6c9112442343a332624e9d75fe Mon Sep 17 00:00:00 2001
From: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Tue, 25 Jun 2024 20:18:14 +0900
Subject: [PATCH] =?UTF-8?q?=E5=9F=8B=E3=82=81=E8=BE=BC=E3=81=BF=E3=82=B3?=
 =?UTF-8?q?=E3=83=BC=E3=83=89=E7=94=9F=E6=88=90=E6=A9=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 locales/index.d.ts                            |  50 +++
 locales/ja-JP.yml                             |  14 +
 .../src/components/MkEmbedCodeGenDialog.vue   | 322 ++++++++++++++++++
 .../frontend/src/scripts/get-embed-code.ts    | 103 ++++++
 .../frontend/src/scripts/get-note-menu.ts     |   8 +
 .../frontend/src/scripts/get-user-menu.ts     |  13 +-
 packages/frontend/src/scripts/theme.ts        |   5 +-
 packages/frontend/src/style.embed.scss        |   1 +
 packages/frontend/src/style.scss              |  24 +-
 9 files changed, 527 insertions(+), 13 deletions(-)
 create mode 100644 packages/frontend/src/components/MkEmbedCodeGenDialog.vue
 create mode 100644 packages/frontend/src/scripts/get-embed-code.ts

diff --git a/locales/index.d.ts b/locales/index.d.ts
index b1817f04c7..2c56932e52 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -4988,6 +4988,14 @@ export interface Locale extends ILocale {
      * {x}から
      */
     "fromX": ParameterizedString<"x">;
+    /**
+     * 埋め込みコードをコピー
+     */
+    "copyEmbedCode": string;
+    /**
+     * このユーザーのノート
+     */
+    "noteOfThisUser": string;
     "_delivery": {
         /**
          * 配信状態
@@ -10070,6 +10078,48 @@ export interface Locale extends ILocale {
          */
         "loop": string;
     };
+    "_embedCodeGen": {
+        /**
+         * 埋め込みコードをカスタマイズ
+         */
+        "title": string;
+        /**
+         * ヘッダーを表示
+         */
+        "header": string;
+        /**
+         * 自動で続きを読み込む(非推奨)
+         */
+        "autoload": string;
+        /**
+         * 高さの最大値
+         */
+        "maxHeight": string;
+        /**
+         * 0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。
+         */
+        "maxHeightDescription": string;
+        /**
+         * 高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。
+         */
+        "maxHeightWarn": string;
+        /**
+         * 角丸にする
+         */
+        "rounded": string;
+        /**
+         * 外枠に枠線をつける
+         */
+        "border": string;
+        /**
+         * プレビューに反映
+         */
+        "applyToPreview": string;
+        /**
+         * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。
+         */
+        "previewIsNotActual": string;
+    };
 }
 declare const locales: {
     [lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index bc4b23bb51..9e8fd95cae 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1243,6 +1243,8 @@ noDescription: "説明文はありません"
 alwaysConfirmFollow: "フォローの際常に確認する"
 inquiry: "お問い合わせ"
 fromX: "{x}から"
+copyEmbedCode: "埋め込みコードをコピー"
+noteOfThisUser: "このユーザーのノート"
 
 _delivery:
   status: "配信状態"
@@ -2685,3 +2687,15 @@ _mediaControls:
   pip: "ピクチャインピクチャ"
   playbackRate: "再生速度"
   loop: "ループ再生"
+
+_embedCodeGen:
+  title: "埋め込みコードをカスタマイズ"
+  header: "ヘッダーを表示"
+  autoload: "自動で続きを読み込む(非推奨)"
+  maxHeight: "高さの最大値"
+  maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。"
+  maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。"
+  rounded: "角丸にする"
+  border: "外枠に枠線をつける"
+  applyToPreview: "プレビューに反映"
+  previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。"
diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
new file mode 100644
index 0000000000..89494ea2bf
--- /dev/null
+++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue
@@ -0,0 +1,322 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+	ref="dialogEl"
+	:width="1000"
+	:height="600"
+	:scroll="false"
+	:withOkButton="true"
+	@close="cancel()"
+	@ok="ok()"
+	@closed="$emit('closed')"
+>
+	<template #header>{{ i18n.ts._embedCodeGen.title }}</template>
+
+	<div :class="$style.embedCodeGenRoot">
+		<div :class="$style.embedCodeGenWrapper">
+			<div
+				:class="$style.embedCodeGenPreviewRoot"
+			>
+				<MkLoading :class="$style.embedCodeGenPreviewSpinner" v-if="iframeLoading"/>
+				<div :class="$style.embedCodeGenPreviewWrapper">
+					<div :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
+					<div :class="$style.embedCodeGenPreviewResizerRoot" ref="resizerRootEl">
+						<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-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="typeof maxHeight === 'number' && maxHeight <= 0" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
+				<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
+				<div>
+					<MkButton @click="applyToPreview" :disabled="iframeLoading">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
+				</div>
+			</div>
+		</div>
+	</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 MkInfo from '@/components/MkInfo.vue';
+
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { url } from '@/config.js';
+import copy from '@/scripts/copy-to-clipboard.js';
+import { normalizeEmbedParams, embedRouteWithScrollbar, getEmbedCode } from '@/scripts/get-embed-code.js';
+import type { EmbeddableEntity, EmbedParams } from '@/scripts/get-embed-code.js';
+
+const emit = defineEmits<{
+	(ev: 'ok', url: string, code: string): void;
+	(ev: 'cancel'): void;
+	(ev: 'closed'): void;
+}>();
+
+const props = withDefaults(defineProps<{
+	entity: EmbeddableEntity;
+	idOrUsername: string;
+	params?: EmbedParams;
+	doCopy?: boolean;
+}>(), {
+	doCopy: true,
+});
+
+//#region Modalの制御
+const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
+
+function cancel() {
+	emit('cancel');
+	dialogEl.value?.close();
+}
+
+function ok() {
+	const _idOrUsername = props.entity === 'user-timeline' ? '@' + props.idOrUsername : props.idOrUsername;
+	const generatedUrl = `${url}/embed/${props.entity}/${_idOrUsername}?${new URLSearchParams(normalizeEmbedParams(paramsForUrl.value)).toString()}`;
+	const generatedCode = getEmbedCode(`/embed/${props.entity}/${_idOrUsername}`, paramsForUrl.value);
+	if (props.doCopy) {
+		copy(generatedCode);
+		os.success();
+	}
+	emit('ok', generatedUrl, generatedCode);
+	dialogEl.value?.close();
+}
+//#endregion
+
+//#region 埋め込みURL生成・カスタマイズ
+
+// 本URL生成用params
+const paramsForUrl = computed<EmbedParams>(() => ({
+	header: header.value === true ? undefined : header.value,
+	autoload: autoload.value === true ? undefined : autoload.value,
+	maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
+	colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
+	rounded: rounded.value === true ? undefined : rounded.value,
+	border: border.value === true ? undefined : border.value,
+}));
+
+// プレビュー用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までに制限
+	}
+	return `${url}/embed/${props.entity}/${_idOrUsername}?${paramClass.toString()}`;
+});
+
+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();
+		}
+	});
+}
+//#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ずつ(プレビューの文字は28px)
+	const iframeWidth = 500;
+	const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大はしない
+	iframeScale.value = scale;
+}
+onMounted(() => {
+	window.addEventListener('message', windowEventHandler);
+	if (!resizerRootEl.value) return;
+	resizeObserver.observe(resizerRootEl.value);
+});
+onDeactivated(() => {
+	window.removeEventListener('message', windowEventHandler);
+	resizeObserver.disconnect();
+});
+onUnmounted(() => {
+	window.removeEventListener('message', windowEventHandler);
+	resizeObserver.disconnect();
+});
+//#endregion
+</script>
+
+<style module>
+.embedCodeGenRoot {
+	container-type: inline-size;
+	height: 100%;
+}
+
+.embedCodeGenWrapper {
+	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;
+}
+
+@container (max-width: 800px) {
+	.embedCodeGenWrapper {
+		grid-template-columns: 1fr;
+		grid-template-rows: 1fr 1fr;
+	}
+}
+</style>
diff --git a/packages/frontend/src/scripts/get-embed-code.ts b/packages/frontend/src/scripts/get-embed-code.ts
new file mode 100644
index 0000000000..5e54b3be6d
--- /dev/null
+++ b/packages/frontend/src/scripts/get-embed-code.ts
@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+import { v4 as uuid } from 'uuid';
+import { url } from '@/config.js';
+import { MOBILE_THRESHOLD } from '@/const.js';
+import * as os from '@/os.js';
+import copy from '@/scripts/copy-to-clipboard.js';
+import MkEmbedCodeGenDialog from '@/components/MkEmbedCodeGenDialog.vue';
+
+// 埋め込みの対象となるエンティティ(/embed/xxx の xxx の部分と対応させる)
+const embeddableEntities = [
+	'notes',
+	'user-timeline',
+	'clip',
+	'tag',
+] as const;
+
+export type EmbeddableEntity = typeof embeddableEntities[number];
+
+// 内部でスクロールがあるページ
+export const embedRouteWithScrollbar: EmbeddableEntity[] = [
+	'clip',
+	'tag',
+	'user-timeline'
+];
+
+export type EmbedParams = {
+	maxHeight?: number;
+	colorMode?: 'light' | 'dark';
+	rounded?: boolean;
+	border?: boolean;
+	autoload?: boolean;
+	header?: boolean;
+};
+
+export function normalizeEmbedParams(params: EmbedParams): Record<string, string> {
+	// paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す
+	const normalizedParams: Record<string, string> = {};
+	for (const key in params) {
+		if (params[key] == null) {
+			continue;
+		}
+		switch (typeof params[key]) {
+			case 'number':
+				normalizedParams[key] = params[key].toString();
+				break;
+			case 'boolean':
+				normalizedParams[key] = params[key] ? 'true' : 'false';
+				break;
+			default:
+				normalizedParams[key] = params[key];
+				break;
+		}
+	}
+	return normalizedParams;
+}
+
+/**
+ * 埋め込みコードを生成(iframe IDの発番もやる)
+ */
+export function getEmbedCode(path: string, params?: EmbedParams): string {
+	const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく
+
+	let paramString = '';
+	if (params) {
+		const searchParams = new URLSearchParams(normalizeEmbedParams(params));
+		paramString = '?' + searchParams.toString();
+	}
+
+	const iframeCode = [
+		`<iframe src="${url + path + paramString}" data-misskey-embed-id="${iframeId}" style="border: none; width: 100%; max-width: 500px; height: 300px; color-scheme: light dark;"></iframe>`,
+		`<script defer src="${url}/embed.js"></script>`,
+	];
+	return iframeCode.join('\n');
+}
+
+/**
+ * 埋め込みコードを生成してコピーする(カスタマイズ機能つき)
+ *
+ * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください
+ */
+export function copyEmbedCode(entity: EmbeddableEntity, idOrUsername: string, params?: EmbedParams) {
+	const _params = { ...params };
+
+	if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) {
+		_params.maxHeight = 700;
+	}
+
+	// PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー
+	if (window.innerWidth < MOBILE_THRESHOLD) {
+		const _idOrUsername = entity === 'user-timeline' ? `@${idOrUsername}` : idOrUsername;
+		copy(getEmbedCode(`/embed/${entity}/${_idOrUsername}`, _params));
+		os.success();
+	} else {
+		os.popup(MkEmbedCodeGenDialog, {
+			entity,
+			idOrUsername,
+			params: _params,
+		});
+	}
+}
diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts
index 71ad299f50..342b7b809d 100644
--- a/packages/frontend/src/scripts/get-note-menu.ts
+++ b/packages/frontend/src/scripts/get-note-menu.ts
@@ -20,6 +20,7 @@ import { clipsCache, favoritedChannelsCache } from '@/cache.js';
 import { MenuItem } from '@/types/menu.js';
 import MkRippleEffect from '@/components/MkRippleEffect.vue';
 import { isSupportShare } from '@/scripts/navigator.js';
+import { copyEmbedCode } from '@/scripts/get-embed-code.js';
 
 export async function getNoteClipMenu(props: {
 	note: Misskey.entities.Note;
@@ -321,6 +322,13 @@ export function getNoteMenu(props: {
 				text: i18n.ts.share,
 				action: share,
 			}] : []),
+			(!appearNote.url && !appearNote.uri) ? {
+				icon: 'ti ti-code',
+				text: i18n.ts.copyEmbedCode,
+				action: () => {
+					copyEmbedCode('notes', appearNote.id);
+				},
+			} : undefined,
 			$i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
 				icon: 'ti ti-language-hiragana',
 				text: i18n.ts.translate,
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index 3e031d232f..33fdab393c 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -16,6 +16,7 @@ import { $i, iAmModerator } from '@/account.js';
 import { IRouter } from '@/nirax.js';
 import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
 import { mainRouter } from '@/router/main.js';
+import { copyEmbedCode } from '@/scripts/get-embed-code.js';
 
 export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
 	const meId = $i ? $i.id : null;
@@ -177,7 +178,17 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
 			if (user.url == null) return;
 			window.open(user.url, '_blank', 'noopener');
 		},
-	}] : []), {
+	}] : [{
+		icon: 'ti ti-code',
+		text: i18n.ts.copyEmbedCode,
+		type: 'parent' as const,
+		children: [{
+			text: i18n.ts.noteOfThisUser,
+			action: () => {
+				copyEmbedCode('user-timeline', user.username);
+			},
+		}], // TODO: ユーザーカードの埋め込みなど
+	}]), {
 		icon: 'ti ti-share',
 		text: i18n.ts.copyProfileUrl,
 		action: () => {
diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts
index c7f8b3d596..60045ac9f0 100644
--- a/packages/frontend/src/scripts/theme.ts
+++ b/packages/frontend/src/scripts/theme.ts
@@ -11,6 +11,7 @@ import { globalEvents } from '@/events.js';
 import lightTheme from '@/themes/_light.json5';
 import darkTheme from '@/themes/_dark.json5';
 import { miLocalStorage } from '@/local-storage.js';
+import { isEmbedPage } from '@/scripts/embed-page.js';
 
 export type Theme = {
 	id: string;
@@ -95,7 +96,9 @@ export function applyTheme(theme: Theme, persist = true) {
 		document.documentElement.style.setProperty(`--${k}`, v.toString());
 	}
 
-	document.documentElement.style.setProperty('color-scheme', colorScheme);
+	if (!isEmbedPage()) {
+		document.documentElement.style.setProperty('color-scheme', colorScheme);
+	}
 
 	if (persist) {
 		miLocalStorage.setItem('theme', JSON.stringify(props));
diff --git a/packages/frontend/src/style.embed.scss b/packages/frontend/src/style.embed.scss
index a40bc35431..60b3e538fb 100644
--- a/packages/frontend/src/style.embed.scss
+++ b/packages/frontend/src/style.embed.scss
@@ -8,6 +8,7 @@
 
 html.embed {
 	background-color: transparent;
+	color-scheme: light dark;
 	overflow: hidden;
 }
 
diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss
index 250a2616a7..7f602c46f9 100644
--- a/packages/frontend/src/style.scss
+++ b/packages/frontend/src/style.scss
@@ -75,20 +75,22 @@ html {
 		}
 	}
 
-	&.f-1 {
-		font-size: 15px;
-	}
+	&:not(.embed) {
+		&.f-1 {
+			font-size: 15px;
+		}
 
-	&.f-2 {
-		font-size: 16px;
-	}
+		&.f-2 {
+			font-size: 16px;
+		}
 
-	&.f-3 {
-		font-size: 17px;
-	}
+		&.f-3 {
+			font-size: 17px;
+		}
 
-	&.useSystemFont {
-		font-family: system-ui;
+		&.useSystemFont {
+			font-family: system-ui;
+		}
 	}
 }