<template>
<ComponentHeader
:current-json="state.currJson"
:md-string="state.mdString"
class="flex-horizontal flex-horizontal--header"
>
<template #header-right>
<slot name="header-right" />
</template>
</ComponentHeader>
<div class="docs-content-tips" v-if="templateModeState.mode === 'mobile-first'">
<div>
<tiny-alert
description="温馨提示:多端打开演练场,移动左右面板的分隔线,可查看大小屏多端效果"
center
></tiny-alert>
</div>
</div>
<div class="docs-content" id="doc-layout-scroller" ref="scrollRef" @scroll="onDocLayoutScroll">
<div class="ti-rel cmp-container">
<div class="flex-horizontal docs-content-main">
<div class="docs-tabs-wrap">
<tiny-tabs v-model="state.activeTab" ref="demoTabs" class="docs-content-tabs" @click="onTabsClick">
<tiny-tab-item :title="i18nByKey('demos')" name="demos">
<template v-if="state.currJson?.demos?.length">
<div class="all-demos-container" id="all-demos-container">
<div v-if="apiModeState.demoMode === 'default'" id="demo-list" class="ti-f-c ti-f-wrap demo-list">
<demo-box
v-for="demo in state.currJson.demos"
:key="demo.name"
:observer="state.observer"
:isIntersecting="demo.isIntersecting"
:demo="demo"
:curr-demo-id="state.currDemoId"
class="mb32"
@mounted="demoMounted"
@get-iframe-demo="getIframeDemo"
/>
</div>
<div v-else>
<demo-box
v-if="state.singleDemo"
:isIntersecting="true"
:key="state.singleDemo.name"
:demo="state.singleDemo"
@get-iframe-demo="getIframeDemo"
/>
</div>
</div>
</template>
<component-contributor :componentId="state.cmpId" />
</tiny-tab-item>
<tiny-tab-item
v-if="state.showApiTab && !isRunningTest && state.currJson.apis?.length"
title="API"
name="api"
>
<api-docs
ref="apiDocsRef"
:current-json="state.currJson"
:chart-code="state.chartCode"
:table-data="state.tableData"
id="API"
class="all-api-container"
@jump-to-demo="jumpToDemo"
></api-docs>
</tiny-tab-item>
<tiny-tab-item v-if="state.tokenList?.length" title="Token" name="token">
<design-token :name="state.cmpId" :tokenList="state.tokenList" />
</tiny-tab-item>
</tiny-tabs>
<slot name="main-right" />
</div>
<div class="cmp-page-anchor catalog">
<aside-anchor
v-if="state.activeTab === 'demos' || state.activeTab === 'api'"
:active-tab="state.activeTab"
:current-json="state.currJson"
:anchor-affix="state.anchorAffix"
:api-types="state.currApiTypes"
:lang-key="state.langKey"
:key="anchorRefreshKey"
@link-click="handleAnchorClick"
></aside-anchor>
</div>
</div>
<div v-if="state.currJson.owner" class="ti-abs ti-right24 ti-top24" @click="copyText(state.currJson.owner)">
{{ i18nByKey('doc-owner') }} : {{ state.currJson.owner }}
</div>
</div>
<div id="footer"></div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed, watch, onMounted, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'
import { TinyTabs, TinyTabItem, TinyAlert } from '@opentiny/vue'
import { debounce } from '@opentiny/utils'
import { i18nByKey, getWord, $clone, useApiMode, useTemplateMode } from '@/tools'
import { router } from '@/router.js'
import { getWebdocPath } from './cmp-config'
import DemoBox from '../../components/demo.vue'
import AsideAnchor from '../../components/anchor.vue'
import ComponentHeader from '../../components/header.vue'
import ComponentContributor from '../../components/contributor.vue'
import ApiDocs from '../../components/api-docs.vue'
import DesignToken from '../../components/design-token.vue'
import useTasksFinish from '../../composable/useTasksFinish'
import list from '@opentiny/vue-theme/token'
import { isSaas } from '../../const'
const props = defineProps({ loadData: {}, appMode: {}, demoKey: {} })
const emit = defineEmits(['single-demo-change', 'load-page'])
defineOptions({
name: 'CmpPageVue'
})
const { templateModeState } = useTemplateMode()
const scrollRef = ref()
const { apiModeState } = useApiMode()
const isRunningTest = localStorage.getItem('tiny-e2e-test') === 'true'
const anchorRefreshKey = ref(0)
const route = useRoute()
const state = reactive({
langKey: getWord('zh-CN', 'en-US', 'es-LA', 'pt-BR'),
cmpId: '',
observer: null,
tokenList: [],
currJson: { column: 1, demos: [], apis: [], types: {} },
mdString: '',
currDemoId: '',
iframeUrl: '',
anchorAffix: true,
singleDemo: null,
activeTab: route.hash === '#api' ? 'api' : 'demos',
tableData: {},
currApiTypes: [],
showApiTab: computed(() => state.currApiTypes.length),
chartCode: false
})
watch(
() => state.singleDemo,
(val) => {
emit('single-demo-change', val)
}
)
watch(
() => router.currentRoute.value.params.cmpId,
(cmpId) => {
if (!cmpId) {
state.currJson = {}
} else {
loadPage()
state.activeTab = 'demos'
anchorRefreshKey.value++
}
}
)
watch(
() => apiModeState.demoMode,
(value) => {
if (value) {
scrollToLayoutTop()
}
}
)
onMounted(() => {
loadPage()
nextTick(() => {
const common = new window.TDCommon(['#footer'], { allowDarkTheme: true })
common.renderFooter()
})
setScrollListener()
})
const getIframeDemo = (demo) => {
state.singleDemo = demo
state.currDemoId = demo.demoId
}
const parseApiData = () => {
if (!state.currJson.apis?.length) {
return {}
}
const tableData = {}
const apis = state.currJson.apis || []
for (const apiGroup of apis) {
const apiDisplay = {}
for (const apiType of Object.keys(apiGroup)) {
if (Array.isArray(apiGroup[apiType]) && apiGroup[apiType].length) {
const apiArr = apiGroup[apiType].map((i) => {
const { name, type, defaultValue, desc, demoId, typeAnchorName, linkTo, meta, versionTipOption, hideSaas } = i
const item = {
name,
type,
defaultValue: defaultValue || '--',
desc: desc[state.langKey],
demoId,
meta,
versionTipOption,
typeAnchorName: '',
linkTo,
hideSaas
}
if (typeAnchorName) {
item.typeAnchorName = `${typeAnchorName?.includes('#') ? '' : '#'}${typeAnchorName}`
item.code = state.currJson.types[i.typeAnchorName]?.code || ''
item.depTypes = state.currJson.types[i.typeAnchorName]?.depTypes || []
}
return item
})
apiDisplay[apiType] = apiArr.sort((a, b) => a.name.localeCompare(b.name))
state.currApiTypes = Array.from(new Set([...state.currApiTypes, apiType]))
}
}
tableData[apiGroup.name] = apiDisplay
}
if (isSaas) {
for (const group of Object.keys(tableData)) {
for (const apiType of Object.keys(tableData[group])) {
tableData[group][apiType] = tableData[group][apiType].filter((item) => !item.hideSaas)
}
}
}
state.tableData = tableData
}
const apiDocsRef = ref()
const jumpToApi = (hash) => {
state.activeTab = 'api'
apiDocsRef.value.jumpToApi(hash)
}
const scrollByHash = (hash) => {
setTimeout(() => {
if (!hash) {
scrollRef.value.scrollTo({
top: 0,
left: 0
})
} else if (state.currJson.types[hash]) {
jumpToApi(hash)
} else {
let scrollTarget
try {
scrollTarget = document.querySelector(`#${hash}`)
} catch (err) {}
if (scrollTarget && !isRunningTest) {
scrollRef.value.scrollTo({
top: scrollTarget.offsetTop,
left: 0,
behavior: 'smooth'
})
}
}
}, 0)
}
const scrollToLayoutTop = () => {
let hash = router.currentRoute.value.hash?.slice(1)
if (hash !== 'api') {
setTimeout(() => {
scrollRef.value.scrollTo({
top: 0,
left: 0,
behavior: 'smooth'
})
}, 0)
}
}
let finishMountTask
const demoMounted = () => {
finishMountTask()
}
const loadPage = () => {
const lang = getWord('cn', 'en', 'es', 'pt')
state.cmpId = router.currentRoute.value.params.cmpId
state.chartCode = getWebdocPath(state.cmpId) === 'chart'
props.loadData({ cmpId: state.cmpId, lang }).then(({ mdString, apisJson, demosJson }) => {
state.mdString = mdString
const hideTabHeader = ['interfaces', 'types', 'classes'].includes(state.cmpId)
if (demosJson && !hideTabHeader) {
demosJson.demos?.forEach((item) => {
item.isIntersecting = false
})
state.currJson = {
...demosJson,
demos: $clone(demosJson.demos || []),
column: demosJson.column || '1'
}
if (isSaas) {
state.currJson.demos = state.currJson.demos.filter((d) => !d.hideSaas)
} else {
state.currJson.demos = state.currJson.demos.filter((d) => !d.hidePc)
}
} else {
state.activeTab = 'api'
if (hideTabHeader) {
document.querySelector('.tiny-tabs__header').style.display = 'none'
}
}
state.tokenList = list[state.cmpId] || []
const { finishTask, waitTasks: allDemoMounted } = useTasksFinish(state.currJson.demos.length)
finishMountTask = finishTask
if (apisJson) {
state.currJson.apis = apisJson.apis.map((item) => {
Object.keys(item).forEach((key) => {
const apiItem = item[key]
if (Array.isArray(apiItem)) {
item[key] = apiItem
.filter((i) => !i.mode || i.mode.includes(props.appMode))
.map((filterItem) => ({ ...filterItem, demoId: filterItem[props.demoKey] }))
}
})
return item
})
state.currJson.types =
apisJson.types?.reduce((res, cur) => {
res[cur.name] = cur
return res
}, {}) || {}
parseApiData()
}
let hash = router.currentRoute.value.hash?.slice(1)
state.singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
if (!state.singleDemo) {
state.singleDemo = state.currJson.demos[0]
}
emit('load-page', state.singleDemo)
return allDemoMounted().then(() => {
scrollByHash(hash)
})
})
}
const onDocLayoutScroll = debounce(100, false, () => {
const docLayout = scrollRef.value
const { scrollTop, scrollHeight, clientHeight: layoutHeight } = docLayout
const headerHeight = document.querySelector('.docs-header')?.clientHeight || 0
const footerHeight = document.getElementById('footer')?.clientHeight || 0
const anchorHeight = document.querySelector('#anchor')?.clientHeight || 0
const remainHeight = scrollHeight - scrollTop - layoutHeight
state.anchorAffix = layoutHeight - headerHeight - (footerHeight - remainHeight) > anchorHeight
})
const setScrollListener = () => {
nextTick(() => {
const docLayout = scrollRef.value
const options = {
root: docLayout,
threshold: 0.2
}
const callback = (entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const demoId = entry.target.id
state.currJson.demos.forEach((item) => {
if (item.demoId === demoId) {
item.isIntersecting = true
}
})
}
})
}
state.observer = new IntersectionObserver(callback, options)
})
}
const copyText = (text) => {
navigator.clipboard.writeText(text)
}
const onTabsClick = (data) => {
if (data.name) {
router.push(`#${data.name}`)
scrollToLayoutTop()
}
}
const jumpToDemo = (demoId) => {
state.activeTab = 'demos'
if (demoId.startsWith('chart') || demoId.startsWith('grid')) {
router.push(demoId)
} else {
if (demoId) {
router.push(`#${demoId}`)
}
if (apiModeState.demoMode === 'single') {
state.singleDemo = state.currJson.demos.find((d) => d.demoId === demoId)
}
scrollByHash(demoId)
}
}
const handleAnchorClick = (e, data) => {
if (apiModeState.demoMode === 'single' && data.link.startsWith('#')) {
e.preventDefault()
const hash = data.link.slice(1)
const singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
if (singleDemo) {
state.singleDemo = singleDemo
scrollToLayoutTop()
}
router.push(data.link)
} else if (apiModeState.demoMode === 'default' && data.link.startsWith('#')) {
e.preventDefault()
const hash = data.link.slice(1)
state.currDemoId = hash
state.singleDemo = state.currJson.demos.find((d) => d.demoId === hash)
router.push(data.link)
scrollByHash(hash)
}
}
defineExpose({ loadPage })
</script>
<style lang="less" scoped>
.docs-content-tips {
width: 100%;
}
.docs-content {
flex: 1;
overflow: hidden auto;
margin-top: 16px;
transition: all ease-in-out 0.3s;
.docs-tabs-wrap {
width: 100%;
flex: 1;
display: flex;
justify-content: center;
padding: 0 40px;
}
.docs-content-tabs {
--tv-Tabs-heigh: 48px;
--tv-Tabs-item-font-size: 18px;
--tv-Tabs-header-font-active-text-color: #2f5bea;
--tv-Tabs-item-active-border-color: #2f5bea;
flex: 1;
transition: all ease-in-out 0.3s;
min-width: var(--layout-content-main-min-width);
max-width: var(--layout-content-main-max-width);
:deep(> .tiny-tabs__header) {
position: sticky;
top: 0;
z-index: var(--docs-tabs-header-zindex);
background-color: var(--docs-color-bg);
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
display: block;
width: 100%;
height: 16px;
background: linear-gradient(to bottom, var(--docs-color-bg), transparent);
transform: translateY(100%);
}
.tiny-tabs__item__title {
font-weight: bold;
}
}
& > :deep(.tiny-tabs__content) {
// 不能影响到tabs组件的样式
margin: 0;
overflow: visible;
}
}
.cmp-page-anchor {
:deep(.tiny-anchor__affix) {
top: unset !important;
overflow-y: auto;
overflow-x: hidden;
max-height: calc(100vh - 300px);
}
:deep(.tiny-anchor-link) {
font-size: 12px;
a {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.catalog {
flex: none;
width: 200px;
height: calc(100vh - 280px);
padding-top: 16px;
overflow: hidden;
.tiny-anchor__dot {
max-height: calc(100vh - 300px);
width: 200px;
:deep(.tiny-anchor) {
--ti-anchor-width: auto;
background-color: transparent;
}
}
}
.catalog:hover {
overflow-y: auto;
}
.catalog::-webkit-scrollbar {
width: 10px;
background-color: #f5f5f5;
}
.catalog::-webkit-scrollbar-thumb {
border-radius: 10px;
background-color: #c1c1c1;
}
}
.one-demo-col2 {
display: grid;
gap: 16px;
grid-template-columns: minmax(0px, 1fr) minmax(0px, 1fr);
align-items: flex-start;
> div {
display: grid;
gap: 16px;
grid-template-columns: 100%;
}
}
.all-demos-container,
.all-api-container {
flex: 1;
padding: 32px 0;
scroll-behavior: smooth;
}
.all-api-container {
padding-top: 12px;
}
.flex-horizontal {
display: flex;
justify-content: space-between;
align-items: flex-start;
column-gap: 16px;
}
.flex-horizontal--header {
overflow: auto;
}
.cmp-container {
p {
font-size: 16px;
line-height: 1.7em;
margin: 12px 0;
}
}
@media (max-width: 1279px) {
.catalog,
.docs-header-spacer {
display: none;
}
.cmp-container {
padding-right: 0;
}
}
@media (max-width: 767px) {
.one-demo-col2 {
grid-template-columns: 100%;
}
}
</style>