Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions app/components/Code/Viewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ const props = defineProps<{
html: string
lines: number
selectedLines: { start: number; end: number } | null
wordWrap?: boolean
}>()

const emit = defineEmits<{
lineClick: [lineNum: number, event: MouseEvent]
}>()

const codeRef = useTemplateRef('codeRef')
const lineNumbersRef = useTemplateRef('lineNumbersRef')

// Generate line numbers array
const lineNumbers = computed(() => {
Expand All @@ -32,6 +34,32 @@ function onLineClick(lineNum: number, event: MouseEvent) {
emit('lineClick', lineNum, event)
}

// Synchronize line number heights with code line heights (needed for word wrap)
function syncLineHeights() {
if (!props.wordWrap || !codeRef.value || !lineNumbersRef.value) {
// Reset heights if word wrap is disabled
if (lineNumbersRef.value) {
const nums = lineNumbersRef.value.querySelectorAll<HTMLElement>('.line-number')
for (const num of nums) {
num.style.height = ''
}
}
return
}

const lines = codeRef.value.querySelectorAll<HTMLElement>('code > .line')
const nums = lineNumbersRef.value.querySelectorAll<HTMLElement>('.line-number')

lines.forEach((line, index) => {
const num = nums[index]
if (num) {
// Use getBoundingClientRect for more precision if needed, but offsetHeight is usually enough
const height = line.offsetHeight
num.style.height = `${height}px`
}
})
}
Comment on lines +37 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify that markdown files default to preview mode, that CodeViewer is mounted with v-show,
# and that syncLineHeights currently only reruns on html/wordWrap/resize.
rg -n -C3 "const markdownViewMode|<CodeViewer|v-show=\"!fileContent.markdownHtml \\|\\| markdownViewMode === 'code'\"" "app/pages/package-code/[[org]]/[packageName]/v/[version]/[...filePath].vue"
rg -n -C3 "function syncLineHeights|offsetHeight|watch\\(|props.wordWrap|useEventListener\\(window, 'resize'" "app/components/Code/Viewer.vue"

Repository: npmx-dev/npmx.dev

Length of output: 2129


Re-sync wrapped line heights after the viewer becomes visible.

The parent component mounts CodeViewer with v-show="!fileContent.markdownHtml || markdownViewMode === 'code'" and defaults to preview mode. For markdown files, this means the component is initially hidden (display: none), yet the immediate watcher on [props.selectedLines, props.html] fires during mount and calls syncLineHeights() while the viewer is not rendered. This causes offsetHeight to return 0, setting all line-number heights to 0px. The heights are never corrected when the user switches to code view with word wrap enabled, leaving the line-number column collapsed.

Add visibility detection to skip measurement while hidden, or use a ResizeObserver on the rendered code block to continuously maintain synchronisation.

🧰 Tools
🪛 Biome (2.4.6)

[error] 43-43: This callback passed to forEach() iterable method should not return a value.

(lint/suspicious/useIterableCallbackReturn)


// Apply highlighting to code lines when selection changes
function updateLineHighlighting() {
if (!codeRef.value) return
Expand All @@ -53,11 +81,27 @@ function updateLineHighlighting() {
watch(
() => [props.selectedLines, props.html] as const,
() => {
nextTick(updateLineHighlighting)
nextTick(() => {
updateLineHighlighting()
syncLineHeights()
})
},
{ immediate: true },
)

// Also watch wordWrap specifically
watch(
() => props.wordWrap,
() => {
nextTick(syncLineHeights)
},
)

// Sync on resize
if (import.meta.client) {
useEventListener(window, 'resize', syncLineHeights)
}

// Use Nuxt's `navigateTo` for the rendered import links
function handleImportLinkNavigate() {
if (!codeRef.value) return
Expand Down Expand Up @@ -86,9 +130,10 @@ watch(
</script>

<template>
<div class="code-viewer flex min-h-full max-w-full">
<div class="code-viewer flex min-h-full max-w-full" :class="{ 'is-wrapped': wordWrap }">
<!-- Line numbers column -->
<div
ref="lineNumbersRef"
class="line-numbers shrink-0 bg-bg-subtle border-ie border-solid border-border text-end select-none relative"
:style="{ '--line-digits': lineDigits }"
aria-hidden="true"
Expand Down Expand Up @@ -155,6 +200,12 @@ watch(
transition: background-color 0.1s;
}

.is-wrapped .code-content :deep(.line) {
white-space: pre-wrap;
word-break: break-all;
max-height: none;
}

/* Highlighted lines in code content - extend full width with negative margin */
.code-content :deep(.line.highlighted) {
@apply bg-yellow-500/20;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ const { copied: fileContentCopied, copy: copyFileContent } = useClipboard({
// Canonical URL for this code page
const canonicalUrl = computed(() => `https://npmx.dev${getCodeUrl(route.params)}`)

const wordWrap = useLocalStorage('npmx-code-word-wrap', false)

// Toggle markdown view mode
const markdownViewModes = [
{
Expand Down Expand Up @@ -416,6 +418,17 @@ defineOgImageComponent('Default', {
</nav>
</div>
<div class="flex items-center gap-2" v-if="isViewingFile && !isBinaryFile && fileContent">
<button
type="button"
class="px-2 py-1 font-mono text-xs text-fg-muted bg-bg-subtle border border-border rounded hover:text-fg hover:border-border-hover transition-colors items-center inline-flex gap-1"
:aria-pressed="wordWrap"
:class="{ 'bg-accent/10 text-accent border-accent/20': wordWrap }"
@click="wordWrap = !wordWrap"
>
<span v-if="!!wordWrap" class="i-lucide:wrap-text w-3 h-3" />
<span v-else class="i-lucide:text w-3 h-3" />
{{ $t('code.word_wrap') }}
</button>
<button
v-if="selectedLines"
type="button"
Expand Down Expand Up @@ -462,6 +475,7 @@ defineOgImageComponent('Default', {
:html="fileContent.html"
:lines="fileContent.lines"
:selected-lines="selectedLines"
:word-wrap="wordWrap"
@line-click="handleLineClick"
/>
<div class="sticky bottom-0 bg-bg border-t border-border px-4 py-1">
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@
"code": "code"
},
"file_path": "File path",
"word_wrap": "Word wrap",
"binary_file": "Binary file",
"binary_rendering_warning": "File type \"{contentType}\" is not supported for preview."
},
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2404,6 +2404,9 @@
"file_path": {
"type": "string"
},
"word_wrap": {
"type": "string"
},
"binary_file": {
"type": "string"
},
Expand Down
Loading