diff --git a/bun.lock b/bun.lock index a5be0522d..d8ae52c76 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "plannotator", @@ -53,7 +54,7 @@ }, "apps/opencode-plugin": { "name": "@plannotator/opencode", - "version": "0.11.2", + "version": "0.11.4", "dependencies": { "@opencode-ai/plugin": "^1.1.10", }, @@ -74,7 +75,7 @@ }, "apps/pi-extension": { "name": "@plannotator/pi-extension", - "version": "0.11.2", + "version": "0.11.4", "peerDependencies": { "@mariozechner/pi-coding-agent": ">=0.53.0", }, @@ -154,7 +155,7 @@ }, "packages/server": { "name": "@plannotator/server", - "version": "0.11.2", + "version": "0.11.4", "dependencies": { "@plannotator/shared": "workspace:*", }, @@ -172,6 +173,7 @@ "dependencies": { "@plannotator/shared": "workspace:*", "@plannotator/web-highlighter": "^0.8.1", + "@viz-js/viz": "^3.25.0", "diff": "^8.0.3", "highlight.js": "^11.11.1", "mermaid": "^11.12.2", @@ -977,6 +979,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="], + "@viz-js/viz": ["@viz-js/viz@3.25.0", "", {}, "sha512-dM7zAYMdf7mcRz5Kdb+YJb6+qv5Rjk0rPZ18gROdpMrP/3S7RFOp8uxybeiz5RypHrE1zo1vccA8Twh4mIcLZw=="], + "@vscode/vsce": ["@vscode/vsce@3.7.1", "", { "dependencies": { "@azure/identity": "^4.1.0", "@secretlint/node": "^10.1.2", "@secretlint/secretlint-formatter-sarif": "^10.1.2", "@secretlint/secretlint-rule-no-dotenv": "^10.1.2", "@secretlint/secretlint-rule-preset-recommend": "^10.1.2", "@vscode/vsce-sign": "^2.0.0", "azure-devops-node-api": "^12.5.0", "chalk": "^4.1.2", "cheerio": "^1.0.0-rc.9", "cockatiel": "^3.1.2", "commander": "^12.1.0", "form-data": "^4.0.0", "glob": "^11.0.0", "hosted-git-info": "^4.0.2", "jsonc-parser": "^3.2.0", "leven": "^3.1.0", "markdown-it": "^14.1.0", "mime": "^1.3.4", "minimatch": "^3.0.3", "parse-semver": "^1.1.1", "read": "^1.0.7", "secretlint": "^10.1.2", "semver": "^7.5.2", "tmp": "^0.2.3", "typed-rest-client": "^1.8.4", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^2.3.1", "yazl": "^2.2.2" }, "optionalDependencies": { "keytar": "^7.7.0" }, "bin": { "vsce": "vsce" } }, "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g=="], "@vscode/vsce-sign": ["@vscode/vsce-sign@2.0.9", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g=="], diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 676a07632..ca64a4858 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -76,6 +76,26 @@ flowchart LR WS <--> WSS \`\`\` +### Service Dependencies (Graphviz) + +\`\`\`graphviz +digraph CollaborationStack { + rankdir=LR; + node [shape=box, style="rounded"]; + + Browser [label="Client Browser"]; + API [label="WebSocket API"]; + OT [label="OT Engine"]; + Redis [label="Presence Cache"]; + Postgres [label="PostgreSQL"]; + + Browser -> API; + API -> OT; + OT -> Redis; + OT -> Postgres; +} +\`\`\` + ## Phase 1: Infrastructure ### WebSocket Server diff --git a/packages/ui/components/GraphvizBlock.tsx b/packages/ui/components/GraphvizBlock.tsx new file mode 100644 index 000000000..c1c30450b --- /dev/null +++ b/packages/ui/components/GraphvizBlock.tsx @@ -0,0 +1,498 @@ +import React, { useRef, useState, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { instance } from '@viz-js/viz'; +import type { Block } from '../types'; + +interface ViewBox { + x: number; + y: number; + width: number; + height: number; +} + +const ZOOM_STEP = 0.25; +const MIN_ZOOM = 0.25; +const MAX_ZOOM = 8; + +let vizInstancePromise: ReturnType | null = null; + +function getVizInstance() { + vizInstancePromise ??= instance(); + return vizInstancePromise; +} + +function parseViewBox(svgEl: SVGSVGElement): ViewBox | null { + const raw = svgEl.getAttribute('viewBox'); + if (!raw) return null; + + const values = raw + .trim() + .split(/[\s,]+/) + .map((value) => Number.parseFloat(value)); + + if (values.length !== 4 || values.some((value) => Number.isNaN(value))) { + return null; + } + + const [x, y, width, height] = values; + if (width <= 0 || height <= 0) return null; + return { x, y, width, height }; +} + +function parseViewBoxFromMarkup(markup: string): ViewBox | null { + const viewBoxMatch = markup.match(/viewBox\s*=\s*"([^"]+)"/i); + if (viewBoxMatch?.[1]) { + const values = viewBoxMatch[1] + .trim() + .split(/[\s,]+/) + .map((value) => Number.parseFloat(value)); + + if (values.length === 4 && values.every((value) => Number.isFinite(value))) { + const [x, y, width, height] = values; + if (width > 0 && height > 0) { + return { x, y, width, height }; + } + } + } + + const widthMatch = markup.match(/\bwidth\s*=\s*"([0-9.]+)(?:px|pt)?"/i); + const heightMatch = markup.match(/\bheight\s*=\s*"([0-9.]+)(?:px|pt)?"/i); + const width = widthMatch?.[1] ? Number.parseFloat(widthMatch[1]) : NaN; + const height = heightMatch?.[1] ? Number.parseFloat(heightMatch[1]) : NaN; + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + return { x: 0, y: 0, width, height }; + } + + return null; +} + +function applyView(svgEl: SVGSVGElement, base: ViewBox, zoom: number, pan: { x: number; y: number }): void { + const zoomedWidth = base.width / zoom; + const zoomedHeight = base.height / zoom; + const centerX = base.x + base.width / 2; + const centerY = base.y + base.height / 2; + const vbX = centerX - zoomedWidth / 2 + pan.x; + const vbY = centerY - zoomedHeight / 2 + pan.y; + svgEl.setAttribute('viewBox', `${vbX} ${vbY} ${zoomedWidth} ${zoomedHeight}`); +} + +function fitBoundsToContainer(bounds: ViewBox, containerRect: DOMRect): ViewBox { + const containerWidth = Math.max(containerRect.width, 1); + const containerHeight = Math.max(containerRect.height, 1); + const contentRatio = bounds.width / bounds.height; + const containerRatio = containerWidth / containerHeight; + + if (contentRatio > containerRatio) { + const targetHeight = bounds.width / containerRatio; + const extra = (targetHeight - bounds.height) / 2; + return { + x: bounds.x, + y: bounds.y - extra, + width: bounds.width, + height: targetHeight, + }; + } + + const targetWidth = bounds.height * containerRatio; + const extra = (targetWidth - bounds.width) / 2; + return { + x: bounds.x - extra, + y: bounds.y, + width: targetWidth, + height: bounds.height, + }; +} + +export const GraphvizBlock: React.FC<{ block: Block }> = ({ block }) => { + const containerRef = useRef(null); + const [svg, setSvg] = useState(''); + const [error, setError] = useState(null); + const [showSource, setShowSource] = useState(true); + const [isExpanded, setIsExpanded] = useState(false); + + const zoomLevelRef = useRef(1); + const isDraggingRef = useRef(false); + const naturalBoundsRef = useRef(null); + const baseViewBoxRef = useRef(null); + const panOffsetRef = useRef({ x: 0, y: 0 }); + const dragStartRef = useRef({ x: 0, y: 0 }); + const panStartRef = useRef({ x: 0, y: 0 }); + + const zoomInBtnRef = useRef(null); + const zoomOutBtnRef = useRef(null); + const zoomDisplayRef = useRef(null); + + const updateZoom = useCallback((newZoom: number) => { + zoomLevelRef.current = newZoom; + + if (containerRef.current && baseViewBoxRef.current) { + const svgEl = containerRef.current.querySelector('svg'); + if (svgEl instanceof SVGSVGElement) { + applyView(svgEl, baseViewBoxRef.current, newZoom, panOffsetRef.current); + } + } + + if (zoomInBtnRef.current) zoomInBtnRef.current.disabled = newZoom >= MAX_ZOOM; + if (zoomOutBtnRef.current) zoomOutBtnRef.current.disabled = newZoom <= MIN_ZOOM; + if (zoomDisplayRef.current) { + const show = Math.abs(newZoom - 1) > 0.001; + zoomDisplayRef.current.textContent = show ? `${Math.round(newZoom * 100)}%` : ''; + zoomDisplayRef.current.hidden = !show; + } + }, []); + + const fitToCurrentViewport = useCallback(() => { + if (!containerRef.current || !naturalBoundsRef.current) return; + + const svgEl = containerRef.current.querySelector('svg'); + if (!(svgEl instanceof SVGSVGElement)) return; + + const fitted = fitBoundsToContainer(naturalBoundsRef.current, containerRef.current.getBoundingClientRect()); + baseViewBoxRef.current = fitted; + panOffsetRef.current = { x: 0, y: 0 }; + updateZoom(1); + applyView(svgEl, fitted, 1, { x: 0, y: 0 }); + }, [updateZoom]); + + useEffect(() => { + let cancelled = false; + + const renderDiagram = async () => { + try { + const viz = await getVizInstance(); + const renderedSvg = await viz.renderString(block.content, { format: 'svg' }); + const cleaned = renderedSvg + .replace(/ width="[^"]*"/, ' width="100%"') + .replace(/ height="[^"]*"/, ' height="100%"') + .replace(/ style="[^"]*"/, ''); + if (!cancelled) { + naturalBoundsRef.current = parseViewBoxFromMarkup(cleaned); + setSvg(cleaned); + setError(null); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to render diagram'); + setSvg(''); + } + } + }; + + renderDiagram(); + + return () => { + cancelled = true; + }; + }, [block.content]); + + useEffect(() => { + zoomLevelRef.current = 1; + naturalBoundsRef.current = null; + baseViewBoxRef.current = null; + panOffsetRef.current = { x: 0, y: 0 }; + setIsExpanded(false); + }, [block.content]); + + useEffect(() => { + if (showSource) { + setIsExpanded(false); + return; + } + + zoomLevelRef.current = 1; + panOffsetRef.current = { x: 0, y: 0 }; + baseViewBoxRef.current = null; + }, [showSource]); + + useEffect(() => { + if (!isExpanded) return undefined; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsExpanded(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + document.body.style.overflow = previousOverflow; + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isExpanded]); + + useEffect(() => { + if (!svg || showSource || !containerRef.current) return; + + const svgEl = containerRef.current.querySelector('svg'); + if (!(svgEl instanceof SVGSVGElement)) return; + + svgEl.style.maxWidth = 'none'; + svgEl.style.width = '100%'; + svgEl.style.height = '100%'; + svgEl.style.display = 'block'; + svgEl.style.filter = 'none'; + svgEl.style.willChange = 'auto'; + svgEl.setAttribute('width', '100%'); + svgEl.setAttribute('height', '100%'); + svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet'); + + let cancelled = false; + + const applyInitialView = () => { + if (cancelled) return; + + try { + const base = naturalBoundsRef.current ?? parseViewBox(svgEl); + + if (!base) return; + + naturalBoundsRef.current = base; + fitToCurrentViewport(); + } catch { + setError('Failed to measure diagram bounds'); + setSvg(''); + } + }; + + const raf = requestAnimationFrame(() => requestAnimationFrame(applyInitialView)); + const timer = window.setTimeout(applyInitialView, 120); + + return () => { + cancelled = true; + cancelAnimationFrame(raf); + window.clearTimeout(timer); + }; + }, [fitToCurrentViewport, isExpanded, showSource, svg]); + + useEffect(() => { + if (showSource || !containerRef.current) return; + + const container = containerRef.current; + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; + const newZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevelRef.current + delta)); + updateZoom(newZoom); + }; + + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + }, [showSource, isExpanded, updateZoom]); + + const handleZoomIn = useCallback(() => { + updateZoom(Math.min(zoomLevelRef.current + ZOOM_STEP, MAX_ZOOM)); + }, [updateZoom]); + + const handleZoomOut = useCallback(() => { + updateZoom(Math.max(zoomLevelRef.current - ZOOM_STEP, MIN_ZOOM)); + }, [updateZoom]); + + const handleFitToScreen = useCallback(() => { + fitToCurrentViewport(); + }, [fitToCurrentViewport]); + + useEffect(() => { + if (showSource || !containerRef.current || !naturalBoundsRef.current) return; + if (typeof ResizeObserver === 'undefined') return; + + const observer = new ResizeObserver(() => { + if (Math.abs(zoomLevelRef.current - 1) > 0.001) return; + fitToCurrentViewport(); + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [fitToCurrentViewport, isExpanded, showSource, svg]); + + const handleMouseDown = useCallback((event: React.MouseEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + isDraggingRef.current = true; + dragStartRef.current = { x: event.clientX, y: event.clientY }; + panStartRef.current = { ...panOffsetRef.current }; + if (containerRef.current) containerRef.current.style.cursor = 'grabbing'; + }, []); + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + if (!isDraggingRef.current || !containerRef.current || !baseViewBoxRef.current) return; + + const svgEl = containerRef.current.querySelector('svg'); + if (!(svgEl instanceof SVGSVGElement)) return; + + const rect = svgEl.getBoundingClientRect(); + const base = baseViewBoxRef.current; + const zoom = zoomLevelRef.current; + const scaleX = (base.width / zoom) / rect.width; + const scaleY = (base.height / zoom) / rect.height; + + const dx = event.clientX - dragStartRef.current.x; + const dy = event.clientY - dragStartRef.current.y; + + panOffsetRef.current = { + x: panStartRef.current.x - dx * scaleX, + y: panStartRef.current.y - dy * scaleY, + }; + + applyView(svgEl, base, zoom, panOffsetRef.current); + }, []); + + const stopDragging = useCallback(() => { + if (!isDraggingRef.current) return; + isDraggingRef.current = false; + if (containerRef.current) containerRef.current.style.cursor = 'grab'; + }, []); + + if (error) { + return ( +
+
+ + + + Graphviz Error +
+
{error}
+
+          {block.content}
+        
+
+ ); + } + + const controls = ( +
+ + + {!showSource && svg && ( + <> +
+ + + + + + + +
+ +
+ ); + + const inlineSource = ( +
+      {block.content}
+    
+ ); + + const diagramBody = ( +
+ ); + + return ( + <> +
+ {!isExpanded && controls} + {showSource || !svg ? inlineSource : !isExpanded ? diagramBody :
} +
+ + {!showSource && svg && isExpanded && typeof document !== 'undefined' && createPortal( +
+
+
+ Graphviz diagram + +
+
+ {controls} + {diagramBody} +
+
+
, + document.body + )} + + ); +}; diff --git a/packages/ui/components/Viewer.tsx b/packages/ui/components/Viewer.tsx index 1de281c8f..1eef660c3 100644 --- a/packages/ui/components/Viewer.tsx +++ b/packages/ui/components/Viewer.tsx @@ -8,7 +8,9 @@ import { AnnotationToolbar } from './AnnotationToolbar'; import { CommentPopover } from './CommentPopover'; import { TaterSpriteSitting } from './TaterSpriteSitting'; import { AttachmentsButton } from './AttachmentsButton'; +import { GraphvizBlock } from './GraphvizBlock'; import { MermaidBlock } from './MermaidBlock'; +import { isGraphvizLanguage, isMermaidLanguage } from './diagramLanguages'; import { getIdentity } from '../utils/identity'; import { PlanDiffBadge } from './plan-diff/PlanDiffBadge'; import { PinpointOverlay } from './PinpointOverlay'; @@ -893,8 +895,10 @@ export const Viewer = forwardRef(({ ))}
- ) : group.block.type === 'code' && group.block.language === 'mermaid' ? ( + ) : group.block.type === 'code' && isMermaidLanguage(group.block.language) ? ( + ) : group.block.type === 'code' && isGraphvizLanguage(group.block.language) ? ( + ) : group.block.type === 'code' ? ( { + test('matches mermaid fences case-insensitively', () => { + expect(isMermaidLanguage('mermaid')).toBe(true); + expect(isMermaidLanguage('Mermaid')).toBe(true); + expect(isMermaidLanguage('mermaid align=center')).toBe(true); + expect(isMermaidLanguage('graphviz')).toBe(false); + }); + + test('matches supported graphviz aliases case-insensitively', () => { + expect(isGraphvizLanguage('graphviz')).toBe(true); + expect(isGraphvizLanguage('dot')).toBe(true); + expect(isGraphvizLanguage('GV')).toBe(true); + expect(isGraphvizLanguage('dot rankdir=LR')).toBe(true); + expect(isGraphvizLanguage('mermaid')).toBe(false); + expect(isGraphvizLanguage(undefined)).toBe(false); + }); + + test('renders a simple dot graph to svg', async () => { + const viz = await instance(); + const svg = await viz.renderString('digraph { Plan -> Review }', { format: 'svg' }); + + expect(svg).toContain('