diff --git a/website/src/app/features/reference-architecture-detail/reference-architecture-detail.component.html b/website/src/app/features/reference-architecture-detail/reference-architecture-detail.component.html index ced8104c..1a9a1267 100644 --- a/website/src/app/features/reference-architecture-detail/reference-architecture-detail.component.html +++ b/website/src/app/features/reference-architecture-detail/reference-architecture-detail.component.html @@ -96,7 +96,7 @@

{{ vm.name }}

-
0) { + // Mark blocks synchronously before the async render to prevent double-processing + // when ngAfterViewChecked fires again during the async mermaid import. + mermaidBlocks.forEach((block: Element) => block.setAttribute('data-mermaid-rendered', 'true')); + this.renderMermaid(mermaidBlocks); } - // Mark blocks synchronously before the async render to prevent double-processing - // when ngAfterViewChecked fires again during the async mermaid import. - mermaidBlocks.forEach((block: Element) => block.setAttribute('data-mermaid-rendered', 'true')); - this.renderMermaid(mermaidBlocks); + this.enhanceDiagramImages(); } public ngOnDestroy(): void { @@ -186,14 +187,78 @@ export class ReferenceArchitectureDetailComponent implements OnInit, OnDestroy, try { const { svg } = await mermaid.render(id, graphDefinition); - const wrapper = document.createElement('div'); - wrapper.classList.add('mermaid-diagram'); - wrapper.innerHTML = svg; - pre.replaceWith(wrapper); + const holder = document.createElement('div'); + holder.innerHTML = svg; + const svgEl = holder.firstElementChild as HTMLElement | null; + + if (!svgEl) { + continue; + } + + pre.replaceWith(svgEl); + this.makeZoomable(svgEl, svgEl, 'Diagram', null); } catch { // Leave the code block as-is if rendering fails } } } + + /** + * Diagrams in the markdown body are committed SVGs referenced as images. They are wide and + * detailed, and browser zoom does not help — the page just reflows and the diagram stays fitted + * to its column — so each one gets a fullscreen viewer with real zoom and pan. + */ + private enhanceDiagramImages(): void { + const images: NodeListOf = this.el.nativeElement.querySelectorAll( + '.markdown-body img:not([data-diagram-enhanced])' + ); + + images.forEach(img => + this.makeZoomable(img, img, img.getAttribute('alt') || 'Diagram', img.getAttribute('src')) + ); + } + + /** + * Wraps `element` in a figure carrying a fullscreen affordance. `viewerNode` is what the viewer + * clones and displays — the same element for images, the bare `` for rendered mermaid. + */ + private makeZoomable( + element: HTMLElement, + viewerNode: HTMLElement, + title: string, + sourceUrl: string | null + ): void { + element.setAttribute('data-diagram-enhanced', 'true'); + + const figure = document.createElement('figure'); + figure.className = 'diagram-figure'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'diagram-expand'; + button.setAttribute('aria-label', `View ${title} fullscreen`); + button.innerHTML = 'Fullscreen'; + + // A markdown image sits alone in its own paragraph — replace that paragraph so the figure does + // not end up nested inside a

. + const parent = element.parentElement; + const replaced = + parent?.tagName === 'P' && parent.childElementCount === 1 && !parent.textContent?.trim() + ? parent + : element; + + replaced.parentElement?.replaceChild(figure, replaced); + figure.appendChild(element); + figure.appendChild(button); + + figure.addEventListener('click', () => this.openDiagram(viewerNode, title, sourceUrl)); + } + + private openDiagram(node: HTMLElement, title: string, sourceUrl: string | null): void { + this.dialog.open(DiagramViewerComponent, { + panelClass: 'diagram-viewer-panel', + data: { title, node, sourceUrl } + }); + } } diff --git a/website/src/app/shared/diagram-viewer/diagram-viewer.component.html b/website/src/app/shared/diagram-viewer/diagram-viewer.component.html new file mode 100644 index 00000000..9999faf7 --- /dev/null +++ b/website/src/app/shared/diagram-viewer/diagram-viewer.component.html @@ -0,0 +1,76 @@ +

+ +
+ + {{ data.title }} + +
+ + {{ zoomPercent }}% + + +
+ + + + + + +
+ + +
+
+
+
+ +
+ Scroll or pinch to zoom · drag to pan · double-click to zoom · Esc to close +
+
diff --git a/website/src/app/shared/diagram-viewer/diagram-viewer.component.scss b/website/src/app/shared/diagram-viewer/diagram-viewer.component.scss new file mode 100644 index 00000000..9c2cba03 --- /dev/null +++ b/website/src/app/shared/diagram-viewer/diagram-viewer.component.scss @@ -0,0 +1,46 @@ +.diagram-viewer-stage { + position: relative; + touch-action: none; + cursor: grab; + + &.is-panning { + cursor: grabbing; + } +} + +.diagram-viewer-canvas { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + transform-origin: center center; + will-change: transform; +} + +.diagram-viewer-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgb(255 255 255 / 0.15); + } +} + +/* The diagram is cloned into the canvas at runtime, so it carries no component style attribute. */ +:host ::ng-deep .diagram-viewer-content { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + user-select: none; + pointer-events: none; + background-color: #ffffff; + border-radius: 0.5rem; +} diff --git a/website/src/app/shared/diagram-viewer/diagram-viewer.component.ts b/website/src/app/shared/diagram-viewer/diagram-viewer.component.ts new file mode 100644 index 00000000..5ab26bee --- /dev/null +++ b/website/src/app/shared/diagram-viewer/diagram-viewer.component.ts @@ -0,0 +1,222 @@ +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + Component, + ElementRef, + HostListener, + Inject, + ViewChild +} from '@angular/core'; + +export interface DiagramViewerData { + /** Title shown in the viewer toolbar. */ + title: string; + /** The diagram to display — an `` or `` element. It is cloned, never moved. */ + node: HTMLElement; + /** Source URL of the diagram, when it has one. Enables "open original". */ + sourceUrl?: string | null; +} + +const MIN_SCALE = 0.25; +const MAX_SCALE = 16; +const ZOOM_STEP = 1.25; + +/** + * Fullscreen diagram viewer with zoom and pan. Architecture diagrams are wide and detailed, so + * browser zoom does not help: the page reflows and the diagram stays fitted to its column. + */ +@Component({ + selector: 'mst-diagram-viewer', + imports: [CommonModule], + templateUrl: './diagram-viewer.component.html', + styleUrl: './diagram-viewer.component.scss', + standalone: true +}) +export class DiagramViewerComponent implements AfterViewInit { + @ViewChild('canvas', { static: true }) + public canvas!: ElementRef; + + @ViewChild('stage', { static: true }) + public stage!: ElementRef; + + public scale = 1; + + public translateX = 0; + + public translateY = 0; + + public panning = false; + + private pointerId: number | null = null; + + private panStartX = 0; + + private panStartY = 0; + + private content: HTMLElement | null = null; + + /** Size the diagram renders at when fitted to the stage — the 100% zoom reference. */ + private baseWidth = 0; + + private baseHeight = 0; + + constructor( + public dialogRef: DialogRef, + @Inject(DIALOG_DATA) public data: DiagramViewerData + ) {} + + public get zoomPercent(): number { + return Math.round(this.scale * 100); + } + + public ngAfterViewInit(): void { + const clone = this.data.node.cloneNode(true) as HTMLElement; + clone.removeAttribute('data-diagram-enhanced'); + clone.classList.add('diagram-viewer-content'); + this.canvas.nativeElement.appendChild(clone); + this.content = clone; + + if (clone instanceof HTMLImageElement && !clone.complete) { + clone.addEventListener('load', () => this.captureBaseSize(), { once: true }); + + return; + } + + this.captureBaseSize(); + } + + public zoomIn(): void { + this.zoomBy(ZOOM_STEP); + } + + public zoomOut(): void { + this.zoomBy(1 / ZOOM_STEP); + } + + /** Back to "fits the viewport" — the state the viewer opens in. */ + public resetView(): void { + this.scale = 1; + this.translateX = 0; + this.translateY = 0; + this.applyScale(); + } + + @HostListener('wheel', ['$event']) + public onWheel(event: WheelEvent): void { + event.preventDefault(); + // Trackpad pinch arrives as a wheel event with ctrlKey set; both gestures zoom. + const factor = Math.pow(ZOOM_STEP, -event.deltaY / 100); + this.zoomBy(factor, event.clientX, event.clientY); + } + + public onPointerDown(event: PointerEvent): void { + if (event.button !== 0) { + return; + } + + this.pointerId = event.pointerId; + this.panning = true; + this.panStartX = event.clientX - this.translateX; + this.panStartY = event.clientY - this.translateY; + (event.target as HTMLElement).setPointerCapture?.(event.pointerId); + event.preventDefault(); + } + + public onPointerMove(event: PointerEvent): void { + if (!this.panning || event.pointerId !== this.pointerId) { + return; + } + + this.translateX = event.clientX - this.panStartX; + this.translateY = event.clientY - this.panStartY; + } + + public onPointerUp(event: PointerEvent): void { + if (event.pointerId !== this.pointerId) { + return; + } + + this.panning = false; + this.pointerId = null; + } + + public onDoubleClick(event: MouseEvent): void { + if (this.scale > 1) { + this.resetView(); + + return; + } + + this.zoomBy(ZOOM_STEP * ZOOM_STEP, event.clientX, event.clientY); + } + + @HostListener('document:keydown', ['$event']) + public onKeydown(event: KeyboardEvent): void { + if (event.key === '+' || event.key === '=') { + this.zoomIn(); + } else if (event.key === '-' || event.key === '_') { + this.zoomOut(); + } else if (event.key === '0') { + this.resetView(); + } else { + return; + } + + event.preventDefault(); + } + + /** + * Scales by `factor`, keeping the point under the cursor fixed. Without an anchor the stage + * centre stays put, which is what the toolbar buttons want. + */ + private zoomBy(factor: number, anchorClientX?: number, anchorClientY?: number): void { + const next = Math.min(MAX_SCALE, Math.max(MIN_SCALE, this.scale * factor)); + const applied = next / this.scale; + + if (applied === 1) { + return; + } + + if (anchorClientX !== undefined && anchorClientY !== undefined) { + const rect = this.stage.nativeElement.getBoundingClientRect(); + // Transform origin is the stage centre, so anchor offsets are measured from there. + const anchorX = anchorClientX - (rect.left + rect.width / 2); + const anchorY = anchorClientY - (rect.top + rect.height / 2); + this.translateX = anchorX - (anchorX - this.translateX) * applied; + this.translateY = anchorY - (anchorY - this.translateY) * applied; + } + + this.scale = next; + this.applyScale(); + } + + private captureBaseSize(): void { + if (!this.content) { + return; + } + + const rect = this.content.getBoundingClientRect(); + this.baseWidth = rect.width; + this.baseHeight = rect.height; + // The fit is now pinned as an explicit size, so the fit constraints have to go. + this.content.style.maxWidth = 'none'; + this.content.style.maxHeight = 'none'; + this.applyScale(); + } + + /** + * Zoom changes the diagram's layout size rather than applying a `scale()` transform: a + * transform would rasterize the SVG once at fit size and blow up that bitmap, which is exactly + * the blurry result users already get from browser zoom. Resizing makes the browser re-render + * the vector, so text stays sharp at any zoom level. + */ + private applyScale(): void { + if (!this.content || !this.baseWidth) { + return; + } + + this.content.style.width = `${this.baseWidth * this.scale}px`; + this.content.style.height = `${this.baseHeight * this.scale}px`; + } +} diff --git a/website/src/app/shared/diagram-viewer/index.ts b/website/src/app/shared/diagram-viewer/index.ts new file mode 100644 index 00000000..0099bcd7 --- /dev/null +++ b/website/src/app/shared/diagram-viewer/index.ts @@ -0,0 +1 @@ +export * from './diagram-viewer.component';