|
| 1 | +<script setup lang="ts"> |
| 2 | +import { ref, computed, onMounted, onUnmounted } from 'vue' |
| 3 | +import { useData, withBase } from 'vitepress' |
| 4 | +
|
| 5 | +const { page } = useData() |
| 6 | +
|
| 7 | +const open = ref(false) |
| 8 | +const copied = ref<string | null>(null) |
| 9 | +const root = ref<HTMLElement | null>(null) |
| 10 | +
|
| 11 | +// withBase prepends site base (e.g. "/ExpressiveSharp/") and ensures a leading slash. |
| 12 | +const mdUrl = computed(() => withBase('/' + page.value.relativePath)) |
| 13 | +const llmUrl = computed(() => withBase('/' + page.value.relativePath.replace(/\.md$/, '.llm.md'))) |
| 14 | +
|
| 15 | +const absoluteUrl = computed(() => { |
| 16 | + if (typeof window === 'undefined') return '' |
| 17 | + return window.location.origin + window.location.pathname |
| 18 | +}) |
| 19 | +
|
| 20 | +const llmPrompt = computed(() => |
| 21 | + encodeURIComponent( |
| 22 | + `Read ${absoluteUrl.value} and help me with ExpressiveSharp.` |
| 23 | + ) |
| 24 | +) |
| 25 | +
|
| 26 | +async function copyFromUrl(url: string, label: string) { |
| 27 | + try { |
| 28 | + console.log('[CopyPage] fetching', url) |
| 29 | + const res = await fetch(url) |
| 30 | + console.log('[CopyPage] response', res.status, res.headers.get('content-type')) |
| 31 | + if (!res.ok) throw new Error(`${res.status}`) |
| 32 | + const text = await res.text() |
| 33 | + console.log('[CopyPage] body starts with:', text.slice(0, 80)) |
| 34 | + await navigator.clipboard.writeText(text) |
| 35 | + copied.value = label |
| 36 | + setTimeout(() => { if (copied.value === label) copied.value = null }, 1500) |
| 37 | + } catch (e) { |
| 38 | + console.error('[CopyPage] failed', e) |
| 39 | + copied.value = 'Copy failed' |
| 40 | + setTimeout(() => { if (copied.value === 'Copy failed') copied.value = null }, 1500) |
| 41 | + } |
| 42 | + open.value = false |
| 43 | +} |
| 44 | +
|
| 45 | +function copyMarkdown() { copyFromUrl(mdUrl.value, 'Copied!') } |
| 46 | +function copyMarkdownExpanded() { copyFromUrl(llmUrl.value, 'Copied!') } |
| 47 | +
|
| 48 | +function viewMarkdown() { |
| 49 | + window.open(mdUrl.value, '_blank', 'noopener') |
| 50 | + open.value = false |
| 51 | +} |
| 52 | +
|
| 53 | +function openInChatGpt() { |
| 54 | + window.open(`https://chat.openai.com/?q=${llmPrompt.value}`, '_blank', 'noopener') |
| 55 | + open.value = false |
| 56 | +} |
| 57 | +
|
| 58 | +function openInClaude() { |
| 59 | + window.open(`https://claude.ai/new?q=${llmPrompt.value}`, '_blank', 'noopener') |
| 60 | + open.value = false |
| 61 | +} |
| 62 | +
|
| 63 | +function onDocClick(e: MouseEvent) { |
| 64 | + if (!root.value) return |
| 65 | + if (!root.value.contains(e.target as Node)) open.value = false |
| 66 | +} |
| 67 | +
|
| 68 | +onMounted(() => document.addEventListener('click', onDocClick)) |
| 69 | +onUnmounted(() => document.removeEventListener('click', onDocClick)) |
| 70 | +</script> |
| 71 | + |
| 72 | +<template> |
| 73 | + <div class="copy-page" ref="root"> |
| 74 | + <div class="copy-page-group"> |
| 75 | + <button class="copy-page-main" @click="copyMarkdown" :title="'Copy page as markdown'"> |
| 76 | + <svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| 77 | + <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect> |
| 78 | + <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path> |
| 79 | + </svg> |
| 80 | + <span>{{ copied ?? 'Copy page' }}</span> |
| 81 | + </button> |
| 82 | + <button class="copy-page-chevron" @click="open = !open" :aria-expanded="open" :title="'More actions'"> |
| 83 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
| 84 | + <polyline points="6 9 12 15 18 9"></polyline> |
| 85 | + </svg> |
| 86 | + </button> |
| 87 | + </div> |
| 88 | + <div v-if="open" class="copy-page-menu" role="menu"> |
| 89 | + <button role="menuitem" @click="copyMarkdown"> |
| 90 | + <span class="menu-title">Copy as Markdown</span> |
| 91 | + <span class="menu-hint">Raw page source</span> |
| 92 | + </button> |
| 93 | + <button role="menuitem" @click="copyMarkdownExpanded"> |
| 94 | + <span class="menu-title">Copy with Rendered SQL</span> |
| 95 | + <span class="menu-hint">Live samples expanded inline</span> |
| 96 | + </button> |
| 97 | + <button role="menuitem" @click="viewMarkdown"> |
| 98 | + <span class="menu-title">View as Markdown ↗</span> |
| 99 | + <span class="menu-hint">Open raw .md in new tab</span> |
| 100 | + </button> |
| 101 | + <div class="menu-divider"></div> |
| 102 | + <button role="menuitem" @click="openInChatGpt"> |
| 103 | + <span class="menu-title">Open in ChatGPT ↗</span> |
| 104 | + <span class="menu-hint">Ask about this page</span> |
| 105 | + </button> |
| 106 | + <button role="menuitem" @click="openInClaude"> |
| 107 | + <span class="menu-title">Open in Claude ↗</span> |
| 108 | + <span class="menu-hint">Ask about this page</span> |
| 109 | + </button> |
| 110 | + </div> |
| 111 | + </div> |
| 112 | +</template> |
| 113 | + |
| 114 | +<style scoped> |
| 115 | +.copy-page { |
| 116 | + position: relative; |
| 117 | + display: flex; |
| 118 | + justify-content: flex-end; |
| 119 | + align-items: stretch; |
| 120 | + margin: 0 0 12px; |
| 121 | + font-size: 12px; |
| 122 | + font-weight: 500; |
| 123 | + z-index: 20; |
| 124 | +} |
| 125 | +
|
| 126 | +.copy-page-group { |
| 127 | + display: inline-flex; |
| 128 | + align-items: stretch; |
| 129 | +} |
| 130 | +
|
| 131 | +.copy-page-main, |
| 132 | +.copy-page-chevron { |
| 133 | + display: inline-flex; |
| 134 | + align-items: center; |
| 135 | + gap: 6px; |
| 136 | + padding: 4px 10px; |
| 137 | + background: var(--vp-c-bg-soft); |
| 138 | + color: var(--vp-c-text-2); |
| 139 | + border: 1px solid var(--vp-c-divider); |
| 140 | + cursor: pointer; |
| 141 | + transition: color 0.15s, background 0.15s, border-color 0.15s; |
| 142 | +} |
| 143 | +
|
| 144 | +.copy-page-main { |
| 145 | + border-radius: 6px 0 0 6px; |
| 146 | + border-right-width: 0; |
| 147 | +} |
| 148 | +
|
| 149 | +.copy-page-chevron { |
| 150 | + border-radius: 0 6px 6px 0; |
| 151 | + padding: 4px 6px; |
| 152 | +} |
| 153 | +
|
| 154 | +.copy-page-main:hover, |
| 155 | +.copy-page-chevron:hover, |
| 156 | +.copy-page-chevron[aria-expanded="true"] { |
| 157 | + color: var(--vp-c-text-1); |
| 158 | + background: var(--vp-c-bg); |
| 159 | + border-color: var(--vp-c-brand-1); |
| 160 | +} |
| 161 | +
|
| 162 | +.copy-page-main .icon { |
| 163 | + opacity: 0.75; |
| 164 | +} |
| 165 | +
|
| 166 | +.copy-page-menu { |
| 167 | + position: absolute; |
| 168 | + top: calc(100% + 6px); |
| 169 | + right: 0; |
| 170 | + min-width: 240px; |
| 171 | + padding: 6px; |
| 172 | + background: var(--vp-c-bg-elv, var(--vp-c-bg)); |
| 173 | + border: 1px solid var(--vp-c-divider); |
| 174 | + border-radius: 8px; |
| 175 | + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); |
| 176 | + z-index: 50; |
| 177 | +} |
| 178 | +
|
| 179 | +.copy-page-menu button { |
| 180 | + display: flex; |
| 181 | + flex-direction: column; |
| 182 | + align-items: flex-start; |
| 183 | + width: 100%; |
| 184 | + padding: 6px 10px; |
| 185 | + background: transparent; |
| 186 | + border: none; |
| 187 | + border-radius: 4px; |
| 188 | + cursor: pointer; |
| 189 | + text-align: left; |
| 190 | + color: var(--vp-c-text-1); |
| 191 | +} |
| 192 | +
|
| 193 | +.copy-page-menu button:hover { |
| 194 | + background: var(--vp-c-bg-soft); |
| 195 | +} |
| 196 | +
|
| 197 | +.copy-page-menu .menu-title { |
| 198 | + font-size: 13px; |
| 199 | + font-weight: 500; |
| 200 | +} |
| 201 | +
|
| 202 | +.copy-page-menu .menu-hint { |
| 203 | + font-size: 11px; |
| 204 | + color: var(--vp-c-text-3); |
| 205 | + margin-top: 1px; |
| 206 | +} |
| 207 | +
|
| 208 | +.copy-page-menu .menu-divider { |
| 209 | + height: 1px; |
| 210 | + margin: 4px 0; |
| 211 | + background: var(--vp-c-divider); |
| 212 | +} |
| 213 | +</style> |
0 commit comments