1import { canonicalizeSvg, materializePresentation, materializeSvg, supportedAppearances } from './client-svg.js'2import { httpRendererUnitFor } from './renderer-units.js'34const STATUS_LABELS = {5 idle: 'Ready',6 loading: 'Rendering',7 ready: 'Rendered',8}910function clamp(value, minimum, maximum) {11 return Math.min(Math.max(value, minimum), maximum)12}1314export function previewThemeForAppearance(appearance) {15 if (appearance.startsWith('dark-')) return 'dark'16 if (appearance.startsWith('auto-')) return 'auto'17 return 'light'18}1920export function previewBackgroundForAppearance(appearance, rawBackground) {21 if (appearance.startsWith('dark-')) return 'var(--preview-dark-bg)'22 if (appearance.startsWith('light-')) return 'var(--preview-light-bg)'23 if (appearance.startsWith('auto-')) return 'var(--preview-auto-bg)'24 return rawBackground25}2627export function previewViewKey({ type, source, options = {} }) {28 return JSON.stringify({ type, source, options })29}3031export class PreviewController {32 constructor({33 stage,34 image,35 status,36 minimap,37 minimapImage,38 minimapViewport,39 autoTheme = 'light',40 onAppearances = () => {},41 onError = () => {},42 }) {43 this.stage = stage44 this.image = image45 this.status = status46 this.minimap = minimap47 this.minimapImage = minimapImage48 this.minimapViewport = minimapViewport49 this.onAppearances = onAppearances50 this.onError = onError51 this.autoTheme = autoTheme52 this.scale = 153 this.x = 054 this.y = 055 this.objectUrl = null56 this.imageSwapNumber = 057 this.abortController = null58 this.requestNumber = 059 this.pendingRender = null60 this.renderLoop = null61 this.latestRenderKey = null62 this.latestViewKey = null63 this.fitOnNextLoad = false64 this.latestSvgBlob = null65 this.latestCanonicalSvg = null66 this.latestPresentation = null67 this.latestAppearance = null68 this.latestRendererIdentity = null69 this.previousImageSize = null70 this.imageFallbackUrl = null71 this.activeImageUrl = null72 this.drag = null7374 this.image.addEventListener('load', () => this.handleImageLoad())75 this.image.addEventListener('error', () => this.retryBlockedImage())76 this.stage.addEventListener('pointerdown', event => this.startPan(event))77 this.stage.addEventListener('pointermove', event => this.pan(event))78 this.stage.addEventListener('pointerup', event => this.endPan(event))79 this.stage.addEventListener('pointercancel', event => this.endPan(event))80 this.stage.addEventListener('wheel', event => this.wheelZoom(event), { passive: false })81 this.minimap.addEventListener('pointerdown', event => this.moveFromMinimap(event))82 new ResizeObserver(() => this.updateTransform()).observe(this.stage)83 }8485 handleImageLoad() {86 if (this.image.src !== this.activeImageUrl) return87 this.imageFallbackUrl = null88 this.setStatus('Rendered', 'ready')89 if (this.fitOnNextLoad) {90 this.fitOnNextLoad = false91 this.previousImageSize = null92 this.fit()93 } else {94 if (this.previousImageSize) {95 this.x += (this.previousImageSize.width - this.image.naturalWidth) * this.scale / 296 this.y += (this.previousImageSize.height - this.image.naturalHeight) * this.scale / 297 this.previousImageSize = null98 }99 this.updateTransform()100 }101 }102103 render({ type, source, options = {}, meta = {}, presentation = {} }) {104 if (!source.trim()) {105 this.requestNumber++106 this.imageSwapNumber++107 this.pendingRender = null108 this.abortController?.abort()109 this.setStatus('Write something to render.', 'idle')110 return111 }112113 const viewKey = previewViewKey({ type, source, options })114 if (this.latestViewKey !== viewKey) {115 this.latestViewKey = viewKey116 this.fitOnNextLoad = true117 }118119 const renderKey = JSON.stringify({ type, source, options, meta, presentation })120 if (this.latestRenderKey === renderKey && this.latestSvgBlob) return Promise.resolve()121122 this.imageSwapNumber++123 this.pendingRender = { type, source, options, meta, presentation, renderKey, requestNumber: ++this.requestNumber }124 this.abortController?.abort()125 if (!this.renderLoop) this.renderLoop = this.drainRenderQueue()126 return this.renderLoop127 }128129 async drainRenderQueue() {130 try {131 while (this.pendingRender) {132 const render = this.pendingRender133 this.pendingRender = null134 await this.performRender(render)135 }136 } finally {137 this.renderLoop = null138 if (this.pendingRender) this.renderLoop = this.drainRenderQueue()139 }140 }141142 async performRender({ type, source, options, meta, presentation, renderKey, requestNumber }) {143 const abortController = new AbortController()144 this.abortController = abortController145 this.setStatus('Renderingā¦', 'loading')146 this.stage.style.setProperty('--render-background', 'var(--preview-bg)')147148 try {149 const rendered = await this.renderThroughUnit({ type, source, options, meta, presentation }, abortController.signal)150 if (requestNumber !== this.requestNumber) return151 const canonicalSvg = canonicalizeSvg(rendered.body, meta, type, rendered.identity.version)152 const canonical = this.normalizedSvgBlob(canonicalSvg)153 const appearances = supportedAppearances(canonicalSvg)154 const requestedAppearance = presentation.appearance ?? 'raw'155 const appearance = appearances.includes(requestedAppearance) ? requestedAppearance : 'raw'156 this.onAppearances(appearances, appearance)157 this.latestRenderKey = renderKey158 this.latestSvgBlob = canonical.blob159 this.latestCanonicalSvg = canonicalSvg160 this.latestPresentation = presentation161 this.latestAppearance = appearance162 this.latestRendererIdentity = rendered.identity163 this.displayCanonical(canonicalSvg, presentation, appearance)164 } catch (error) {165 if (error.name === 'AbortError') return166 this.handleRenderError(error.message || 'Could not render this diagram.')167 } finally {168 if (this.abortController === abortController) this.abortController = null169 }170 }171172 setAutoTheme(theme) {173 if (!['light', 'dark'].includes(theme) || this.autoTheme === theme) return174 this.autoTheme = theme175 if (this.latestCanonicalSvg && this.latestAppearance?.startsWith('auto-')) {176 this.displayCanonical(this.latestCanonicalSvg, this.latestPresentation, this.latestAppearance)177 }178 }179180 displayCanonical(canonicalSvg, presentation, appearance) {181 const displayAppearance = appearance.startsWith('auto-')182 ? `${this.autoTheme}-${appearance.slice('auto-'.length)}`183 : appearance184 const displayedSvg = displayAppearance === 'raw'185 ? materializePresentation(canonicalSvg, presentation)186 : materializeSvg(canonicalSvg, displayAppearance)187 const displayed = this.normalizedSvgBlob(displayedSvg)188 const nextObjectUrl = URL.createObjectURL(displayed.blob)189 const swapNumber = ++this.imageSwapNumber190 this.setStatus('Loading imageā¦', 'loading')191 void this.swapDecodedImage({192 objectUrl: nextObjectUrl,193 fallbackUrl: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(displayedSvg)}`,194 background: previewBackgroundForAppearance(displayAppearance, displayed.background),195 theme: previewThemeForAppearance(displayAppearance),196 }, swapNumber)197 }198199 async decodeImage(url) {200 const image = new Image()201 image.decoding = 'async'202 if (typeof image.decode === 'function') {203 image.src = url204 await image.decode()205 return206 }207 await new Promise((resolve, reject) => {208 image.addEventListener('load', resolve, { once: true })209 image.addEventListener('error', () => reject(new Error('The rendered image was blocked or invalid.')), { once: true })210 image.src = url211 })212 }213214 async swapDecodedImage(next, swapNumber) {215 let activeUrl = next.objectUrl216 try {217 await this.decodeImage(activeUrl)218 } catch {219 activeUrl = next.fallbackUrl220 try {221 await this.decodeImage(activeUrl)222 } catch (error) {223 URL.revokeObjectURL(next.objectUrl)224 if (swapNumber === this.imageSwapNumber) this.handleRenderError(error.message)225 return226 }227 }228229 if (swapNumber !== this.imageSwapNumber) {230 URL.revokeObjectURL(next.objectUrl)231 return232 }233234 this.previousImageSize = !this.fitOnNextLoad && this.image.naturalWidth && this.image.naturalHeight235 ? { width: this.image.naturalWidth, height: this.image.naturalHeight }236 : null237 this.stage.dataset.previewTheme = next.theme238 this.stage.style.setProperty('--render-background', next.background)239240 const previousObjectUrl = this.objectUrl241 this.objectUrl = activeUrl === next.objectUrl ? next.objectUrl : null242 this.imageFallbackUrl = activeUrl === next.objectUrl ? next.fallbackUrl : null243 this.activeImageUrl = activeUrl244 this.image.src = activeUrl245 this.minimapImage.src = activeUrl246 this.image.hidden = false247 if (activeUrl !== next.objectUrl) URL.revokeObjectURL(next.objectUrl)248 if (previousObjectUrl) URL.revokeObjectURL(previousObjectUrl)249 }250251 async renderThroughUnit({ type, source, options, meta, presentation }, signal) {252 const unitEndpoint = httpRendererUnitFor(type)253 if (!unitEndpoint) throw new Error(`No renderer is registered for ${type}.`)254 const response = await this.renderRequest(unitEndpoint, {255 source,256 format: 'svg',257 options,258 metadata: meta,259 presentation: {260 background: presentation.background ?? '',261 padding: presentation.padding ?? 0,262 frame: presentation.frame ?? false,263 },264 }, signal)265 return this.renderResponse(response, type)266 }267268 renderRequest(endpoint, body, signal) {269 return fetch(endpoint, {270 method: 'POST',271 headers: { Accept: 'image/svg+xml', 'Content-Type': 'application/json' },272 body: JSON.stringify(body),273 signal,274 })275 }276277 async renderResponse(response, fallbackUnit) {278 if (!response.ok) throw new Error(await this.errorMessage(response))279 this.status.dataset.cache = response.headers.get('X-Diagram-Cache')?.toLowerCase() ?? 'miss'280 this.status.dataset.renderer = response.headers.get('X-Diagram-Unit')?.toLowerCase()281 ?? response.headers.get('X-Diagram-Renderer')?.toLowerCase()282 ?? fallbackUnit283 const unit = response.headers.get('X-Diagram-Unit')?.toLowerCase()284 ?? response.headers.get('X-Diagram-Engine')?.toLowerCase()285 ?? fallbackUnit286 const build = response.headers.get('X-Renderer-Build')287 ?? response.headers.get('X-Diagram-Engine-Version')288 ?? `${unit}-unknown`289 const version = [response.headers.get('X-Diagram-Engine-Version'), build].filter(Boolean).join(' ')290 const pipeline = (response.headers.get('X-Diagram-Pipeline') ?? unit)291 .split(',')292 .map(value => value.trim().toLowerCase())293 .filter(Boolean)294 return { body: await response.text(), identity: { unit, build, version, pipeline } }295 }296297 retryBlockedImage() {298 if (this.image.src !== this.activeImageUrl) return299 if (this.imageFallbackUrl && this.activeImageUrl.startsWith('blob:')) {300 const fallback = this.imageFallbackUrl301 this.imageFallbackUrl = null302 if (this.objectUrl) URL.revokeObjectURL(this.objectUrl)303 this.objectUrl = null304 this.activeImageUrl = fallback305 this.image.src = fallback306 this.minimapImage.src = fallback307 return308 }309 this.handleRenderError('The rendered image was blocked or invalid.')310 }311312 svgBlobFor(state) {313 const { type, source, options = {}, meta = {}, presentation = {} } = state314 const renderKey = JSON.stringify({ type, source, options, meta, presentation })315 return this.latestRenderKey === renderKey ? this.latestSvgBlob : null316 }317318 canonicalSvgFor(state) {319 const { type, source, options = {}, meta = {}, presentation = {} } = state320 const renderKey = JSON.stringify({ type, source, options, meta, presentation })321 return this.latestRenderKey === renderKey ? this.latestCanonicalSvg : null322 }323324 rendererIdentityFor(state) {325 const { type, source, options = {}, meta = {}, presentation = {} } = state326 const renderKey = JSON.stringify({ type, source, options, meta, presentation })327 return this.latestRenderKey === renderKey ? this.latestRendererIdentity : null328 }329330 async errorMessage(response) {331 const body = await response.text()332 if (response.headers.get('Content-Type')?.includes('application/json')) {333 try {334 const payload = JSON.parse(body)335 if (typeof payload?.error?.message === 'string') return payload.error.message336 } catch {337 // Use the HTML/plain-text parser below.338 }339 }340 const document = new DOMParser().parseFromString(body, 'text/html')341 const message = document.body.textContent.trim().replace(/\s+/g, ' ')342 return message || `Render failed with HTTP ${response.status}.`343 }344345 normalizedSvgBlob(source) {346 const document = new DOMParser().parseFromString(source, 'image/svg+xml')347 const svg = document.documentElement348 const viewBox = svg.getAttribute('viewBox')?.trim().split(/[\s,]+/).map(Number)349 if (svg.nodeName === 'svg' && viewBox?.length === 4 && viewBox.every(Number.isFinite)) {350 if (!svg.hasAttribute('width')) svg.setAttribute('width', String(viewBox[2]))351 if (!svg.hasAttribute('height')) svg.setAttribute('height', String(viewBox[3]))352 source = new XMLSerializer().serializeToString(svg)353 }354 const declaredBackground = svg.style.backgroundColor || svg.style.background355 const background = declaredBackground && declaredBackground !== 'transparent'356 ? declaredBackground357 : '#ffffff'358 return {359 blob: new Blob([source], { type: 'image/svg+xml' }),360 background,361 }362 }363364 setStatus(message, state) {365 const label = STATUS_LABELS[state] ?? 'Ready'366 this.status.textContent = label367 this.status.title = ''368 this.status.setAttribute('aria-label', label)369 this.status.dataset.state = state370 }371372 handleRenderError(message) {373 this.setStatus('Ready', 'idle')374 this.onError(message)375 }376377 zoom(factor) {378 if (this.image.hidden) return379 const bounds = this.stage.getBoundingClientRect()380 this.zoomAt(factor, bounds.width / 2, bounds.height / 2)381 }382383 zoomAt(factor, anchorX, anchorY) {384 const nextScale = clamp(this.scale * factor, 0.1, 8)385 const diagramX = (anchorX - this.x) / this.scale386 const diagramY = (anchorY - this.y) / this.scale387 this.x = anchorX - diagramX * nextScale388 this.y = anchorY - diagramY * nextScale389 this.scale = nextScale390 this.updateTransform()391 }392393 wheelZoom(event) {394 if (this.image.hidden || event.target.closest('.preview-toolbar, .minimap')) return395 event.preventDefault()396 const bounds = this.stage.getBoundingClientRect()397 const delta = event.deltaY * (398 event.deltaMode === WheelEvent.DOM_DELTA_LINE ? 16399 : event.deltaMode === WheelEvent.DOM_DELTA_PAGE ? bounds.height400 : 1401 )402 this.zoomAt(403 Math.exp(-clamp(delta, -240, 240) * 0.002),404 event.clientX - bounds.left,405 event.clientY - bounds.top,406 )407 }408409 oneToOne() {410 if (this.image.hidden) return411 const bounds = this.stage.getBoundingClientRect()412 this.scale = 1413 this.x = (bounds.width - this.image.naturalWidth) / 2414 this.y = (bounds.height - this.image.naturalHeight) / 2415 this.updateTransform()416 }417418 fit() {419 if (!this.image.naturalWidth || !this.image.naturalHeight) return420 const bounds = this.stage.getBoundingClientRect()421 const padding = Math.min(64, bounds.width * 0.12)422 this.scale = clamp(Math.min(423 (bounds.width - padding * 2) / this.image.naturalWidth,424 (bounds.height - padding * 2) / this.image.naturalHeight,425 ), 0.1, 2)426 this.x = (bounds.width - this.image.naturalWidth * this.scale) / 2427 this.y = (bounds.height - this.image.naturalHeight * this.scale) / 2428 this.updateTransform()429 }430431 updateTransform() {432 this.image.style.transform = `translate(${this.x}px, ${this.y}px) scale(${this.scale})`433 this.updateMinimap()434 }435436 startPan(event) {437 if (this.image.hidden || event.button !== 0 || event.target.closest('.preview-toolbar, .minimap')) return438 this.drag = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, originX: this.x, originY: this.y }439 this.stage.setPointerCapture(event.pointerId)440 this.stage.dataset.dragging = 'true'441 }442443 pan(event) {444 if (!this.drag || event.pointerId !== this.drag.pointerId) return445 this.x = this.drag.originX + event.clientX - this.drag.x446 this.y = this.drag.originY + event.clientY - this.drag.y447 this.updateTransform()448 }449450 endPan(event) {451 if (!this.drag || event.pointerId !== this.drag.pointerId) return452 this.drag = null453 delete this.stage.dataset.dragging454 }455456 moveFromMinimap(event) {457 if (!this.image.naturalWidth || !this.image.naturalHeight) return458 const bounds = this.minimap.getBoundingClientRect()459 const imageFit = Math.min(bounds.width / this.image.naturalWidth, bounds.height / this.image.naturalHeight)460 const offsetX = (bounds.width - this.image.naturalWidth * imageFit) / 2461 const offsetY = (bounds.height - this.image.naturalHeight * imageFit) / 2462 const diagramX = (event.clientX - bounds.left - offsetX) / imageFit463 const diagramY = (event.clientY - bounds.top - offsetY) / imageFit464 const stageBounds = this.stage.getBoundingClientRect()465 this.x = stageBounds.width / 2 - diagramX * this.scale466 this.y = stageBounds.height / 2 - diagramY * this.scale467 this.updateTransform()468 }469470 updateMinimap() {471 if (!this.image.naturalWidth || !this.image.naturalHeight) return472 const stageBounds = this.stage.getBoundingClientRect()473 const renderedWidth = this.image.naturalWidth * this.scale474 const renderedHeight = this.image.naturalHeight * this.scale475 const fullyVisible = this.x >= 0476 && this.y >= 0477 && this.x + renderedWidth <= stageBounds.width478 && this.y + renderedHeight <= stageBounds.height479 this.minimap.hidden = fullyVisible480 if (fullyVisible) return481482 const minimapBounds = this.minimap.getBoundingClientRect()483 const imageFit = Math.min(484 minimapBounds.width / this.image.naturalWidth,485 minimapBounds.height / this.image.naturalHeight,486 )487 const offsetX = (minimapBounds.width - this.image.naturalWidth * imageFit) / 2488 const offsetY = (minimapBounds.height - this.image.naturalHeight * imageFit) / 2489 const visibleLeft = clamp(-this.x / this.scale, 0, this.image.naturalWidth)490 const visibleTop = clamp(-this.y / this.scale, 0, this.image.naturalHeight)491 const visibleRight = clamp((stageBounds.width - this.x) / this.scale, 0, this.image.naturalWidth)492 const visibleBottom = clamp((stageBounds.height - this.y) / this.scale, 0, this.image.naturalHeight)493494 this.minimapViewport.style.left = `${offsetX + visibleLeft * imageFit}px`495 this.minimapViewport.style.top = `${offsetY + visibleTop * imageFit}px`496 this.minimapViewport.style.width = `${Math.max(4, (visibleRight - visibleLeft) * imageFit)}px`497 this.minimapViewport.style.height = `${Math.max(4, (visibleBottom - visibleTop) * imageFit)}px`498 }499}