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; + } } }