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 @@ + + + + + + + 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 { + // paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す + const normalizedParams: Record = {}; + 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 = [ + ``, + ``, + ]; + 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; + } } }