diff --git a/docs/.vitepress/theme/components/TutorialPlayer.vue b/docs/.vitepress/theme/components/TutorialPlayer.vue index 2d50cb5..ad61078 100644 --- a/docs/.vitepress/theme/components/TutorialPlayer.vue +++ b/docs/.vitepress/theme/components/TutorialPlayer.vue @@ -19,11 +19,31 @@ const { site, frontmatter } = useData() function formatInlineMarkdown(text) { if (!text) return '' - const safe = text + let safe = text .replace(/&/g, '&') .replace(//g, '>') - return safe.replace(/`([^`]+)`/g, '$1') + + safe = safe.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { + const cleanAlt = alt.replace(/"/g, '"') + let cleanSrc = src.trim().replace(/"/g, '"') + + // Only rewrite relative public/assets paths, preserving http://, https://, and data: URLs + if (!/^(?:https?:\/\/|data:)/i.test(cleanSrc)) { + if (cleanSrc.includes('assets/')) { + cleanSrc = '/assets/' + cleanSrc.split('assets/')[1] + } + + if (cleanSrc.startsWith('/')) { + cleanSrc = withBase(cleanSrc) + } + } + + return `${cleanAlt}` + }) + + safe = safe.replace(/`([^`]+)`/g, '$1') + return safe.replace(/\n/g, '
') } const currentChapterObj = computed(() => { @@ -96,6 +116,7 @@ const entry = ref('') const isSheetOpen = ref(false) const isExpanded = ref(false) const isModalOpen = ref(false) +const activeModalImage = ref(null) function openModal() { isModalOpen.value = true @@ -105,6 +126,21 @@ function closeModal() { isModalOpen.value = false } +function openImageModal(src, alt = '') { + activeModalImage.value = { src, alt } +} + +function closeImageModal() { + activeModalImage.value = null +} + +function handleLogClick(e) { + const target = e.target + if (target && target.tagName === 'IMG' && target.classList.contains('tut-img-embed')) { + openImageModal(target.src, target.alt) + } +} + function toggleExpand() { if (typeof document === 'undefined') return @@ -136,9 +172,25 @@ function handleFullscreenChange() { } function handleKeydown(e) { - if (e.key === 'Escape' && isExpanded.value) { - if (!document.fullscreenElement) { - toggleExpand() + if (e.key === 'Escape') { + if (activeModalImage.value) { + closeImageModal() + return + } + if (isExpanded.value) { + if (!document.fullscreenElement) { + toggleExpand() + } + return + } + } + + // Intercept Space or PageDown for paging if output overflow is active and input is empty or unfocused + if (isScrollOverflowActive.value && !isScrolling.value) { + const isInputFocused = document.activeElement === inputEl.value + if ((e.key === 'PageDown' || e.key === ' ') && (!isInputFocused || !entry.value.trim())) { + e.preventDefault() + pageForward() } } } @@ -146,8 +198,76 @@ function handleKeydown(e) { const logEl = ref(null) const inputEl = ref(null) +// Pager state management +const isScrollOverflowActive = ref(false) +const currentPageNum = ref(1) +const totalPagesNum = ref(1) +const isScrolling = ref(false) +let scrollEndTimer = null + +function updatePagerState() { + const container = logEl.value + if (!container) { + isScrollOverflowActive.value = false + currentPageNum.value = 1 + totalPagesNum.value = 1 + return + } + + const scrollHeight = container.scrollHeight + const clientHeight = container.clientHeight + const scrollTop = container.scrollTop + + // Determine if there is significant scrollable content below viewport + const scrollableDistance = scrollHeight - clientHeight + const remainingScroll = scrollableDistance - scrollTop + + if (scrollableDistance > 40 && remainingScroll > 35) { + isScrollOverflowActive.value = true + const pageSize = Math.max(1, clientHeight - 30) + totalPagesNum.value = Math.max(1, Math.ceil(scrollHeight / pageSize)) + currentPageNum.value = Math.min(totalPagesNum.value, Math.max(1, Math.floor(scrollTop / pageSize) + 1)) + } else { + isScrollOverflowActive.value = false + currentPageNum.value = 1 + totalPagesNum.value = 1 + } +} + +function handleScroll() { + isScrolling.value = true + if (scrollEndTimer) clearTimeout(scrollEndTimer) + scrollEndTimer = setTimeout(() => { + isScrolling.value = false + updatePagerState() + }, 150) +} + +function pageForward() { + const container = logEl.value + if (!container) return + const pageStep = container.clientHeight - 40 + container.scrollBy({ top: pageStep, behavior: 'smooth' }) + setTimeout(updatePagerState, 300) +} + +function flushPager() { + const container = logEl.value + if (!container) return + container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }) + setTimeout(updatePagerState, 300) +} + const stepLabel = computed(() => `Chapter ${chapterNum.value} of ${totalChapters.value}`) +let autoAdvanceTimer = null +function clearAutoAdvanceTimer() { + if (autoAdvanceTimer) { + clearTimeout(autoAdvanceTimer) + autoAdvanceTimer = null + } +} + function focusInput() { if (typeof window !== 'undefined') { setTimeout(() => { @@ -158,6 +278,7 @@ function focusInput() { function navigateToUrl(url) { if (!url) return + clearAutoAdvanceTimer() const targetUrl = withBase(url) if (router && router.go) { router.go(targetUrl).catch(() => { @@ -192,30 +313,76 @@ function scrollLog() { } } -function renderStepLog() { - subStepIdx.value = 0 +function goToStep(targetIdx) { + clearAutoAdvanceTimer() + if (targetIdx < 0) targetIdx = 0 + if (targetIdx >= stepsList.value.length) targetIdx = Math.max(0, stepsList.value.length - 1) + + subStepIdx.value = targetIdx finished.value = false + entry.value = '' const newLog = [] + const firstSub = stepsList.value[0] || null + newLog.push({ kind: 'lesson', chapterNum: chapterNum.value, title: chapterTitle.value, teach: teachList.value, - note: currentSubStep.value ? currentSubStep.value.note : null, - ask: currentSubStep.value ? currentSubStep.value.ask : null + note: firstSub ? firstSub.note : null, + ask: firstSub ? firstSub.ask : null }) + for (let i = 0; i < targetIdx; i++) { + const step = stepsList.value[i] + if (step) { + if (step.ask) { + newLog.push({ kind: 'echo', text: step.ask }) + let rawRes = step.response || step.example || mumeResponses.value[step.ask.toLowerCase()] || '' + if (!rawRes && step.accept && step.accept.length) { + for (const alias of step.accept) { + if (mumeResponses.value[alias.toLowerCase()]) { + rawRes = mumeResponses.value[alias.toLowerCase()] + break + } + } + } + const body = rawRes ? rawRes.replace(/^>[^\n]*\n?/, '') : '' + if (body) { + newLog.push({ kind: 'example', body }) + } + } else if (step.text || step.response) { + newLog.push({ + kind: 'story', + body: step.text || step.response + }) + } + + const nextSub = stepsList.value[i + 1] + if (nextSub && nextSub.ask) { + newLog.push({ + kind: 'prompt_next', + stepIndex: i + 1, + totalSteps: stepsList.value.length, + note: nextSub.note || null, + ask: nextSub.ask + }) + } + } + } + log.value = newLog scrollLog() - if (currentSubStep.value && !currentSubStep.value.ask && (currentSubStep.value.text || currentSubStep.value.response)) { + const curSub = stepsList.value[targetIdx] || null + if (curSub && !curSub.ask && (curSub.text || curSub.response)) { log.value.push({ kind: 'story', - body: currentSubStep.value.text || currentSubStep.value.response + body: curSub.text || curSub.response }) scrollLog() - setTimeout(() => { + autoAdvanceTimer = setTimeout(() => { advanceSubStep() }, 250) } else { @@ -223,6 +390,10 @@ function renderStepLog() { } } +function renderStepLog() { + goToStep(0) +} + function completeChapter() { finished.value = true log.value.push({ @@ -243,11 +414,12 @@ function advanceNext() { } function advanceSubStep() { + clearAutoAdvanceTimer() if (subStepIdx.value < stepsList.value.length - 1) { subStepIdx.value++ - const nextSub = currentSubStep.value + const nextSub = stepsList.value[subStepIdx.value] || null - if (nextSub.ask) { + if (nextSub && nextSub.ask) { log.value.push({ kind: 'prompt_next', stepIndex: subStepIdx.value, @@ -257,13 +429,13 @@ function advanceSubStep() { }) scrollLog() focusInput() - } else if (nextSub.text || nextSub.response) { + } else if (nextSub && (nextSub.text || nextSub.response)) { log.value.push({ kind: 'story', body: nextSub.text || nextSub.response }) scrollLog() - setTimeout(() => { + autoAdvanceTimer = setTimeout(() => { advanceSubStep() }, 250) } @@ -274,22 +446,23 @@ function advanceSubStep() { function prevSubStep() { if (subStepIdx.value > 0) { - subStepIdx.value-- - const prevSub = currentSubStep.value - log.value.push({ - kind: 'prompt_prev', - stepIndex: subStepIdx.value, - totalSteps: stepsList.value.length, - note: prevSub.note || null, - ask: prevSub.ask - }) - scrollLog() + goToStep(subStepIdx.value - 1) + } else if (prevChapterUrl.value) { + navigateToUrl(prevChapterUrl.value) } } function submit() { const raw = entry.value.trim() const cmd = raw.toLowerCase() + + // Handle Pager Mode when input is empty and overflow is active + if (isScrollOverflowActive.value && !raw && !finished.value) { + pageForward() + focusInput() + return + } + entry.value = '' if (finished.value) { @@ -302,6 +475,10 @@ function submit() { return } + if (isScrollOverflowActive.value) { + flushPager() + } + if (raw) { log.value.push({ kind: 'echo', text: raw }) } if (cmd === 'skip') { advanceNext(); focusInput(); return } @@ -352,9 +529,11 @@ watch(() => route.path, () => { renderStepLog() }, { immediate: true }) +let logResizeObserver = null + onMounted(() => { - renderStepLog() if (typeof window !== 'undefined') { + renderStepLog() // Auto-open Command Sheet on desktop viewports by default if (window.innerWidth >= 1024) { isSheetOpen.value = true @@ -362,6 +541,13 @@ onMounted(() => { focusInput() window.addEventListener('keydown', handleKeydown) document.addEventListener('fullscreenchange', handleFullscreenChange) + + if (logEl.value && typeof ResizeObserver !== 'undefined') { + logResizeObserver = new ResizeObserver(() => { + updatePagerState() + }) + logResizeObserver.observe(logEl.value) + } } }) @@ -369,6 +555,10 @@ onUnmounted(() => { if (typeof window !== 'undefined') { window.removeEventListener('keydown', handleKeydown) document.removeEventListener('fullscreenchange', handleFullscreenChange) + if (logResizeObserver) { + logResizeObserver.disconnect() + logResizeObserver = null + } if (isExpanded.value && typeof document !== 'undefined') { document.body.style.overflow = '' } @@ -428,7 +618,7 @@ onUnmounted(() => {
-
+