From 2ccdd08e5ad1f680e41620b2fca18670df898cc3 Mon Sep 17 00:00:00 2001 From: Tiara Lena Hock Date: Wed, 16 Sep 2026 16:28:12 +0200 Subject: [PATCH] Feat: Create link previews with floating ui Signed-off-by: Tiara Lena Hock --- .../theme/components/LinkPreview.vue | 301 ++++++++++++++++++ .../theme/components/LinkPreviewProvider.vue | 25 ++ .../theme/composables/useLinkPreview.ts | 165 ++++++++++ docs/.vitepress/theme/index.ts | 7 +- package.json | 1 + pnpm-lock.yaml | 47 ++- 6 files changed, 531 insertions(+), 15 deletions(-) create mode 100644 docs/.vitepress/theme/components/LinkPreview.vue create mode 100644 docs/.vitepress/theme/components/LinkPreviewProvider.vue create mode 100644 docs/.vitepress/theme/composables/useLinkPreview.ts diff --git a/docs/.vitepress/theme/components/LinkPreview.vue b/docs/.vitepress/theme/components/LinkPreview.vue new file mode 100644 index 0000000..1d59fc6 --- /dev/null +++ b/docs/.vitepress/theme/components/LinkPreview.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/docs/.vitepress/theme/components/LinkPreviewProvider.vue b/docs/.vitepress/theme/components/LinkPreviewProvider.vue new file mode 100644 index 0000000..884305c --- /dev/null +++ b/docs/.vitepress/theme/components/LinkPreviewProvider.vue @@ -0,0 +1,25 @@ + + + diff --git a/docs/.vitepress/theme/composables/useLinkPreview.ts b/docs/.vitepress/theme/composables/useLinkPreview.ts new file mode 100644 index 0000000..c9e06b5 --- /dev/null +++ b/docs/.vitepress/theme/composables/useLinkPreview.ts @@ -0,0 +1,165 @@ +import { ref, onMounted, onUnmounted, nextTick } from 'vue' + +export function useLinkPreview() { + const targetElement = ref(null) + const href = ref('') + const isVisible = ref(false) + + let hoverTimeout: ReturnType | null = null + let isHoveringLink = false + let isHoveringPreview = false + + const HOVER_DELAY = 400 // ms before showing preview + const HIDE_DELAY = 200 // ms before hiding when mouse leaves + + function shouldShowPreview(link: HTMLAnchorElement): boolean { + const url = link.getAttribute('href') + if (!url) return false + + // Only show for internal links + if (!url.startsWith('/')) return false + + // Don't show for anchor-only links (same page) + if (url.startsWith('#')) return false + + // Optional: Add more filters + // e.g., exclude external links, certain paths, etc. + + return true + } + + function handleLinkMouseEnter(event: MouseEvent) { + const target = event.currentTarget as HTMLAnchorElement + + if (!shouldShowPreview(target)) return + + isHoveringLink = true + + // Clear any existing timeout + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + + // Show preview after delay + hoverTimeout = setTimeout(() => { + if (isHoveringLink) { + targetElement.value = target + href.value = target.getAttribute('href') || '' + isVisible.value = true + } + }, HOVER_DELAY) + } + + function handleLinkMouseLeave() { + isHoveringLink = false + + // Clear hover timeout + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = null + } + + // Hide preview after delay (gives time to move mouse to preview) + setTimeout(() => { + if (!isHoveringLink && !isHoveringPreview) { + hidePreview() + } + }, HIDE_DELAY) + } + + function handlePreviewMouseEnter() { + isHoveringPreview = true + } + + function handlePreviewMouseLeave() { + isHoveringPreview = false + + // Hide preview after delay + setTimeout(() => { + if (!isHoveringLink && !isHoveringPreview) { + hidePreview() + } + }, HIDE_DELAY) + } + + function hidePreview() { + isVisible.value = false + targetElement.value = null + href.value = '' + } + + function attachListeners() { + // Find all content links + const contentArea = document.querySelector('.vp-doc') + if (!contentArea) return + + const links = contentArea.querySelectorAll('a') + + links.forEach((link) => { + link.addEventListener('mouseenter', handleLinkMouseEnter as EventListener) + link.addEventListener('mouseleave', handleLinkMouseLeave) + }) + } + + function detachListeners() { + const contentArea = document.querySelector('.vp-doc') + if (!contentArea) return + + const links = contentArea.querySelectorAll('a') + + links.forEach((link) => { + link.removeEventListener('mouseenter', handleLinkMouseEnter as EventListener) + link.removeEventListener('mouseleave', handleLinkMouseLeave) + }) + } + + // Watch for route changes and reattach listeners + function setupRouteWatcher(): MutationObserver | null { + const observer = new MutationObserver(() => { + detachListeners() + attachListeners() + }) + + const contentArea = document.querySelector('.VPContent') + if (contentArea) { + observer.observe(contentArea, { + childList: true, + subtree: true + }) + return observer + } + + return null // Explicit return + } + + let observer: MutationObserver | null = null + + onMounted(async () => { + // Wait for next tick to ensure DOM is fully rendered + await nextTick() + + // Small delay to ensure VitePress content has rendered + setTimeout(() => { + attachListeners() + }, 100) + + // Watch for route changes + observer = setupRouteWatcher() + }) + + onUnmounted(() => { + detachListeners() + observer?.disconnect() + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + }) + + return { + targetElement, + href, + isVisible, + handlePreviewMouseEnter, + handlePreviewMouseLeave + } +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 6cc9670..c9f4126 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -8,6 +8,7 @@ import SectionIndex from './components/SectionIndex.vue' import RelatedTopics from './components/RelatedTopics.vue' import Carousel from './components/Carousel.vue' import VPFooter from './components/VPFooter.vue' +import LinkPreviewProvider from './components/LinkPreviewProvider.vue' export default { extends: DefaultTheme, @@ -30,8 +31,10 @@ export default { } ) - return h(DefaultTheme.Layout, null, { - "layout-bottom": () => h(VPFooter), + return h(LinkPreviewProvider, null, { + default: () => h(DefaultTheme.Layout, null, { + "layout-bottom": () => h(VPFooter), + }) }) }, enhanceApp({ app }) { diff --git a/package.json b/package.json index 0990424..f28126f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "docs:woke": "woke docs/" }, "dependencies": { + "@floating-ui/vue": "^2.0.1", "autoprefixer": "10.4.0", "corepack": "^0.34.6", "gray-matter": "4.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4708c52..f01c4cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/vue': + specifier: ^2.0.1 + version: 2.0.1(vue@3.5.16) autoprefixer: specifier: 10.4.0 version: 10.4.0(postcss@8.5.14) @@ -314,6 +317,20 @@ packages: cpu: [x64] os: [win32] + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@floating-ui/vue@2.0.1': + resolution: {integrity: sha512-043dyEKD9TDSfz080YH2vobubgg+NIo/9Fw4RAl7sEMpD8Am1DNrrSTl/GpMBjyGHoD/FbJY2QLawY+HSQsEIA==} + peerDependencies: + vue: '>=3.3.0' + '@iconify-json/simple-icons@1.2.77': resolution: {integrity: sha512-oaENvo6C3BkAEWMlcQA3XemxU9v2SFOTlApSUCODAkIu1haeLCjzrmH3HgmGqjRnJjM+LevO8sA+MgdMHBFBDA==} @@ -371,79 +388,66 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -1717,6 +1721,23 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@floating-ui/vue@2.0.1(vue@3.5.16)': + dependencies: + '@floating-ui/dom': 1.8.0 + '@floating-ui/utils': 0.2.12 + vue: 3.5.16 + '@iconify-json/simple-icons@1.2.77': dependencies: '@iconify/types': 2.0.0