Skip to main content
Version: 11.14.0

Preview Internals

This page explains in detail how the document preview works in Mint: how it is triggered when a user clicks a result, how the iframe content is fetched and validated, how entity highlights are applied, and how it adapts to different screen sizes.

tip

For a higher-level overview of the Preview component and its sub-components (navbar, header, tabs, assistant), see Preview.

Triggering the Preview

Step 1 — User clicks an article

Each article card calls SelectionService.setCurrentArticle(article). This method writes the article into SelectionStore, making it available as a signal across the application.

Step 2 — SelectionStore drives visibility

SearchAllComponent computes hasPreview() directly from the store:

readonly hasPreview = computed(() => this.selectionStore.id?.() !== undefined);

When hasPreview() becomes true, the right-hand panel becomes visible via conditional rendering and CSS animation classes.

Step 3 — Preview panel renders

There is no Drawer component. The <preview> element is rendered directly in the search results template:

Desktop (≥ 1024 px):

<!-- src/app2/pages/search/search-all.html -->
<preview
[class]="cn(
'absolute inset-0 size-full',
hasPreview() && 'z-0 animate-slide-in-right',
!hasPreview() && 'z-1 translate-x-full animate-slide-out-right opacity-0'
)" />

The panel slides in from the right using CSS animation classes. When hasPreview() is false, the reverse animation plays and the element becomes invisible.

Mobile / Tablet (< 1024 px):

A <sheet-previewer> side sheet component is used instead:

<sheet-previewer class="hidden md:block" />

The SheetPreviewerComponent uses a <sheet> (side sheet from @sinequa/ui). It adapts its content based on the breakpoint:

  • Mobile (< 768 px): shows only <preview-content> with the article title in a <sheet-header>
  • Tablet (768 px–1023 px): shows the full <preview> component inside the sheet

Step 4 — PreviewComponent reads the article

PreviewComponent does not receive the article as an @Input(). It reads it directly from SelectionStore:

// src/components/preview/preview.ts
protected readonly article = computed(() => {
const article = this.selectionStore.article?.();
if (article) {
this.applicationService.setTitle(article.title || "Preview");
}
return article as Article;
});

Closing the Preview

Closing the preview goes through PreviewNavbarComponent:

// src/components/preview/preview-navbar/preview-navbar.ts
handleClose() {
this.queryParamsStore.patch({ id: undefined }); // removes id from URL
this.selectionStore.clear(); // clears SelectionStore
this.onClose.emit();
}

Clearing SelectionStore sets selectionStore.id() to undefined, which makes hasPreview() return false in SearchAllComponent, triggering the slide-out animation.

For the SheetPreviewerComponent, closure is handled by the sheet itself:

handleChange(open: boolean) {
this.sheetService.setOpen(open);
if (!open) {
setTimeout(() => this.selectionService.clearCurrentArticle(), 200); // waits for animation
}
}

ESC key also closes the sheet via:

// src/components/preview/preview.ts
this.eventManager.addEventListener(this.document.body, "keyup", (event: KeyboardEvent) => {
if (event.key === "Escape") this.sheetService.setOpen(false);
});

Auto-open from URL

If the URL contains an id parameter (e.g., #/search/all?id=doc-42), fetchServerPage automatically opens the preview for the matching record as soon as the first page loads:

// src/config/fetch-server-page.ts
if (id) {
result.records?.forEach(article => {
if (article.id === id) selectionService.setCurrentArticle(article);
});
}

This makes preview state shareable via URL. The id parameter is written to the URL by Effect 2 in SearchAllComponent whenever queryParamsStore.id is set.

PreviewContentComponent — iframe Loading Pipeline

PreviewContentComponent (src/components/preview/preview-content/preview-content.ts) manages the document iframe. It runs two Angular resources in sequence:

Resource 1 — Fetch PreviewData

previewDataResource = rxResource({
params: () => ({
id: this.id() || this.selectionStore.id?.() || "",
text: this.selectionStore.queryText?.() || "",
previewHighlights: this.selectionStore.previewHighlights?.()?.highlights
}),
stream: ({ params: { id, text, previewHighlights } }) =>
this.previewService.preview(id, { name: this.queryName, text }, previewHighlights)
});

previewService.preview() calls the Sinequa backend to retrieve the PreviewData object, which contains the documentCachedContentUrl — the URL of the cached HTML version of the document.

Resource 2 — Validate the cached URL

Before displaying the iframe, a HEAD request verifies the cached content is actually accessible:

previewValidationResource = resource({
params: () => ({ url: this.previewUrl(), previewData: this.previewData() }),
loader: async ({ params }) => {
const response = await fetch(
window.location.origin + params.previewData.documentCachedContentUrl,
{ method: "HEAD" }
);
return { isValid: response.status === 200 };
}
});

If the fetch fails, previewService.DOMContentLoaded.set(true) is called to stop the loading indicator, and a "preview unavailable" message is shown.

Template states

StateDisplay
isLoading() on either resourceSpinning loader icon
Both resources have values and previewUrl() is definediframe + preview navigator + preview actions
No preview data or validation failed"Preview unavailable" message

iframe Communication

Once the iframe is mounted, its contentWindow is registered with PreviewService:

effect(() => {
const iframeElement = this.iframe();
if (iframeElement) this.previewService.setIframe(iframeElement.nativeElement.contentWindow);
});

The parent and iframe communicate via postMessage:

  • To iframe: previewService.sendMessage({ action: "select", id: "snippet_123", usePassageHighlighter: true })
  • From iframe: window.addEventListener("message", ...) — receives current-page events to track the currently visible page

Advanced Search (Entity / Extract Navigation)

The <advanced-search> component is rendered directly inside PreviewComponent (not in a separate drawer):

<!-- src/components/preview/preview.html -->
<advanced-search
[class]="cn('max-w-(clamp(200px,350px,100%)) h-full overflow-y-auto border-foreground/18 bg-menu', extended() && 'border-l')"
[article]="article()"
previewStrategy="replace" />

The host grid switches between grid-cols-[auto_0fr] (collapsed, Advanced Search invisible) and grid-cols-[1fr_.5fr] (extended, Advanced Search visible) based on the extended signal.

The "Search in document" button in the navbar toggles this signal:

toggleExtended(): void {
this.extended.set(!this.extended());
}

The button is only shown when isPrimary is true (primary document conversion) and showSearchButton is enabled in the navbar config.

Expanded Preview Dialog

When the expandPreview feature flag is enabled (AppStore.general()?.features?.expandPreview), the navbar shows an expand button. Clicking it opens PreviewDialogComponent:

onExpand(): void {
this.previewDialog()?.open(this.article() as Article);
}

PreviewDialogComponent (src/components/preview/dialog/preview-dialog.ts) is a full-screen modal dialog that renders the preview with its own set of tabs:

TabDescription
ChatChat with the document (assistant, preview-chatwithdoc-assistant)
SummaryAI summary of the document (preview-summarize-assistant)
FindAdvanced search / entity navigation

It provides its own PreviewService instance (providers: [PreviewService]) to keep the dialog's iframe state independent from the inline preview.

Entity Highlighting

When the preview renders a document's content, named entities (companies, people, places, amounts, etc.) are highlighted with distinct colors.

Configuration

The highlights are configured in src/config/highlight.config.ts and provided via the HIGHLIGHTS token:

// src/app2/app.config.ts
{ provide: HIGHLIGHTS, useValue: PREVIEW_HIGHLIGHTS }

Each entry defines a category name, a CSS class applied in the iframe, and a color for the navigation UI.

CategoryColor
companyRed (#FF7675)
geoBlue (#74B9FF)
personTeal (#00ABB5)
moneyGreen (#85BB65)
entity12Gold (#FFD700)
matchlocations / extractslocationsYellow

How it works

The highlights configuration is consumed by PreviewService and PreviewNavigator:

  1. CSS rules are injected into the iframe to colorize each entity class
  2. The <preview-navigator> overlay (displayed above the iframe) lists entity categories and allows jumping between occurrences

Passage Navigation

When a search result with a selected passage is opened, PreviewContentComponent scrolls the iframe to the relevant passage:

  1. SelectionStore.previewHighlights() provides the snippetId
  2. On iframe load, previewService.sendMessage({ action: "select", id: "snippet_X", usePassageHighlighter: true }) scrolls and highlights the passage

For secondary conversions (multi-conversion), passage highlighting is not possible, so the component falls back to page-level scrolling:

  1. previewService.passageOffset() provides the passage offset in the original document
  2. queryService.getDocPage(id, offset, length) resolves the page number for that offset
  3. previewService.sendMessage({ action: "select", id: "sq-page-start-N" }) scrolls to the page

Responsive Summary

Screen sizePreview presentationOpened by
Desktop (≥ 1024 px)Inline right panel, 50% width, slide-in animationhasPreview() CSS toggle
Tablet (768 px–1023 px)Side sheet (full <preview>) from the rightSheetPreviewerComponent
Mobile (< 768 px)Side sheet (title + <preview-content> only) from the rightSheetPreviewerComponent