forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1<script setup lang="ts">
2const props = defineProps<{
3 html: string
4 lines: number
5 selectedLines: { start: number; end: number } | null
6}>()
7
8const emit = defineEmits<{
9 lineClick: [lineNum: number, event: MouseEvent]
10}>()
11
12const codeRef = useTemplateRef('codeRef')
13
14// Generate line numbers array
15const lineNumbers = computed(() => {
16 return Array.from({ length: props.lines }, (_, i) => i + 1)
17})
18
19// Used for CSS calculation of line number column width
20const lineDigits = computed(() => {
21 return String(props.lines).length
22})
23
24// Check if a line is selected
25function isLineSelected(lineNum: number): boolean {
26 if (!props.selectedLines) return false
27 return lineNum >= props.selectedLines.start && lineNum <= props.selectedLines.end
28}
29
30// Handle line number click
31function onLineClick(lineNum: number, event: MouseEvent) {
32 emit('lineClick', lineNum, event)
33}
34
35// Apply highlighting to code lines when selection changes
36function updateLineHighlighting() {
37 if (!codeRef.value) return
38
39 // Lines are inside pre > code > .line
40 const lines = codeRef.value.querySelectorAll('code > .line')
41 lines.forEach((line, index) => {
42 const lineNum = index + 1
43 if (isLineSelected(lineNum)) {
44 line.classList.add('highlighted')
45 } else {
46 line.classList.remove('highlighted')
47 }
48 })
49}
50
51// Watch for changes to selection and HTML content
52// Use deep watch and nextTick to ensure DOM is updated
53watch(
54 () => [props.selectedLines, props.html] as const,
55 () => {
56 nextTick(updateLineHighlighting)
57 },
58 { immediate: true },
59)
60
61// Use Nuxt's `navigateTo` for the rendered import links
62function handleImportLinkNavigate() {
63 if (!codeRef.value) return
64
65 const anchors = codeRef.value.querySelectorAll<HTMLAnchorElement>('a.import-link')
66 anchors.forEach(anchor => {
67 // NOTE: We do not need to remove previous listeners because we re-create the entire HTML content on each html update
68 anchor.addEventListener('click', event => {
69 if (event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return
70 const href = anchor.getAttribute('href')
71 if (href) {
72 event.preventDefault()
73 navigateTo(href)
74 }
75 })
76 })
77}
78
79watch(
80 () => props.html,
81 () => {
82 nextTick(handleImportLinkNavigate)
83 },
84 { immediate: true },
85)
86</script>
87
88<template>
89 <div class="code-viewer flex min-h-full max-w-full">
90 <!-- Line numbers column -->
91 <div
92 class="line-numbers shrink-0 bg-bg-subtle border-ie border-solid border-border text-end select-none relative"
93 :style="{ '--line-digits': lineDigits }"
94 aria-hidden="true"
95 >
96 <!-- This needs to be a native <a> element, because `LinkBase` (or specifically `NuxtLink`) does not seem to work when trying to prevent default behavior (jumping to the anchor) -->
97 <a
98 v-for="lineNum in lineNumbers"
99 :id="`L${lineNum}`"
100 :key="lineNum"
101 :href="`#L${lineNum}`"
102 tabindex="-1"
103 class="line-number block px-3 py-0 font-mono text-sm leading-6 cursor-pointer transition-colors no-underline"
104 :class="[
105 isLineSelected(lineNum)
106 ? 'bg-yellow-500/20 text-fg'
107 : 'text-fg-subtle hover:text-fg-muted',
108 ]"
109 @click.prevent="onLineClick(lineNum, $event)"
110 >
111 {{ lineNum }}
112 </a>
113 </div>
114
115 <!-- Code content -->
116 <div class="code-content flex-1 overflow-x-auto min-w-0">
117 <!-- eslint-disable vue/no-v-html -- HTML is generated server-side by Shiki -->
118 <div ref="codeRef" class="code-lines w-fit" v-html="html" />
119 <!-- eslint-enable vue/no-v-html -->
120 </div>
121 </div>
122</template>
123
124<style scoped>
125.code-viewer {
126 font-size: 14px;
127}
128
129.line-numbers {
130 /* 1ch per digit + 1.5rem (px-3 * 2) padding */
131 min-width: calc(var(--line-digits) * 1ch + 1.5rem);
132}
133
134.code-content :deep(pre) {
135 margin: 0;
136 padding: 0;
137 background: transparent !important;
138 overflow: visible;
139}
140
141.code-content :deep(code) {
142 display: block;
143 padding: 0 1rem;
144 background: transparent !important;
145}
146
147.code-content :deep(.line) {
148 display: block;
149 /* Ensure consistent height matching line numbers */
150 line-height: 24px;
151 min-height: 24px;
152 max-height: 24px;
153 white-space: pre;
154 overflow: hidden;
155 transition: background-color 0.1s;
156}
157
158/* Highlighted lines in code content - extend full width with negative margin */
159.code-content :deep(.line.highlighted) {
160 background: rgb(234 179 8 / 0.2); /* yellow-500/20 */
161 margin: 0 -1rem;
162 padding: 0 1rem;
163}
164
165/* Clickable import links */
166.code-content :deep(.import-link) {
167 color: inherit;
168 text-decoration: underline;
169 text-decoration-style: dotted;
170 text-decoration-color: rgba(158, 203, 255, 0.5); /* syntax.str with transparency */
171 text-underline-offset: 2px;
172 transition:
173 text-decoration-color 0.15s,
174 text-decoration-style 0.15s;
175 cursor: pointer;
176}
177
178.code-content :deep(.import-link:hover) {
179 text-decoration-style: solid;
180 text-decoration-color: #9ecbff; /* syntax.str - light blue */
181}
182</style>