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 commit05ca36f400. * 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 commit937ae44521. * 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 commit12bab1c6fb. * Revert "Update preview.ts" This reverts commit5c0ce01dbd. * Revert "Update preview.ts" This reverts commitf4863524d7. * Revert "fix storybook instance imports" This reverts commited8eabb246. * Revert "wip" This reverts commitd3c1926519. * Revert "Update page.vue" This reverts commit27c7900b0c. * Revert "Update branding.vue" This reverts commitc08ccb65ba. * Revert "Update roles.vue" This reverts commit1488b67066. * Revert "wip" This reverts commitaab1c76981. * 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:
		| @@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts"> | ||||
| import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; | ||||
| import sanitizeHtml from 'sanitize-html'; | ||||
| import { emojilist, getEmojiName } from '@@/js/emojilist.js'; | ||||
| import contains from '@/scripts/contains.js'; | ||||
| import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js'; | ||||
| import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js'; | ||||
| import { acct } from '@/filters/user.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { emojilist, getEmojiName } from '@/scripts/emojilist.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { miLocalStorage } from '@/local-storage.js'; | ||||
| import { customEmojis } from '@/custom-emojis.js'; | ||||
| import { MFM_TAGS, MFM_PARAMS } from '@/const.js'; | ||||
| import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js'; | ||||
| import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js'; | ||||
|  | ||||
| const lib = emojilist.filter(x => x.category !== 'flags'); | ||||
|   | ||||
| @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { computed, onMounted, onUnmounted, ref } from 'vue'; | ||||
| import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { useInterval } from '@/scripts/use-interval.js'; | ||||
| import { useInterval } from '@@/js/use-interval.js'; | ||||
| import * as game from '@/scripts/clicker-game.js'; | ||||
| import number from '@/filters/number.js'; | ||||
| import { claimAchievement } from '@/scripts/achievements.js'; | ||||
|   | ||||
| @@ -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')); | ||||
|  | ||||
|   | ||||
							
								
								
									
										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> | ||||
| @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { ref, computed, Ref } from 'vue'; | ||||
| import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; | ||||
| import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { customEmojis } from '@/custom-emojis.js'; | ||||
| import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; | ||||
|   | ||||
| @@ -117,7 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { ref, shallowRef, computed, watch, onMounted } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import XSection from '@/components/MkEmojiPicker.section.vue'; | ||||
| import { | ||||
| 	emojilist, | ||||
| 	emojiCharByCategory, | ||||
| @@ -126,7 +125,8 @@ import { | ||||
| 	getEmojiName, | ||||
| 	CustomEmojiFolderTree, | ||||
| 	getUnicodeEmoji, | ||||
| } from '@/scripts/emojilist.js'; | ||||
| } from '@@/js/emojilist.js'; | ||||
| import XSection from '@/components/MkEmojiPicker.section.vue'; | ||||
| import MkRippleEffect from '@/components/MkRippleEffect.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { isTouchUsing } from '@/scripts/touch.js'; | ||||
|   | ||||
| @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import DrawBlurhash from '@/workers/draw-blurhash?worker'; | ||||
| import TestWebGL2 from '@/workers/test-webgl2?worker'; | ||||
| import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js'; | ||||
| import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js'; | ||||
| import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; | ||||
|  | ||||
| const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { | ||||
| 	// テスト環境で Web Worker インスタンスは作成できない | ||||
|   | ||||
| @@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; | ||||
| import { debounce } from 'throttle-debounce'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { useInterval } from '@/scripts/use-interval.js'; | ||||
| import { useInterval } from '@@/js/use-interval.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; | ||||
|  | ||||
|   | ||||
| @@ -37,7 +37,7 @@ import XBanner from '@/components/MkMediaBanner.vue'; | ||||
| import XImage from '@/components/MkMediaImage.vue'; | ||||
| import XVideo from '@/components/MkMediaVideo.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; | ||||
| import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { focusParent } from '@/scripts/focus.js'; | ||||
|  | ||||
|   | ||||
| @@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { watch, ref } from 'vue'; | ||||
| import { v4 as uuid } from 'uuid'; | ||||
| import tinycolor from 'tinycolor2'; | ||||
| import { useInterval } from '@/scripts/use-interval.js'; | ||||
| import { useInterval } from '@@/js/use-interval.js'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	src: number[]; | ||||
|   | ||||
| @@ -627,7 +627,7 @@ function emitUpdReaction(emoji: string, delta: number) { | ||||
| 	// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる | ||||
| 	// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) | ||||
| 	//content-visibility: auto; | ||||
|   //contain-intrinsic-size: 0 128px; | ||||
| 	//contain-intrinsic-size: 0 128px; | ||||
|  | ||||
| 	&:focus-visible { | ||||
| 		outline: none; | ||||
|   | ||||
| @@ -35,7 +35,7 @@ import MkSwitch from './MkSwitch.vue'; | ||||
| import MkInfo from './MkInfo.vue'; | ||||
| import MkButton from './MkButton.vue'; | ||||
| import MkModalWindow from '@/components/MkModalWindow.vue'; | ||||
| import { notificationTypes } from '@/const.js'; | ||||
| import { notificationTypes } from '@@/js/const.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
| type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>> | ||||
|   | ||||
| @@ -31,7 +31,7 @@ import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; | ||||
| import MkNote from '@/components/MkNote.vue'; | ||||
| import { useStream } from '@/stream.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { notificationTypes } from '@/const.js'; | ||||
| import { notificationTypes } from '@@/js/const.js'; | ||||
| import { infoImageUrl } from '@/instance.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; | ||||
|   | ||||
| @@ -40,7 +40,7 @@ import { i18n } from '@/i18n.js'; | ||||
| import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; | ||||
| import { openingWindowsCount } from '@/os.js'; | ||||
| import { claimAchievement } from '@/scripts/achievements.js'; | ||||
| import { getScrollContainer } from '@/scripts/scroll.js'; | ||||
| import { getScrollContainer } from '@@/js/scroll.js'; | ||||
| import { useRouterFactory } from '@/router/supplier.js'; | ||||
| import { mainRouter } from '@/router/main.js'; | ||||
|  | ||||
|   | ||||
| @@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts"> | ||||
| import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { useDocumentVisibility } from '@@/js/use-document-visibility.js'; | ||||
| import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js'; | ||||
| import { useDocumentVisibility } from '@/scripts/use-document-visibility.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { MisskeyEntity } from '@/types/date-separated-list.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|   | ||||
| @@ -29,14 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { computed, ref } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import { sum } from '@/scripts/array.js'; | ||||
| import { pleaseLogin } from '@/scripts/please-login.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { host } from '@/config.js'; | ||||
| import { useInterval } from '@/scripts/use-interval.js'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import { useInterval } from '@@/js/use-interval.js'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	noteId: string; | ||||
| @@ -83,10 +83,10 @@ if (props.poll.expiresAt) { | ||||
| } | ||||
|  | ||||
| const vote = async (id) => { | ||||
| 	pleaseLogin(undefined, pleaseLoginContext.value); | ||||
|  | ||||
| 	if (props.readOnly || closed.value || isVoted.value) return; | ||||
|  | ||||
| 	pleaseLogin(undefined, pleaseLoginContext.value); | ||||
|  | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'question', | ||||
| 		text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }), | ||||
| @@ -145,7 +145,7 @@ const vote = async (id) => { | ||||
|  | ||||
| .done { | ||||
| 	.choice { | ||||
| 		cursor: default; | ||||
| 		cursor: initial; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { getScrollContainer } from '@/scripts/scroll.js'; | ||||
| import { getScrollContainer } from '@@/js/scroll.js'; | ||||
| import { isHorizontalSwipeSwiping } from '@/scripts/touch.js'; | ||||
|  | ||||
| const SCROLL_STOP = 10; | ||||
|   | ||||
| @@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { } from 'vue'; | ||||
| import { getEmojiName } from '@@/js/emojilist.js'; | ||||
| import MkTooltip from './MkTooltip.vue'; | ||||
| import MkReactionIcon from '@/components/MkReactionIcon.vue'; | ||||
| import { getEmojiName } from '@/scripts/emojilist.js'; | ||||
|  | ||||
| defineProps<{ | ||||
| 	showing: boolean; | ||||
|   | ||||
| @@ -20,6 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { computed, inject, onMounted, shallowRef, watch } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { getUnicodeEmoji } from '@@/js/emojilist.js'; | ||||
| import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue'; | ||||
| import XDetails from '@/components/MkReactionsViewer.details.vue'; | ||||
| import MkReactionIcon from '@/components/MkReactionIcon.vue'; | ||||
| @@ -34,7 +35,6 @@ import { i18n } from '@/i18n.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
| import { checkReactionPermissions } from '@/scripts/check-reaction-permissions.js'; | ||||
| import { customEmojisMap } from '@/custom-emojis.js'; | ||||
| import { getUnicodeEmoji } from '@/scripts/emojilist.js'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	reaction: string; | ||||
|   | ||||
| @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { onMounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots, VNodeChild } from 'vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import * as os from '@/os.js'; | ||||
| import { useInterval } from '@/scripts/use-interval.js'; | ||||
| import { useInterval } from '@@/js/use-interval.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
| import { MenuItem } from '@/types/menu.js'; | ||||
|  | ||||
|   | ||||
| @@ -66,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue'; | ||||
| import { toUnicode } from 'punycode/'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; | ||||
| import { query, extractDomain } from '@@/js/url.js'; | ||||
| import type { OpenOnRemoteOptions } from '@/scripts/please-login.js'; | ||||
| import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| @@ -74,7 +75,6 @@ import MkInfo from '@/components/MkInfo.vue'; | ||||
| import { host as configHost } from '@/config.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { misskeyApi } from '@/scripts/misskey-api.js'; | ||||
| import { query, extractDomain } from '@/scripts/url.js'; | ||||
| import { login } from '@/account.js'; | ||||
| import { i18n } from '@/i18n.js'; | ||||
|  | ||||
|   | ||||
| @@ -42,10 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { watch, ref, computed } from 'vue'; | ||||
| import * as Misskey from 'misskey-js'; | ||||
| import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js'; | ||||
| import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; | ||||
| import MkA from './MkA.vue'; | ||||
| import { getStaticImageUrl } from '@/scripts/media-proxy.js'; | ||||
| import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js'; | ||||
| import { acct, userPage } from '@/filters/user.js'; | ||||
| import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
|   | ||||
| @@ -10,9 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed, inject } from 'vue'; | ||||
| import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@/scripts/emoji-base.js'; | ||||
| import { colorizeEmoji, getEmojiName } from '@@/js/emojilist.js'; | ||||
| import { char2fluentEmojiFilePath, char2twemojiFilePath } from '@@/js/emoji-base.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { colorizeEmoji, getEmojiName } from '@/scripts/emojilist.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; | ||||
| import * as sound from '@/scripts/sound.js'; | ||||
|   | ||||
| @@ -19,8 +19,13 @@ import MkSparkle from '@/components/MkSparkle.vue'; | ||||
| import MkA, { MkABehavior } from '@/components/global/MkA.vue'; | ||||
| import { host } from '@/config.js'; | ||||
| import { defaultStore } from '@/store.js'; | ||||
| import { nyaize as doNyaize } from '@/scripts/nyaize.js'; | ||||
| import { safeParseFloat } from '@/scripts/safe-parse.js'; | ||||
|  | ||||
| function safeParseFloat(str: unknown): number | null { | ||||
| 	if (typeof str !== 'string' || str === '') return null; | ||||
| 	const num = parseFloat(str); | ||||
| 	if (isNaN(num)) return null; | ||||
| 	return num; | ||||
| } | ||||
|  | ||||
| const QUOTE_STYLE = ` | ||||
| display: block; | ||||
| @@ -86,7 +91,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven | ||||
| 			case 'text': { | ||||
| 				let text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); | ||||
| 				if (!disableNyaize && shouldNyaize) { | ||||
| 					text = doNyaize(text); | ||||
| 					text = Misskey.nyaize(text); | ||||
| 				} | ||||
|  | ||||
| 				if (!props.plain) { | ||||
| @@ -281,14 +286,14 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven | ||||
| 							const child = token.children[0]; | ||||
| 							let text = child.type === 'text' ? child.props.text : ''; | ||||
| 							if (!disableNyaize && shouldNyaize) { | ||||
| 								text = doNyaize(text); | ||||
| 								text = Misskey.nyaize(text); | ||||
| 							} | ||||
| 							return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); | ||||
| 						} else { | ||||
| 							const rt = token.children.at(-1)!; | ||||
| 							let text = rt.type === 'text' ? rt.props.text : ''; | ||||
| 							if (!disableNyaize && shouldNyaize) { | ||||
| 								text = doNyaize(text); | ||||
| 								text = Misskey.nyaize(text); | ||||
| 							} | ||||
| 							return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); | ||||
| 						} | ||||
| @@ -400,7 +405,6 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext<MfmEven | ||||
| 			} | ||||
|  | ||||
| 			case 'emojiCode': { | ||||
| 				// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition | ||||
| 				if (props.author?.host == null) { | ||||
| 					return [h(MkCustomEmoji, { | ||||
| 						key: Math.random(), | ||||
|   | ||||
| @@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; | ||||
| import tinycolor from 'tinycolor2'; | ||||
| import XTabs, { Tab } from './MkPageHeader.tabs.vue'; | ||||
| import { scrollToTop } from '@/scripts/scroll.js'; | ||||
| import { scrollToTop } from '@@/js/scroll.js'; | ||||
| import { globalEvents } from '@/events.js'; | ||||
| import { injectReactiveMetadata } from '@/scripts/page-metadata.js'; | ||||
| import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; | ||||
|   | ||||
| @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue'; | ||||
|  | ||||
| import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const.js'; | ||||
| import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@@/js/const.js'; | ||||
|  | ||||
| const rootEl = shallowRef<HTMLElement>(); | ||||
| const headerEl = shallowRef<HTMLElement>(); | ||||
|   | ||||
| @@ -30,10 +30,17 @@ import { toUnicode as decodePunycode } from 'punycode/'; | ||||
| import { url as local } from '@/config.js'; | ||||
| import * as os from '@/os.js'; | ||||
| import { useTooltip } from '@/scripts/use-tooltip.js'; | ||||
| import { safeURIDecode } from '@/scripts/safe-uri-decode.js'; | ||||
| import { isEnabledUrlPreview } from '@/instance.js'; | ||||
| import { MkABehavior } from '@/components/global/MkA.vue'; | ||||
|  | ||||
| function safeURIDecode(str: string): string { | ||||
| 	try { | ||||
| 		return decodeURIComponent(str); | ||||
| 	} catch { | ||||
| 		return str; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	url: string; | ||||
| 	rel?: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 かっこかり
					かっこかり