埋め込みコード生成機能

This commit is contained in:
kakkokari-gtyih 2024-06-25 20:18:14 +09:00
parent 05ca36f400
commit 3bd055b045
9 changed files with 527 additions and 13 deletions

50
locales/index.d.ts vendored
View file

@ -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;

View file

@ -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: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。"

View file

@ -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
// URLparams
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,
}));
// paramsref
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; // 20px28px
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>

View file

@ -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,
});
}
}

View file

@ -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,

View file

@ -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: () => {

View file

@ -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));

View file

@ -8,6 +8,7 @@
html.embed {
background-color: transparent;
color-scheme: light dark;
overflow: hidden;
}

View file

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