feat: improve chunk viewer with fixed navigation and markdown rendering

This commit implements three UI improvements for the chunk viewer:

1. Fixed modal footer with navigation controls
   - Moved PDF navigation buttons to a fixed footer
   - Footer remains visible while scrolling content
   - Three-section layout: fixed header, scrollable body, fixed footer

2. Removed duplicate navigation controls
   - Removed previous/next buttons from PDFViewer component
   - Controls now only in App.vue modal footer
   - Cleaned up unused imports and CSS

3. Markdown rendering for chunk content
   - Created MarkdownViewer component using markdown-it
   - Renders markdown content aligned with Nextcloud design system
   - Removed problematic markdown-it-task-checkbox dependency
   - Combines before/chunk/after context with visual separators

4. Cleaned up search results display
   - Removed excerpt snippets from results list
   - Kept only chunk/page metadata for cleaner UI

The modal structure now has:
- Fixed header (title + close button)
- Scrollable body (PDF canvas or markdown content)
- Fixed footer (page navigation - always visible)

Fixes markdown rendering "require is not defined" error by using
only markdown-it without CommonJS plugins.
This commit is contained in:
Chris Coutinho
2025-12-15 23:37:47 +01:00
parent 04c64e97b0
commit b246a03ac4
5 changed files with 319 additions and 92 deletions
+49 -4
View File
@@ -13,6 +13,7 @@
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.29.2",
"markdown-it": "^14.1.0",
"pdfjs-dist": "^4.0.379",
"plotly.js-dist-min": "^2.35.3",
"vue": "^2.7.16",
@@ -4113,9 +4114,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0",
"peer": true
"license": "Python-2.0"
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
@@ -5722,7 +5721,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -8622,6 +8620,15 @@
"license": "MIT",
"peer": true
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkify-string": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/linkify-string/-/linkify-string-4.3.2.tgz",
@@ -8737,6 +8744,23 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/material-colors": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz",
@@ -8909,6 +8933,12 @@
"license": "CC0-1.0",
"peer": true
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/meow": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
@@ -10414,6 +10444,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qified": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/qified/-/qified-0.5.3.tgz",
@@ -12669,6 +12708,12 @@
"license": "MIT",
"optional": true
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/ufo": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz",
+1
View File
@@ -22,6 +22,7 @@
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.29.2",
"markdown-it": "^14.1.0",
"plotly.js-dist-min": "^2.35.3",
"pdfjs-dist": "^4.0.379",
"vue": "^2.7.16",
+84 -36
View File
@@ -199,10 +199,6 @@
· {{ t('astroglobe', 'Page {page}/{total}', { page: result.page_number, total: result.page_count }) }}
</span>
</div>
<div
class="mcp-result-excerpt">
{{ result.excerpt }}
</div>
</div>
</div>
</div>
@@ -296,6 +292,7 @@
<!-- PDF/Chunk Viewer Modal -->
<div v-if="showViewer" class="mcp-modal-overlay" @click.self="closeViewer">
<div class="mcp-modal">
<!-- Fixed Header -->
<div class="mcp-modal-header">
<h3>{{ viewerTitle }}</h3>
<NcButton type="tertiary" @click="closeViewer">
@@ -304,6 +301,8 @@
</template>
</NcButton>
</div>
<!-- Scrollable Content -->
<div class="mcp-modal-body">
<!-- Loading State -->
<div v-if="viewerLoading" class="mcp-viewer-loading">
@@ -311,27 +310,43 @@
<span>{{ t('astroglobe', 'Loading content...') }}</span>
</div>
<!-- PDF Viewer -->
<!-- PDF Viewer (canvas only, controls in footer) -->
<PDFViewer
v-else-if="viewerType === 'pdf'"
:file-path="currentPdfPath"
:page-number="viewerPage"
@prev-page="viewerPage--"
@next-page="viewerPage++"
@loaded="handlePdfLoaded"
@error="handlePdfError" />
<!-- Text Viewer (for non-PDFs) -->
<div v-else class="mcp-text-viewer">
<div v-if="viewerContext.before" class="mcp-context-text">
{{ viewerContext.before }}
</div>
<div class="mcp-highlighted-chunk">
{{ viewerContext.chunk }}
</div>
<div v-if="viewerContext.after" class="mcp-context-text">
{{ viewerContext.after }}
</div>
</div>
<!-- Markdown Viewer (for non-PDFs) -->
<MarkdownViewer
v-else
:content="getMarkdownContent()" />
</div>
<!-- Fixed Footer (navigation controls) -->
<div v-if="!viewerLoading && viewerType === 'pdf' && pdfTotalPages > 0" class="mcp-modal-footer">
<NcButton
:disabled="viewerPage <= 1"
@click="viewerPage--">
<template #icon>
<ChevronLeft :size="20" />
</template>
{{ t('astroglobe', 'Previous') }}
</NcButton>
<span class="mcp-page-info">
{{ t('astroglobe', 'Page {current} of {total}', { current: viewerPage, total: pdfTotalPages }) }}
</span>
<NcButton
:disabled="viewerPage >= pdfTotalPages"
@click="viewerPage++">
<template #icon>
<ChevronRight :size="20" />
</template>
{{ t('astroglobe', 'Next') }}
</NcButton>
</div>
</div>
</div>
@@ -356,12 +371,15 @@ import ChartBox from 'vue-material-design-icons/ChartBox.vue'
import Cog from 'vue-material-design-icons/Cog.vue'
import ChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import ChevronUp from 'vue-material-design-icons/ChevronUp.vue'
import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
import Refresh from 'vue-material-design-icons/Refresh.vue'
import OpenInNew from 'vue-material-design-icons/OpenInNew.vue'
import Eye from 'vue-material-design-icons/Eye.vue'
import Close from 'vue-material-design-icons/Close.vue'
import PDFViewer from './components/PDFViewer.vue'
import MarkdownViewer from './components/MarkdownViewer.vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
@@ -394,11 +412,14 @@ export default {
NcEmptyContent,
NcCheckboxRadioSwitch,
PDFViewer,
MarkdownViewer,
Magnify,
ChartBox,
Cog,
ChevronDown,
ChevronUp,
ChevronLeft,
ChevronRight,
Refresh,
OpenInNew,
Eye,
@@ -434,6 +455,7 @@ export default {
viewerTitle: '',
viewerType: 'text',
viewerPage: 1,
pdfTotalPages: 0,
currentPdfPath: '',
viewerContext: {
chunk: '',
@@ -776,8 +798,35 @@ export default {
this.viewerType = 'text'
},
handlePdfLoaded(event) {
this.pdfTotalPages = event.totalPages || 0
},
getMarkdownContent() {
// Combine before/chunk/after context into single markdown string
let content = ''
if (this.viewerContext.before) {
content += this.viewerContext.before + '\n\n'
}
if (this.viewerContext.chunk) {
// Highlight the main chunk with a separator
content += '---\n\n'
content += this.viewerContext.chunk
content += '\n\n---'
}
if (this.viewerContext.after) {
content += '\n\n' + this.viewerContext.after
}
return content
},
closeViewer() {
this.showViewer = false
this.pdfTotalPages = 0
},
},
}
@@ -1031,25 +1080,6 @@ a.mcp-result-title {
line-height: 1.4;
}
.mcp-result-excerpt {
font-size: 13px;
color: var(--color-text-maxcontrast);
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
&--expanded {
display: block;
-webkit-line-clamp: unset;
background: var(--color-background-dark);
padding: 12px;
border-radius: var(--border-radius);
margin-top: 8px;
white-space: pre-wrap;
word-break: break-word;
}
}
.mcp-result-actions {
display: flex;
align-items: center;
@@ -1159,6 +1189,24 @@ a.mcp-result-title {
position: relative;
}
.mcp-modal-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 20px;
border-top: 1px solid var(--color-border);
background: var(--color-main-background);
flex-shrink: 0;
.mcp-page-info {
font-size: 14px;
color: var(--color-text-maxcontrast);
min-width: 150px;
text-align: center;
}
}
.mcp-viewer-loading {
display: flex;
flex-direction: column;
+185
View File
@@ -0,0 +1,185 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown-viewer" v-html="html" />
</template>
<script>
import MarkdownIt from 'markdown-it'
export default {
name: 'MarkdownViewer',
props: {
content: {
type: String,
required: true,
},
},
data() {
const md = new MarkdownIt({
html: false, // Disable HTML for security
linkify: true,
breaks: true,
typographer: true,
})
return {
html: '',
md,
}
},
watch: {
content: {
immediate: true,
handler(newContent) {
this.renderMarkdown(newContent)
},
},
},
methods: {
renderMarkdown(text) {
if (!text) {
this.html = ''
return
}
try {
this.html = this.md.render(text)
} catch (error) {
console.error('Markdown rendering error:', error)
// Fallback to escaped plain text
this.html = `<pre>${this.escapeHtml(text)}</pre>`
}
},
escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
},
},
}
</script>
<style scoped lang="scss">
.markdown-viewer {
font-size: 14px;
line-height: 1.6;
color: var(--color-main-text);
word-wrap: break-word;
overflow-wrap: break-word;
// Typography
:deep(h1), :deep(h2), :deep(h3), :deep(h4), :deep(h5), :deep(h6) {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--color-main-text);
}
:deep(h1) { font-size: 2em; border-bottom: 1px solid var(--color-border); padding-bottom: 8px; }
:deep(h2) { font-size: 1.5em; border-bottom: 1px solid var(--color-border); padding-bottom: 8px; }
:deep(h3) { font-size: 1.25em; }
:deep(h4) { font-size: 1em; }
:deep(h5) { font-size: 0.875em; }
:deep(h6) { font-size: 0.85em; color: var(--color-text-maxcontrast); }
// Paragraphs and spacing
:deep(p) {
margin-top: 0;
margin-bottom: 16px;
}
// Lists
:deep(ul), :deep(ol) {
margin-top: 0;
margin-bottom: 16px;
padding-left: 2em;
}
:deep(li) {
margin-bottom: 4px;
}
// Code blocks
:deep(code) {
padding: 2px 6px;
background: var(--color-background-dark);
border-radius: var(--border-radius);
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
}
:deep(pre) {
background: var(--color-background-dark);
padding: 16px;
border-radius: var(--border-radius);
overflow-x: auto;
margin-bottom: 16px;
code {
padding: 0;
background: transparent;
}
}
// Blockquotes
:deep(blockquote) {
margin: 0 0 16px 0;
padding: 0 16px;
border-left: 4px solid var(--color-primary-element);
color: var(--color-text-maxcontrast);
p:last-child {
margin-bottom: 0;
}
}
// Links
:deep(a) {
color: var(--color-primary-element);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
// Tables
:deep(table) {
border-collapse: collapse;
width: 100%;
margin-bottom: 16px;
}
:deep(th), :deep(td) {
padding: 8px 12px;
border: 1px solid var(--color-border);
text-align: left;
}
:deep(th) {
background: var(--color-background-dark);
font-weight: 600;
}
// Horizontal rule
:deep(hr) {
border: none;
border-top: 1px solid var(--color-border);
margin: 24px 0;
}
// Images
:deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 16px 0;
}
}
</style>
-52
View File
@@ -11,27 +11,6 @@
<div v-else ref="container" class="pdf-canvas-container">
<canvas ref="canvas" />
</div>
<div v-if="!loading && !error && totalPages > 0" class="pdf-controls">
<NcButton
:disabled="pageNumber <= 1"
@click="$emit('prev-page')">
<template #icon>
<ChevronLeft :size="20" />
</template>
{{ t('astroglobe', 'Previous') }}
</NcButton>
<span class="page-info">
{{ t('astroglobe', 'Page {current} of {total}', { current: pageNumber, total: totalPages }) }}
</span>
<NcButton
:disabled="pageNumber >= totalPages"
@click="$emit('next-page')">
<template #icon>
<ChevronRight :size="20" />
</template>
{{ t('astroglobe', 'Next') }}
</NcButton>
</div>
</div>
</template>
@@ -40,19 +19,13 @@ import * as pdfjsLib from 'pdfjs-dist'
import { generateUrl } from '@nextcloud/router'
import { translate as t } from '@nextcloud/l10n'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import AlertCircle from 'vue-material-design-icons/AlertCircle.vue'
import ChevronLeft from 'vue-material-design-icons/ChevronLeft.vue'
import ChevronRight from 'vue-material-design-icons/ChevronRight.vue'
export default {
name: 'PDFViewer',
components: {
NcLoadingIcon,
NcButton,
AlertCircle,
ChevronLeft,
ChevronRight,
},
props: {
filePath: {
@@ -247,34 +220,9 @@ export default {
}
}
.pdf-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 8px;
background: var(--color-background-dark);
border-radius: var(--border-radius-large);
.page-info {
font-size: 14px;
color: var(--color-text-maxcontrast);
min-width: 120px;
text-align: center;
}
}
@media (max-width: 768px) {
.pdf-viewer {
padding: 8px;
}
.pdf-controls {
flex-direction: column;
gap: 8px;
.page-info {
order: -1;
}
}
}
</style>