feat(renderer): full article reader with TOC, font controls & print (M10.1)
- ArticleReader.vue: sticky TOC sidebar (desktop), mobile dropdown - Auto-generated heading anchors with IntersectionObserver tracking - Reading time estimate, font size controls (persisted localStorage) - Print mode opens clean window with serif typography - Long-form detection (>800 words + headings) shows "Read as article" - Removed isDark conditionals from ArticleDetail (dark-only app) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
aeb184b930
commit
edb4bbadcd
@@ -277,6 +277,13 @@
|
|||||||
>
|
>
|
||||||
View brief →
|
View brief →
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isLongForm"
|
||||||
|
class="text-[10px] text-accent/70 hover:text-accent transition-colors"
|
||||||
|
@click.stop="openLongFormArticle(message.content)"
|
||||||
|
>
|
||||||
|
Read as article →
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,7 +371,7 @@ function submitEdit() {
|
|||||||
isEditing.value = false
|
isEditing.value = false
|
||||||
editContent.value = ''
|
editContent.value = ''
|
||||||
}
|
}
|
||||||
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
const { getContextualInlineContent, stripContentTags, stripMarkdownLinks, updatePanelFromText, panelOpen, availableTabs, setActiveTab, openFilmDetail, openBookDetail, openTVSeriesDetail, openImageDetail, openPlaceDetail, openSongDetail, openPodcastDetail, openArticleDetail, openWebsiteDetail, openLongFormArticle, closeFilmDetail, closeBookDetail, closeTVSeriesDetail, closeImageDetail, closePlaceDetail, closeSongDetail, closePodcastDetail } = useContentPanel()
|
||||||
const codeContext = useCodeContext()
|
const codeContext = useCodeContext()
|
||||||
|
|
||||||
const isUser = computed(() => props.message.role === 'user')
|
const isUser = computed(() => props.message.role === 'user')
|
||||||
@@ -459,6 +466,15 @@ const formattedTime = computed(() => {
|
|||||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Long-form article detection (>800 words + has headings)
|
||||||
|
const isLongForm = computed(() => {
|
||||||
|
if (isUser.value) return false
|
||||||
|
const text = props.message.content
|
||||||
|
const words = text.split(/\s+/).length
|
||||||
|
const hasHeadings = /^#{2,3}\s+.+$/m.test(text)
|
||||||
|
return words > 800 && hasHeadings
|
||||||
|
})
|
||||||
|
|
||||||
// Token estimate (~4 chars per token)
|
// Token estimate (~4 chars per token)
|
||||||
const estimatedTokens = computed(() => Math.ceil(props.message.content.length / 4))
|
const estimatedTokens = computed(() => Math.ceil(props.message.content.length / 4))
|
||||||
const showTokenCount = computed(() => props.message.content.length > 20)
|
const showTokenCount = computed(() => props.message.content.length > 20)
|
||||||
|
|||||||
@@ -32,14 +32,13 @@
|
|||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<article
|
<article
|
||||||
v-if="article.content"
|
v-if="article.content"
|
||||||
class="[&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
class="text-white/90 [&_p]:mb-3 [&_ul]:list-disc [&_ol]:list-decimal [&_li]:ml-4 [&_a]:underline [&_a]:underline-offset-2 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:italic"
|
||||||
:class="isDark ? 'text-white/90' : 'text-gray-900'"
|
|
||||||
>
|
>
|
||||||
<div v-html="sanitizedContent" />
|
<div v-html="sanitizedContent" />
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<div v-else class="py-4">
|
<div v-else class="py-4">
|
||||||
<p class="text-sm" :class="isDark ? 'text-white/50' : 'text-gray-500'">
|
<p class="text-sm text-white/50">
|
||||||
Full article content is not available. Open the link below to read on the source site.
|
Full article content is not available. Open the link below to read on the source site.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,10 +48,7 @@
|
|||||||
:href="article.url"
|
:href="article.url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors"
|
class="inline-flex items-center gap-2 p-3 rounded-xl transition-colors bg-white/10 hover:bg-white/15 text-white/90"
|
||||||
:class="isDark
|
|
||||||
? 'bg-white/10 hover:bg-white/15 text-white/90'
|
|
||||||
: 'bg-black/5 hover:bg-black/10 text-gray-800'"
|
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
@@ -66,14 +62,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { WebSearchResult } from '@aiui/core/types/message'
|
import type { WebSearchResult } from '@aiui/core/types/message'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
|
||||||
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
|
import { isSafeImgSrc, sanitizeHtml, escapeHtml } from '@/utils/html'
|
||||||
|
|
||||||
const props = defineProps<{ article: WebSearchResult }>()
|
const props = defineProps<{ article: WebSearchResult }>()
|
||||||
defineEmits<{ back: [] }>()
|
defineEmits<{ back: [] }>()
|
||||||
|
|
||||||
const { isDark } = useTheme()
|
|
||||||
|
|
||||||
const articleDomain = computed(() => {
|
const articleDomain = computed(() => {
|
||||||
const url = props.article?.url
|
const url = props.article?.url
|
||||||
if (!url || typeof url !== 'string') return ''
|
if (!url || typeof url !== 'string') return ''
|
||||||
|
|||||||
@@ -87,6 +87,12 @@
|
|||||||
:article="selectedArticle"
|
:article="selectedArticle"
|
||||||
@back="closeArticleDetail"
|
@back="closeArticleDetail"
|
||||||
/>
|
/>
|
||||||
|
<ArticleReader
|
||||||
|
v-else-if="longFormArticle"
|
||||||
|
:content="longFormArticle.content"
|
||||||
|
:title="longFormArticle.title"
|
||||||
|
@back="closeLongFormArticle"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Grid views by active tab -->
|
<!-- Grid views by active tab -->
|
||||||
<component
|
<component
|
||||||
@@ -170,6 +176,7 @@ import PodcastGrid from './PodcastGrid.vue'
|
|||||||
import PodcastDetail from './PodcastDetail.vue'
|
import PodcastDetail from './PodcastDetail.vue'
|
||||||
import NewsGrid from './NewsGrid.vue'
|
import NewsGrid from './NewsGrid.vue'
|
||||||
import ArticleDetail from './ArticleDetail.vue'
|
import ArticleDetail from './ArticleDetail.vue'
|
||||||
|
import ArticleReader from '@/components/renderers/ArticleReader.vue'
|
||||||
import MagazineGrid from './MagazineGrid.vue'
|
import MagazineGrid from './MagazineGrid.vue'
|
||||||
import ProjectGrid from './ProjectGrid.vue'
|
import ProjectGrid from './ProjectGrid.vue'
|
||||||
import NostrGrid from './NostrGrid.vue'
|
import NostrGrid from './NostrGrid.vue'
|
||||||
@@ -218,11 +225,13 @@ const {
|
|||||||
openPodcastDetail,
|
openPodcastDetail,
|
||||||
closePodcastDetail,
|
closePodcastDetail,
|
||||||
closeArticleDetail,
|
closeArticleDetail,
|
||||||
|
longFormArticle,
|
||||||
|
closeLongFormArticle,
|
||||||
closePanel,
|
closePanel,
|
||||||
} = useContentPanel()
|
} = useContentPanel()
|
||||||
|
|
||||||
const hasDetailOpen = computed(() =>
|
const hasDetailOpen = computed(() =>
|
||||||
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value)
|
!!(selectedFilm.value || selectedBook.value || selectedTVSeries.value || selectedSong.value || selectedPodcast.value || selectedArticle.value || selectedDesignSystemItem.value || longFormArticle.value)
|
||||||
)
|
)
|
||||||
|
|
||||||
const windowWidth = ref(window.innerWidth)
|
const windowWidth = ref(window.innerWidth)
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
<template>
|
||||||
|
<div class="article-reader h-full flex">
|
||||||
|
<!-- TOC Sidebar (desktop only) -->
|
||||||
|
<aside
|
||||||
|
v-if="headings.length > 1"
|
||||||
|
class="hidden lg:flex flex-col w-56 shrink-0 border-r border-white/5 overflow-y-auto scrollbar-hide py-4 px-3"
|
||||||
|
>
|
||||||
|
<p class="text-[10px] uppercase tracking-wider text-white/30 mb-2 px-2">Contents</p>
|
||||||
|
<button
|
||||||
|
v-for="(h, i) in headings"
|
||||||
|
:key="i"
|
||||||
|
class="text-left text-xs leading-relaxed py-1 px-2 rounded transition-colors truncate"
|
||||||
|
:class="[
|
||||||
|
activeHeadingIdx === i ? 'text-accent bg-accent/10' : 'text-white/50 hover:text-white/70 hover:bg-white/5',
|
||||||
|
h.level === 3 ? 'pl-5' : ''
|
||||||
|
]"
|
||||||
|
@click="scrollToHeading(h.id)"
|
||||||
|
>
|
||||||
|
{{ h.text }}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<div ref="contentRef" class="flex-1 overflow-y-auto scrollbar-hide">
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div class="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-black/60 backdrop-blur-md border-b border-white/5">
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||||
|
title="Back"
|
||||||
|
@click="$emit('back')"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="flex-1 text-xs text-white/40 truncate">{{ readingTime }} min read</span>
|
||||||
|
|
||||||
|
<!-- TOC toggle (mobile) -->
|
||||||
|
<button
|
||||||
|
v-if="headings.length > 1"
|
||||||
|
class="lg:hidden w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||||
|
title="Table of contents"
|
||||||
|
@click="showMobileToc = !showMobileToc"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Font size -->
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||||
|
title="Decrease font size"
|
||||||
|
:disabled="fontSizeIdx <= 0"
|
||||||
|
@click="fontSizeIdx = Math.max(0, fontSizeIdx - 1)"
|
||||||
|
>
|
||||||
|
<span class="text-[10px] font-bold">A-</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||||
|
title="Increase font size"
|
||||||
|
:disabled="fontSizeIdx >= fontSizes.length - 1"
|
||||||
|
@click="fontSizeIdx = Math.min(fontSizes.length - 1, fontSizeIdx + 1)"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-bold">A+</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Print -->
|
||||||
|
<button
|
||||||
|
class="w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white/80 hover:bg-white/10 transition-colors"
|
||||||
|
title="Print"
|
||||||
|
@click="printArticle"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile TOC dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="showMobileToc && headings.length > 1"
|
||||||
|
class="lg:hidden bg-black/40 backdrop-blur-md border-b border-white/5 px-4 py-2 space-y-0.5 animate-fade-up-fast"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(h, i) in headings"
|
||||||
|
:key="i"
|
||||||
|
class="block w-full text-left text-xs py-1 px-2 rounded transition-colors truncate"
|
||||||
|
:class="[
|
||||||
|
activeHeadingIdx === i ? 'text-accent bg-accent/10' : 'text-white/50 hover:text-white/70',
|
||||||
|
h.level === 3 ? 'pl-5' : ''
|
||||||
|
]"
|
||||||
|
@click="scrollToHeading(h.id); showMobileToc = false"
|
||||||
|
>
|
||||||
|
{{ h.text }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Article body -->
|
||||||
|
<article
|
||||||
|
ref="articleRef"
|
||||||
|
class="article-body px-4 md:px-8 py-6 max-w-prose mx-auto leading-relaxed text-white/90"
|
||||||
|
:style="{ fontSize: fontSizes[fontSizeIdx] + 'px' }"
|
||||||
|
>
|
||||||
|
<h1 v-if="title" class="text-xl font-bold text-white/96 mb-4">{{ title }}</h1>
|
||||||
|
<div
|
||||||
|
class="article-content [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-white/96 [&_h2]:mt-8 [&_h2]:mb-3 [&_h3]:text-base [&_h3]:font-medium [&_h3]:text-white/90 [&_h3]:mt-6 [&_h3]:mb-2 [&_p]:mb-4 [&_ul]:list-disc [&_ul]:ml-5 [&_ul]:mb-4 [&_ol]:list-decimal [&_ol]:ml-5 [&_ol]:mb-4 [&_li]:mb-1 [&_a]:text-accent [&_a]:underline [&_a]:underline-offset-2 [&_blockquote]:border-l-2 [&_blockquote]:border-accent/30 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:text-white/70 [&_blockquote]:my-4 [&_code]:bg-white/10 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.9em] [&_pre]:bg-white/5 [&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:my-4 [&_img]:rounded-lg [&_img]:max-w-full [&_img]:my-4 [&_hr]:border-white/10 [&_hr]:my-6"
|
||||||
|
v-html="renderedHtml"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import MarkdownIt from 'markdown-it'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ back: [] }>()
|
||||||
|
|
||||||
|
// Font sizes
|
||||||
|
const fontSizes = [13, 15, 17, 19, 21]
|
||||||
|
const savedIdx = localStorage.getItem('aiui-article-font-size')
|
||||||
|
const fontSizeIdx = ref(savedIdx ? parseInt(savedIdx, 10) : 1)
|
||||||
|
|
||||||
|
watch(fontSizeIdx, (v) => {
|
||||||
|
localStorage.setItem('aiui-article-font-size', String(v))
|
||||||
|
})
|
||||||
|
|
||||||
|
const showMobileToc = ref(false)
|
||||||
|
|
||||||
|
// markdown-it instance
|
||||||
|
const md = new MarkdownIt({
|
||||||
|
html: false,
|
||||||
|
linkify: true,
|
||||||
|
breaks: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add IDs to headings for TOC anchoring
|
||||||
|
md.renderer.rules.heading_open = (tokens, idx, options, _env, self) => {
|
||||||
|
const token = tokens[idx]
|
||||||
|
const level = parseInt(token.tag.slice(1), 10)
|
||||||
|
if (level === 2 || level === 3) {
|
||||||
|
const nextToken = tokens[idx + 1]
|
||||||
|
const text = nextToken?.children?.reduce((acc, t) => acc + (t.content || ''), '') || ''
|
||||||
|
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '')
|
||||||
|
token.attrSet('id', id)
|
||||||
|
}
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open links in new tab
|
||||||
|
const defaultLinkOpen = md.renderer.rules.link_open || function (tokens, idx, options, _env, self) {
|
||||||
|
return self.renderToken(tokens, idx, options)
|
||||||
|
}
|
||||||
|
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||||
|
tokens[idx].attrSet('target', '_blank')
|
||||||
|
tokens[idx].attrSet('rel', 'noopener noreferrer')
|
||||||
|
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedHtml = computed(() => md.render(props.content))
|
||||||
|
|
||||||
|
// Extract headings for TOC
|
||||||
|
interface Heading {
|
||||||
|
text: string
|
||||||
|
id: string
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const headings = computed<Heading[]>(() => {
|
||||||
|
const result: Heading[] = []
|
||||||
|
const re = /^(#{2,3})\s+(.+)$/gm
|
||||||
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = re.exec(props.content)) !== null) {
|
||||||
|
const text = m[2].trim()
|
||||||
|
const id = text.toLowerCase().replace(/[^\w]+/g, '-').replace(/(^-|-$)/g, '')
|
||||||
|
result.push({ text, id, level: m[1].length })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reading time (~200 words/min)
|
||||||
|
const readingTime = computed(() => {
|
||||||
|
const words = props.content.split(/\s+/).length
|
||||||
|
return Math.max(1, Math.ceil(words / 200))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active heading tracking via Intersection Observer
|
||||||
|
const contentRef = ref<HTMLElement | null>(null)
|
||||||
|
const articleRef = ref<HTMLElement | null>(null)
|
||||||
|
const activeHeadingIdx = ref(0)
|
||||||
|
let observer: IntersectionObserver | null = null
|
||||||
|
|
||||||
|
function setupObserver() {
|
||||||
|
if (!contentRef.value) return
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const id = (entry.target as HTMLElement).id
|
||||||
|
const idx = headings.value.findIndex((h) => h.id === id)
|
||||||
|
if (idx >= 0) activeHeadingIdx.value = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: contentRef.value, rootMargin: '-20% 0px -60% 0px', threshold: 0 }
|
||||||
|
)
|
||||||
|
const headingEls = articleRef.value?.querySelectorAll('h2[id], h3[id]')
|
||||||
|
headingEls?.forEach((el) => observer!.observe(el))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(setupObserver, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.content, () => {
|
||||||
|
setTimeout(setupObserver, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollToHeading(id: string) {
|
||||||
|
const el = articleRef.value?.querySelector(`#${CSS.escape(id)}`)
|
||||||
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function printArticle() {
|
||||||
|
const printWindow = window.open('', '_blank')
|
||||||
|
if (!printWindow) return
|
||||||
|
printWindow.document.write(`<!DOCTYPE html>
|
||||||
|
<html><head><title>${props.title || 'Article'}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Georgia, serif; max-width: 700px; margin: 2em auto; padding: 0 1em; line-height: 1.7; color: #222; }
|
||||||
|
h1 { font-size: 1.8em; margin-bottom: 0.5em; }
|
||||||
|
h2 { font-size: 1.4em; margin-top: 1.5em; }
|
||||||
|
h3 { font-size: 1.2em; margin-top: 1.2em; }
|
||||||
|
code { background: #f0f0f0; padding: 2px 5px; border-radius: 3px; }
|
||||||
|
pre { background: #f5f5f5; padding: 1em; overflow-x: auto; border-radius: 5px; }
|
||||||
|
blockquote { border-left: 3px solid #ccc; padding-left: 1em; color: #555; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
a { color: #0066cc; }
|
||||||
|
</style></head><body>
|
||||||
|
${props.title ? `<h1>${props.title}</h1>` : ''}
|
||||||
|
${renderedHtml.value}
|
||||||
|
</body></html>`)
|
||||||
|
printWindow.document.close()
|
||||||
|
printWindow.print()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -50,6 +50,7 @@ const contentType = ref<'film' | 'song' | 'podcast'>('film')
|
|||||||
const activeTab = ref<ContentTab>('film')
|
const activeTab = ref<ContentTab>('film')
|
||||||
const availableTabs = ref<ContentTab[]>([])
|
const availableTabs = ref<ContentTab[]>([])
|
||||||
const selectedDesignSystemItem = ref<DesignSystemItem | null>(null)
|
const selectedDesignSystemItem = ref<DesignSystemItem | null>(null)
|
||||||
|
const longFormArticle = ref<{ content: string; title?: string } | null>(null)
|
||||||
|
|
||||||
export interface DesignSystemItem {
|
export interface DesignSystemItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -271,6 +272,7 @@ export function useContentPanel() {
|
|||||||
selectedWebsite.value = null
|
selectedWebsite.value = null
|
||||||
selectedMagazineSection.value = null
|
selectedMagazineSection.value = null
|
||||||
selectedDesignSystemItem.value = null
|
selectedDesignSystemItem.value = null
|
||||||
|
longFormArticle.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openFilmDetail(film: Film) { clearAllSelections(); selectedFilm.value = film }
|
function openFilmDetail(film: Film) { clearAllSelections(); selectedFilm.value = film }
|
||||||
@@ -291,6 +293,9 @@ export function useContentPanel() {
|
|||||||
function openWebsiteDetail(website: WebSearchResult) { clearAllSelections(); selectedWebsite.value = website; panelOpen.value = true }
|
function openWebsiteDetail(website: WebSearchResult) { clearAllSelections(); selectedWebsite.value = website; panelOpen.value = true }
|
||||||
function closeWebsiteDetail() { selectedWebsite.value = null }
|
function closeWebsiteDetail() { selectedWebsite.value = null }
|
||||||
|
|
||||||
|
function openLongFormArticle(content: string, title?: string) { clearAllSelections(); longFormArticle.value = { content, title }; panelOpen.value = true }
|
||||||
|
function closeLongFormArticle() { longFormArticle.value = null }
|
||||||
|
|
||||||
function openMagazineSectionDetail(section: MagazineSection, index: number) {
|
function openMagazineSectionDetail(section: MagazineSection, index: number) {
|
||||||
clearAllSelections()
|
clearAllSelections()
|
||||||
selectedMagazineSection.value = section
|
selectedMagazineSection.value = section
|
||||||
@@ -435,6 +440,9 @@ export function useContentPanel() {
|
|||||||
selectedDesignSystemItem,
|
selectedDesignSystemItem,
|
||||||
openDesignSystemItem,
|
openDesignSystemItem,
|
||||||
closeDesignSystemItem,
|
closeDesignSystemItem,
|
||||||
|
longFormArticle,
|
||||||
|
openLongFormArticle,
|
||||||
|
closeLongFormArticle,
|
||||||
enterDesignSystemMode,
|
enterDesignSystemMode,
|
||||||
closePanel,
|
closePanel,
|
||||||
showAllFilms,
|
showAllFilms,
|
||||||
|
|||||||
Reference in New Issue
Block a user