feat: 커스텀 페이지 customContentReady 함수 추가
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// @ts-ignore
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
import type { DirectiveBinding } from 'vue'
|
||||
@@ -35,9 +36,12 @@ const commonTags = [
|
||||
'thead',
|
||||
'tbody',
|
||||
'tfoot',
|
||||
'video',
|
||||
'source',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'section',
|
||||
] as const
|
||||
|
||||
// 공통 허용 속성
|
||||
@@ -55,16 +59,20 @@ const commonAttrs = [
|
||||
'colspan',
|
||||
'rowspan',
|
||||
'scope',
|
||||
'type',
|
||||
'autoplay',
|
||||
'muted',
|
||||
'loop',
|
||||
'playsinline',
|
||||
'controls',
|
||||
'preload',
|
||||
'poster',
|
||||
] as const
|
||||
|
||||
// 기본 설정 (일반 사용자용)
|
||||
const defaultConfig: DOMPurifyConfig = {
|
||||
ALLOWED_TAGS: [
|
||||
...commonTags
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
...commonAttrs
|
||||
],
|
||||
ALLOWED_TAGS: [...commonTags],
|
||||
ALLOWED_ATTR: [...commonAttrs],
|
||||
}
|
||||
|
||||
// 관리자용 설정 (확장된 태그 및 속성)
|
||||
@@ -74,6 +82,7 @@ const adminConfig: DOMPurifyConfig = {
|
||||
'iframe',
|
||||
'link',
|
||||
'style',
|
||||
'script',
|
||||
'blockquote',
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
@@ -133,16 +142,21 @@ function createDirectiveHandler(config: DOMPurifyConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
// SSR 환경에서는 디렉티브 등록하지 않음
|
||||
// if (!import.meta.client) {
|
||||
// return
|
||||
// }
|
||||
|
||||
// 기본 디렉티브: v-dompurify-html
|
||||
nuxtApp.vueApp.directive('dompurify-html', createDirectiveHandler(defaultConfig))
|
||||
nuxtApp.vueApp.directive(
|
||||
'dompurify-html',
|
||||
createDirectiveHandler(defaultConfig)
|
||||
)
|
||||
|
||||
// 관리자용 디렉티브: v-dompurify-admin
|
||||
nuxtApp.vueApp.directive('dompurify-admin', createDirectiveHandler(adminConfig))
|
||||
nuxtApp.vueApp.directive(
|
||||
'dompurify-admin',
|
||||
createDirectiveHandler(adminConfig)
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { PageDataTemplateComponents } from '#layers/types/api/pageData'
|
||||
|
||||
|
||||
// Window 타입 확장
|
||||
declare global {
|
||||
interface Window {
|
||||
fnCustomAction: (
|
||||
actionOrEl: string | HTMLElement | null,
|
||||
rawContent?: string,
|
||||
confirmText?: string,
|
||||
cancelText?: string,
|
||||
) => void
|
||||
fnCustomLog: (clickSarea: string, clickItem: string) => void
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
components: PageDataTemplateComponents
|
||||
pageVerTmplSeq: number
|
||||
pageVerTmplNameEn: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -15,21 +29,25 @@
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const staticUrl = runtimeConfig.public.staticUrl
|
||||
|
||||
// CSR에서만 __customAction 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction이 정의되기 전이므로 나중에 업데이트됨
|
||||
// CSR에서만 fnCustomAction, fnCustomLog 즉시 설정 (HTML 렌더링 전에 필요)
|
||||
// handleCustomAction, handleCustomLog가 정의되기 전이므로 나중에 업데이트됨
|
||||
if (import.meta.client) {
|
||||
;(window as any).__customAction = () => {
|
||||
;window.fnCustomAction = () => {
|
||||
// handleCustomAction이 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
;window.fnCustomLog = () => {
|
||||
// handleCustomLog가 아직 정의되지 않았을 수 있으므로 무시
|
||||
// onMounted에서 실제 함수로 교체됨
|
||||
}
|
||||
}
|
||||
|
||||
// handleSendLog 함수 정의
|
||||
const handleSendLog = (item: string) => {
|
||||
const handleSendLog = (clickSarea: string, clickItem: string) => {
|
||||
const analytics = {
|
||||
action_type: 'click',
|
||||
click_item: item,
|
||||
click_sarea: 'CUSTOM',
|
||||
click_sarea: clickSarea,
|
||||
click_item: clickItem,
|
||||
}
|
||||
sendLog(locale.value, analytics)
|
||||
}
|
||||
@@ -67,6 +85,8 @@
|
||||
// fetch한 HTML을 저장할 ref
|
||||
const fetchedHtml = ref<string>('')
|
||||
const isLoadingHtml = ref(false)
|
||||
// script/link 상대 경로 해석용 베이스 URL (staticUrl + cleanedResPath)
|
||||
const assetBaseUrl = ref<string>('')
|
||||
|
||||
// URL에서 HTML 가져오기
|
||||
const fetchHtmlFromUrl = async (url: string): Promise<string> => {
|
||||
@@ -180,11 +200,13 @@
|
||||
async (cleanedResPath) => {
|
||||
if (!cleanedResPath) {
|
||||
fetchedHtml.value = ''
|
||||
assetBaseUrl.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const contentStr = typeof cleanedResPath === 'string' ? `${staticUrl}${cleanedResPath}/index.html` : String(`${staticUrl}${cleanedResPath}/index.html`)
|
||||
console.log("🚀 ~ contentStr:", contentStr)
|
||||
const base = String(cleanedResPath).replace(/\/?$/, '')
|
||||
assetBaseUrl.value = `${staticUrl}${base}/`
|
||||
|
||||
let html = ''
|
||||
if (isUrl(contentStr)) {
|
||||
@@ -271,7 +293,15 @@
|
||||
}
|
||||
return (document.querySelector('main') as HTMLElement | null) ?? document.body
|
||||
}
|
||||
|
||||
|
||||
// http/https가 없으면 assetBaseUrl(staticUrl + cleanedResPath)을 붙여 절대 경로로 변환
|
||||
const resolveAssetUrl = (path: string): string => {
|
||||
if (/^https?:\/\//i.test(path)) return path
|
||||
const base = assetBaseUrl.value || ''
|
||||
if (!base) return path
|
||||
return base + path.replace(/^\//, '')
|
||||
}
|
||||
|
||||
// custom_contents 원본 문자열에서 link/script를 "작성된 순서" 그대로 추출 (파서가 위치를 바꾸는 문제 방지)
|
||||
const extractOrderedAssets = (html: string): OrderedAsset[] => {
|
||||
const assets: OrderedAsset[] = []
|
||||
@@ -336,7 +366,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
const href = asset.href
|
||||
const href = resolveAssetUrl(asset.href)
|
||||
// 이미 로드된 스타일시트 확인
|
||||
const existingLink = document.querySelector(`link[href="${href}"]`)
|
||||
if (existingLink) {
|
||||
@@ -365,7 +395,7 @@
|
||||
return
|
||||
}
|
||||
|
||||
const src = asset.src
|
||||
const src = resolveAssetUrl(asset.src)
|
||||
// 이미 로드된 스크립트 확인
|
||||
const existingScript = document.querySelector(`script[src="${src}"]`)
|
||||
if (existingScript) {
|
||||
@@ -375,8 +405,7 @@
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = src
|
||||
// 순서 보장을 위해 async=false
|
||||
script.async = false
|
||||
script.defer = true
|
||||
if (asset.type) script.type = asset.type
|
||||
if (asset.integrity) script.integrity = asset.integrity
|
||||
if (asset.crossOrigin) script.crossOrigin = asset.crossOrigin
|
||||
@@ -417,15 +446,26 @@
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
// 로그 전송 함수 (fnCustomLog로 노출)
|
||||
// - 호출 방식: fnCustomLog('로그값') 또는 fnCustomLog(element)
|
||||
const handleCustomLog = (clickSarea: string, clickItem: string | null) => {
|
||||
if (!import.meta.client) return
|
||||
if (clickItem == null) return
|
||||
|
||||
|
||||
if (!clickSarea || !clickItem) return
|
||||
|
||||
handleSendLog(clickSarea, clickItem)
|
||||
}
|
||||
|
||||
// API HTML에서 onclick으로 호출할 수 있도록 window에 노출할 "단일 함수"
|
||||
// - 인자 방식: onclick="window.__customAction('open-confirm', '내용', '확인', '취소', '로그값')"
|
||||
// - dataset 방식도 호환: onclick="window.__customAction(this)"
|
||||
// - 인자 방식: onclick="fnCustomAction('open-confirm', '내용', '확인', '취소')"
|
||||
// - dataset 방식도 호환: onclick="fnCustomAction(this)"
|
||||
const handleCustomAction = (
|
||||
actionOrEl: string | HTMLElement | null,
|
||||
rawContent?: string,
|
||||
confirmText?: string,
|
||||
cancelText?: string,
|
||||
logValue?: string,
|
||||
) => {
|
||||
if (!import.meta.client) return
|
||||
if (!actionOrEl) return
|
||||
@@ -452,11 +492,6 @@
|
||||
? cancelText || ''
|
||||
: actionOrEl.getAttribute('data-cancel-text') || ''
|
||||
|
||||
const analyticsLogValue =
|
||||
typeof actionOrEl === 'string'
|
||||
? logValue || ''
|
||||
: actionOrEl.getAttribute('data-log') || ''
|
||||
|
||||
// 1) open-confirm
|
||||
if (action === 'open-confirm') {
|
||||
const contentText = contentRaw ? applyI18nToApiHtml(contentRaw) : ''
|
||||
@@ -478,12 +513,6 @@
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2) analytics
|
||||
if (analyticsLogValue) {
|
||||
console.log("🚀 ~ handleCustomAction ~ analyticsLogValue:", analyticsLogValue)
|
||||
handleSendLog(analyticsLogValue)
|
||||
}
|
||||
}
|
||||
|
||||
// custom_contents 변경 시 link/script를 "작성된 순서"대로 순차 로드
|
||||
@@ -493,9 +522,10 @@
|
||||
if (!import.meta.client) return
|
||||
if (!html) return
|
||||
|
||||
// HTML이 업데이트될 때마다 __customAction 재설정 (CSR에서만)
|
||||
// HTML이 업데이트될 때마다 fnCustomAction, fnCustomLog 재설정 (CSR에서만)
|
||||
if (import.meta.client) {
|
||||
;(window as any).__customAction = handleCustomAction
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
}
|
||||
|
||||
scriptsLoaded.value = false
|
||||
@@ -508,27 +538,95 @@
|
||||
const assets = extractOrderedAssets(html)
|
||||
for (const asset of assets) {
|
||||
if (cancelled) return
|
||||
if (asset.kind === 'script') continue
|
||||
try {
|
||||
if (asset.kind === 'link') {
|
||||
await loadStylesheet(asset)
|
||||
} else if (asset.kind === 'style') {
|
||||
loadInlineStyle(asset)
|
||||
} else {
|
||||
await loadScript(asset)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`${asset.kind} load error:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 스크립트 로드 후에도 __customAction 재설정 (CSR에서만)
|
||||
if (!cancelled && import.meta.client) {
|
||||
scriptsLoaded.value = true
|
||||
;(window as any).__customAction = handleCustomAction
|
||||
} else if (!cancelled) {
|
||||
scriptsLoaded.value = true
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// DOM이 실제로 렌더링되었는지 확인하는 함수
|
||||
const waitForDomReady = (container: HTMLElement): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// v-dompurify-admin이 DOM을 업데이트할 때까지 대기
|
||||
const observer = new MutationObserver((_mutations, obs) => {
|
||||
// 컨테이너에 자식 요소가 있으면 DOM 준비 완료
|
||||
if (container.children.length > 0) {
|
||||
obs.disconnect()
|
||||
// 추가로 한 프레임 대기하여 렌더링 완료 보장
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 이미 자식이 있으면 바로 resolve
|
||||
if (container.children.length > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
resolve()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
|
||||
// 타임아웃 fallback (3초)
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve()
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
// customContentsHtml 주입이 끝난 뒤에만 script 동적 로드
|
||||
watch(
|
||||
() => [customContentsHtml.value, customContainerRef.value],
|
||||
() => {
|
||||
if (!import.meta.client || !customContainerRef.value || !customContentsHtml.value) return
|
||||
nextTick(async () => {
|
||||
const html = customContentsRaw.value
|
||||
if (!html) return
|
||||
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
scriptsLoaded.value = false
|
||||
|
||||
// DOM이 실제로 렌더링될 때까지 대기
|
||||
await waitForDomReady(container)
|
||||
|
||||
const assets = extractOrderedAssets(html)
|
||||
const scriptAssets = assets.filter((a): a is Extract<OrderedAsset, { kind: 'script' }> => a.kind === 'script')
|
||||
try {
|
||||
for (const asset of scriptAssets) {
|
||||
await loadScript(asset)
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[CustomContent] Script load error:', err)
|
||||
}
|
||||
scriptsLoaded.value = true
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
// 동적 로드 시점에는 DOMContentLoaded/load가 이미 발생했으므로, 스크립트에서 구독할 커스텀 이벤트 발생
|
||||
if (import.meta.client) {
|
||||
window.dispatchEvent(new CustomEvent('customContentReady'))
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -540,8 +638,9 @@
|
||||
const container = customContainerRef.value
|
||||
if (!container) return
|
||||
|
||||
// onclick="window.__customAction(this)" 지원 (CSR에서만)
|
||||
;(window as any).__customAction = handleCustomAction
|
||||
// onclick="fnCustomAction(this)", fnCustomLog(this) 지원 (CSR에서만)
|
||||
;window.fnCustomAction = handleCustomAction
|
||||
;window.fnCustomLog = handleCustomLog
|
||||
|
||||
// onclick을 못 쓰는 케이스 대비: 이벤트 위임도 같이 지원
|
||||
const onDelegatedClick = (e: MouseEvent) => {
|
||||
@@ -561,8 +660,11 @@
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) return
|
||||
const w = window as any
|
||||
if (w.__customAction === handleCustomAction) {
|
||||
delete w.__customAction
|
||||
if (w.fnCustomAction === handleCustomAction) {
|
||||
delete w.fnCustomAction
|
||||
}
|
||||
if (w.fnCustomLog === handleCustomLog) {
|
||||
delete w.fnCustomLog
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -575,11 +677,6 @@
|
||||
v-dompurify-admin="customContentsHtml"
|
||||
class="custom-container"
|
||||
></div>
|
||||
<div
|
||||
class="custom-container"
|
||||
>
|
||||
{{ tm('Newreturnevent_Section03_Title') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user