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