feat(frontend): ノート・ユーザータイムライン埋め込み (#13929)
* 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
* うぃp
* 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>
This commit is contained in:
parent
0d0cd738f8
commit
2cbe1d1210
236 changed files with 9470 additions and 454 deletions
412
packages/frontend/src/components/MkEmbedCodeGenDialog.vue
Normal file
412
packages/frontend/src/components/MkEmbedCodeGenDialog.vue
Normal file
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue