33
44import type { ExportFormat } from "./types" ;
55
6- import { toJpeg , toPng , toSvg } from "html-to-image" ;
6+ import { toJpeg , toPng } from "html-to-image" ;
77import { getDefaultStore } from "jotai" ;
88import { nodesByIdAtom } from "@/lib/graph" ;
99
@@ -65,272 +65,6 @@ function findTransformedElement(root: HTMLElement): HTMLElement | null {
6565 return null ;
6666}
6767
68- /**
69- * Snapshot the computed style of a blank detached <div> to learn
70- * every default property value.
71- */
72- function computeCssDefaults ( ) : Map < string , string > {
73- const el = document . createElement ( "div" ) ;
74- document . body . appendChild ( el ) ;
75- const cs = getComputedStyle ( el ) ;
76- const defaults = new Map < string , string > ( ) ;
77- for ( let i = 0 ; i < cs . length ; i ++ ) {
78- const prop = cs [ i ] ! ;
79- defaults . set ( prop , cs . getPropertyValue ( prop ) ) ;
80- }
81- document . body . removeChild ( el ) ;
82- return defaults ;
83- }
84-
85- /**
86- * Properties that never affect the visual output of a static SVG export.
87- */
88- const NON_VISUAL_PROPS = new Set ( [
89- "cursor" ,
90- "caret-color" ,
91- "pointer-events" ,
92- "user-select" ,
93- "-webkit-user-select" ,
94- "touch-action" ,
95- "resize" ,
96- "outline" ,
97- "outline-color" ,
98- "outline-offset" ,
99- "outline-style" ,
100- "outline-width" ,
101- "orphans" ,
102- "widows" ,
103- "page" ,
104- "page-break-after" ,
105- "page-break-before" ,
106- "page-break-inside" ,
107- "break-after" ,
108- "break-before" ,
109- "break-inside" ,
110- "accent-color" ,
111- "appearance" ,
112- "backface-visibility" ,
113- "buffered-rendering" ,
114- "contain" ,
115- "container" ,
116- "container-name" ,
117- "container-type" ,
118- "content-visibility" ,
119- "forced-color-adjust" ,
120- "image-orientation" ,
121- "image-rendering" ,
122- "interpolate-size" ,
123- "isolation" ,
124- "math-depth" ,
125- "math-shift" ,
126- "math-style" ,
127- "mix-blend-mode" ,
128- "object-fit" ,
129- "object-position" ,
130- "object-view-box" ,
131- "perspective" ,
132- "perspective-origin" ,
133- "print-color-adjust" ,
134- "ruby-align" ,
135- "ruby-position" ,
136- "shape-image-threshold" ,
137- "shape-margin" ,
138- "shape-outside" ,
139- "speak" ,
140- "table-layout" ,
141- "text-combine-upright" ,
142- "text-orientation" ,
143- "text-size-adjust" ,
144- "timeline-scope" ,
145- "unicode-bidi" ,
146- "will-change" ,
147- "writing-mode" ,
148- "counter-increment" ,
149- "counter-reset" ,
150- "counter-set" ,
151- "content" ,
152- ] ) ;
153-
154- /** Prefix families that are entirely non-visual for a static export. */
155- const NON_VISUAL_PREFIXES = [
156- "animation" ,
157- "transition" ,
158- "scroll-" ,
159- "scrollbar-" ,
160- "overscroll-" ,
161- "contain-intrinsic-" ,
162- "view-transition-" ,
163- "view-timeline-" ,
164- "scroll-timeline-" ,
165- "anchor-" ,
166- "app-region" ,
167- ] ;
168-
169- function isNonVisualProperty ( prop : string ) : boolean {
170- if ( prop . startsWith ( "--" ) ) return true ;
171- if ( NON_VISUAL_PROPS . has ( prop ) ) return true ;
172- return NON_VISUAL_PREFIXES . some ( ( pfx ) => prop . startsWith ( pfx ) ) ;
173- }
174-
175- /**
176- * Inheritable properties must be preserved even when they match the
177- * browser default, because a standalone SVG has no parent to inherit from.
178- */
179- const INHERITABLE_PROPS = new Set ( [
180- "color" ,
181- "direction" ,
182- "font" ,
183- "font-family" ,
184- "font-size" ,
185- "font-style" ,
186- "font-variant" ,
187- "font-weight" ,
188- "font-stretch" ,
189- "font-size-adjust" ,
190- "letter-spacing" ,
191- "line-height" ,
192- "text-align" ,
193- "text-indent" ,
194- "text-transform" ,
195- "white-space-collapse" ,
196- "word-spacing" ,
197- "word-break" ,
198- "visibility" ,
199- "cursor" ,
200- "-webkit-text-fill-color" ,
201- "-webkit-text-stroke" ,
202- "fill" ,
203- "fill-opacity" ,
204- "fill-rule" ,
205- "stroke" ,
206- "stroke-dasharray" ,
207- "stroke-dashoffset" ,
208- "stroke-linecap" ,
209- "stroke-linejoin" ,
210- "stroke-miterlimit" ,
211- "stroke-opacity" ,
212- "stroke-width" ,
213- ] ) ;
214-
215- /**
216- * Remove custom properties, known non-visual declarations,
217- * and declarations whose values match the browser default.
218- */
219- function stripBloatedDeclarations ( style : string , cssDefaults : Map < string , string > ) : string {
220- return style
221- . split ( ";" )
222- . filter ( ( decl ) => {
223- const trimmed = decl . trim ( ) ;
224- if ( ! trimmed ) return false ;
225- const colonIdx = trimmed . indexOf ( ":" ) ;
226- if ( colonIdx === - 1 ) return false ;
227- const prop = trimmed . substring ( 0 , colonIdx ) . trim ( ) . toLowerCase ( ) ;
228- if ( isNonVisualProperty ( prop ) ) return false ;
229- if ( INHERITABLE_PROPS . has ( prop ) ) return true ;
230- const val = trimmed . substring ( colonIdx + 1 ) . trim ( ) ;
231- const defaultVal = cssDefaults . get ( prop ) ;
232- if ( defaultVal !== undefined && val === defaultVal ) return false ;
233- return true ;
234- } )
235- . map ( ( d ) => d . trim ( ) )
236- . join ( "; " ) ;
237- }
238-
239- /**
240- * Strip bloat from the SVG data URL produced by html-to-image.
241- */
242- function cleanSvgDataUrl ( dataUrl : string , cssDefaults : Map < string , string > ) : string {
243- const prefix = "data:image/svg+xml;charset=utf-8," ;
244- if ( ! dataUrl . startsWith ( prefix ) ) return dataUrl ;
245-
246- try {
247- const svgText = decodeURIComponent ( dataUrl . slice ( prefix . length ) ) ;
248- const doc = new DOMParser ( ) . parseFromString ( svgText , "image/svg+xml" ) ;
249-
250- if ( doc . querySelector ( "parsererror" ) ) {
251- console . warn ( "SVG parse error, returning original" ) ;
252- return dataUrl ;
253- }
254-
255- const root = doc . documentElement ;
256- const elements : Element [ ] = [ root ] ;
257- const tw = doc . createTreeWalker ( root , NodeFilter . SHOW_ELEMENT ) ;
258- let n : Node | null ;
259- while ( ( n = tw . nextNode ( ) ) ) elements . push ( n as Element ) ;
260-
261- for ( const el of elements ) {
262- if ( el . localName === "style" ) {
263- el . parentNode ?. removeChild ( el ) ;
264- continue ;
265- }
266-
267- el . removeAttribute ( "class" ) ;
268- el . removeAttribute ( "data-testid" ) ;
269-
270- const raw = el . getAttribute ( "style" ) ;
271- if ( raw ) {
272- const cleaned = stripBloatedDeclarations ( raw , cssDefaults ) ;
273- if ( cleaned ) {
274- el . setAttribute ( "style" , cleaned ) ;
275- } else {
276- el . removeAttribute ( "style" ) ;
277- }
278- }
279- }
280-
281- // --- Pass 2: deduplicate inline styles into shared CSS classes ---
282- const remaining : Element [ ] = [ ] ;
283- const tw2 = doc . createTreeWalker ( root , NodeFilter . SHOW_ELEMENT ) ;
284- let n2 : Node | null = root ;
285- while ( n2 ) {
286- remaining . push ( n2 as Element ) ;
287- n2 = tw2 . nextNode ( ) ;
288- }
289-
290- const styleCounts = new Map < string , number > ( ) ;
291- for ( const el of remaining ) {
292- const s = el . getAttribute ( "style" ) ;
293- if ( s ) styleCounts . set ( s , ( styleCounts . get ( s ) ?? 0 ) + 1 ) ;
294- }
295-
296- const styleToClass = new Map < string , string > ( ) ;
297- let classIdx = 0 ;
298- for ( const [ style , count ] of styleCounts ) {
299- if ( count >= 2 ) {
300- styleToClass . set ( style , `s${ classIdx ++ } ` ) ;
301- }
302- }
303-
304- if ( styleToClass . size > 0 ) {
305- for ( const el of remaining ) {
306- const s = el . getAttribute ( "style" ) ;
307- if ( s && styleToClass . has ( s ) ) {
308- el . removeAttribute ( "style" ) ;
309- el . setAttribute ( "class" , styleToClass . get ( s ) ! ) ;
310- }
311- }
312-
313- let css = "" ;
314- for ( const [ style , cls ] of styleToClass ) {
315- css += `.${ cls } { ${ style } }\n` ;
316- }
317-
318- const defs =
319- root . querySelector ( "defs" ) ??
320- root . insertBefore ( doc . createElementNS ( "http://www.w3.org/2000/svg" , "defs" ) , root . firstChild ) ;
321- const styleEl = doc . createElementNS ( "http://www.w3.org/2000/svg" , "style" ) ;
322- styleEl . textContent = css ;
323- defs . appendChild ( styleEl ) ;
324- }
325-
326- const out = new XMLSerializer ( ) . serializeToString ( root ) ;
327- return prefix + encodeURIComponent ( out ) ;
328- } catch ( error ) {
329- console . warn ( "SVG cleanup failed, returning original:" , error ) ;
330- return dataUrl ;
331- }
332- }
333-
33468/**
33569 * Capture the graph by cloning the canvas into an off-screen element.
33670 *
@@ -407,12 +141,7 @@ export async function captureGraphElement(
407141 } ,
408142 } ;
409143
410- // Snapshot CSS defaults while we have live DOM access.
411- const cssDefaults = computeCssDefaults ( ) ;
412-
413144 switch ( format ) {
414- case "svg" :
415- return cleanSvgDataUrl ( await toSvg ( clone , options ) , cssDefaults ) ;
416145 case "png" :
417146 return await toPng ( clone , { ...options , pixelRatio : 2 } ) ;
418147 case "jpeg" :
@@ -429,14 +158,12 @@ export async function captureGraphElement(
429158
430159/** MIME types for each export format. */
431160const FORMAT_MIME : Record < ExportFormat , string > = {
432- svg : "image/svg+xml" ,
433161 png : "image/png" ,
434162 jpeg : "image/jpeg" ,
435163} ;
436164
437165/** File extension descriptions for the Save dialog. */
438166const FORMAT_DESC : Record < ExportFormat , string > = {
439- svg : "SVG Image" ,
440167 png : "PNG Image" ,
441168 jpeg : "JPEG Image" ,
442169} ;
@@ -454,7 +181,7 @@ function dataUrlToBlob(dataUrl: string): Blob {
454181 for ( let i = 0 ; i < bytes . length ; i ++ ) buf [ i ] = bytes . charCodeAt ( i ) ;
455182 return new Blob ( [ buf ] , { type : mime } ) ;
456183 }
457- // charset=utf-8, URI-encoded (SVG path)
184+ // Fallback: URI-encoded data URL.
458185 const commaIdx = dataUrl . indexOf ( "," ) ;
459186 const meta = dataUrl . substring ( 0 , commaIdx ) ;
460187 const mime = meta . split ( ":" ) [ 1 ] ?. split ( ";" ) [ 0 ] ?? "application/octet-stream" ;
0 commit comments