diff --git a/locales/index.d.ts b/locales/index.d.ts index 077a0e271e..36818b0065 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -10103,6 +10103,10 @@ export interface Locale extends ILocale { * 高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。 */ "maxHeightWarn": string; + /** + * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。 + */ + "previewIsNotActual": string; /** * 角丸にする */ @@ -10116,9 +10120,17 @@ export interface Locale extends ILocale { */ "applyToPreview": string; /** - * プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。 + * 埋め込みコードを作成 */ - "previewIsNotActual": string; + "generateCode": string; + /** + * コードが生成されました + */ + "codeGenerated": string; + /** + * 生成されたコードをウェブサイトに貼り付けてご利用ください。 + */ + "codeGeneratedDescription": string; }; } declare const locales: { diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 07f8ac4bac..a909a3df1a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2695,7 +2695,10 @@ _embedCodeGen: maxHeight: "高さの最大値" maxHeightDescription: "0で最大値の設定が無効になります。ウィジェットが縦に伸び続けるのを防ぐために、何らかの値に指定してください。" maxHeightWarn: "高さの最大値制限が無効(0)になっています。これが意図した変更ではない場合は、高さの最大値を何らかの値に設定してください。" + previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。" rounded: "角丸にする" border: "外枠に枠線をつける" applyToPreview: "プレビューに反映" - previewIsNotActual: "プレビュー画面で表示可能な範囲を超えたため、実際に埋め込んだ際とは表示が異なります。" + generateCode: "埋め込みコードを作成" + codeGenerated: "コードが生成されました" + codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a3c80e743b..7216daffe3 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.codeBlockRoot"> - <button :class="$style.codeBlockCopyButton" class="_button" @click="copy"> + <button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy"> <i class="ti ti-copy"></i> </button> <Suspense> @@ -32,12 +32,17 @@ import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ code: string; + forceShow?: boolean; + copyButton?: boolean; lang?: string; -}>(); +}>(), { + copyButton: true, + forceShow: false, +}); -const show = ref(!defaultStore.state.dataSaver.code); +const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code); const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index f6b4499c70..8f99cc4bb7 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -9,60 +9,82 @@ SPDX-License-Identifier: AGPL-3.0-only :width="1000" :height="600" :scroll="false" - :withOkButton="true" + :withOkButton="false" @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 v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/> - <div :class="$style.embedCodeGenPreviewWrapper"> - <div :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> - <div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot"> - <div - :class="$style.embedCodeGenPreviewResizer" - :style="{ transform: iframeStyle }" - > - <iframe - ref="iframeEl" - :src="embedPreviewUrl" - :class="$style.embedCodeGenPreviewIframe" - :style="{ height: `${iframeHeight}px` }" - @load="iframeOnLoad" - ></iframe> + <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="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div> + <div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot"> + <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> - <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="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> + <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="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> + <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> @@ -75,6 +97,8 @@ 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'; @@ -86,19 +110,16 @@ import { embedRouteWithScrollbar } from '@/scripts/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@/scripts/embed-page.js'; const emit = defineEmits<{ - (ev: 'ok', url: string, code: string): void; + (ev: 'ok'): void; (ev: 'cancel'): void; (ev: 'closed'): void; }>(); -const props = withDefaults(defineProps<{ +const props = defineProps<{ entity: EmbeddableEntity; idOrUsername: string; params?: EmbedParams; - doCopy?: boolean; -}>(), { - doCopy: true, -}); +}>(); //#region Modalの制御 const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); @@ -108,17 +129,11 @@ function 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); +function close() { dialogEl.value?.close(); } + +const phase = ref<'input' | 'result'>('input'); //#endregion //#region 埋め込みURL生成・カスタマイズ @@ -143,7 +158,7 @@ const embedPreviewUrl = computed(() => { 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() ? '?' + paramClass.toString() : ''}`; + return `http://localhost:3000/embed/${props.entity}/${_idOrUsername}${paramClass.toString() ? '?' + paramClass.toString() : ''}`; }); const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity)); @@ -174,6 +189,18 @@ function applyToPreview() { } }); } + +const result = ref(''); +function generate() { + const _idOrUsername = props.entity === 'user-timeline' ? '@' + props.idOrUsername : props.idOrUsername; + result.value = getEmbedCode(`/embed/${props.entity}/${_idOrUsername}`, paramsForUrl.value); + phase.value = 'result'; +} + +function doCopy() { + copy(result.value); + os.success(); +} //#endregion //#region プレビューのリサイズ @@ -235,25 +262,48 @@ onMounted(() => { resizeObserver.observe(resizerRootEl.value); }); -onDeactivated(() => { +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(() => { - window.removeEventListener('message', windowEventHandler); - resizeObserver.disconnect(); + 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%; } -.embedCodeGenWrapper { +.embedCodeGenInputRoot { height: 100%; display: grid; grid-template-columns: 1fr 400px; @@ -319,6 +369,47 @@ onUnmounted(() => { 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) { .embedCodeGenWrapper { grid-template-columns: 1fr;