@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'preact/hooks';
22import Header from './components/Header' ;
33import OptionsDisplay from './components/OptionsDisplay' ;
44import WebFont from 'webfontloader' ;
5+ import Modal from './components/Modal' ;
6+ import { Download } from 'lucide-preact' ;
57
68export type TextAlign = 'left' | 'center' | 'right' ;
79export type TextBaseLine = 'top' | 'middle' | 'bottom' ;
@@ -56,6 +58,7 @@ export default function App() {
5658 } ;
5759
5860 const canvasRef = useRef < HTMLCanvasElement > ( null ) ;
61+ const [ downloadModalOpen , setDownloadModalOpen ] = useState ( false ) ;
5962 const [ title , setTitle ] = useState ( 'Movies' ) ;
6063 const [ image , setImage ] = useState < HTMLImageElement | null > ( null ) ;
6164 const [ textSize , setTextSize ] = useState ( DEFAULT_TEXT_SIZE ) ;
@@ -98,6 +101,36 @@ export default function App() {
98101 return canvasSizes [ imageType ] . defaultFontSize ;
99102 } ;
100103
104+ const [ downloadWidth , setDownloadWidth ] = useState ( getCanvasWidth ( ) ) ;
105+ const [ downloadHeight , setDownloadHeight ] = useState ( getCanvasHeight ( ) ) ;
106+ const [ downloadScale , setDownloadScale ] = useState ( 1 ) ;
107+
108+ const syncFromWidth = ( w : number ) => {
109+ const baseW = getCanvasWidth ( ) ;
110+ const baseH = getCanvasHeight ( ) ;
111+ const aspect = baseW / baseH ;
112+
113+ const width = Math . max ( 1 , Math . round ( w || 1 ) ) ;
114+ const height = Math . round ( width / aspect ) ;
115+
116+ setDownloadWidth ( width ) ;
117+ setDownloadHeight ( height ) ;
118+ setDownloadScale ( width / baseW ) ;
119+ } ;
120+
121+ const syncFromHeight = ( h : number ) => {
122+ const baseW = getCanvasWidth ( ) ;
123+ const baseH = getCanvasHeight ( ) ;
124+ const aspect = baseW / baseH ;
125+
126+ const height = Math . max ( 1 , Math . round ( h || 1 ) ) ;
127+ const width = Math . round ( height * aspect ) ;
128+
129+ setDownloadHeight ( height ) ;
130+ setDownloadWidth ( width ) ;
131+ setDownloadScale ( height / baseH ) ;
132+ } ;
133+
101134 const handleImageUpload = ( e : Event ) => {
102135 const input = e . target as HTMLInputElement ;
103136 const file = input . files ?. [ 0 ] ;
@@ -161,21 +194,27 @@ export default function App() {
161194 }
162195 } ;
163196
164- const drawCanvas = ( img : HTMLImageElement , titleText : string ) => {
165- console . log ( 'Custom Aspect Ratio:' , customAspectRatioWidth , ':' , customAspectRatioHeight ) ;
166- console . log ( 'Canvas Size:' , getCanvasWidth ( ) , 'x' , getCanvasHeight ( ) ) ;
167-
168- const canvas = canvasRef . current ;
197+ const renderCanvas = async (
198+ canvas : HTMLCanvasElement | null ,
199+ img : HTMLImageElement ,
200+ titleText : string ,
201+ scale : number = 1
202+ ) => {
169203 if ( ! canvas ) return ;
170204 const ctx = canvas . getContext ( '2d' ) ;
171205 if ( ! ctx ) return ;
172206
173- canvas . width = getCanvasWidth ( ) ;
174- canvas . height = getCanvasHeight ( ) ;
207+ const width = getCanvasWidth ( ) * scale ;
208+ const height = getCanvasHeight ( ) * scale ;
209+
210+ canvas . width = width ;
211+ canvas . height = height ;
175212
176213 const canvasWidth = getCanvasWidth ( ) ;
177214 const canvasHeight = getCanvasHeight ( ) ;
178215
216+ ctx . scale ( scale , scale ) ;
217+
179218 const imgAspect = img . width / img . height ;
180219 const canvasAspect = canvasWidth / canvasHeight ;
181220
@@ -209,20 +248,26 @@ export default function App() {
209248 ctx . fillStyle = `rgba(${ r } , ${ g } , ${ b } , ${ bgDim } )` ;
210249 ctx . fillRect ( 0 , 0 , getCanvasWidth ( ) , getCanvasHeight ( ) ) ;
211250
212- document . fonts . ready . then ( ( ) => {
213- ctx . font = `bold ${ textSize } px "${ fontName } ", sans-serif` ;
214- ctx . fillStyle = textColor ;
215- ctx . textAlign = textAlign ;
216- ctx . textBaseline = textBaseline ;
217- drawWrappedText (
218- ctx ,
219- titleText ,
220- getCanvasWidth ( ) * 0.9 ,
221- textSize * 1.2 ,
222- textAlign ,
223- textBaseline
224- ) ;
225- } ) ;
251+ // make sure font is loaded
252+ try {
253+ await document . fonts . load ( `700 ${ textSize } px "${ fontName } "` ) ;
254+ await document . fonts . ready ; // extra safety for all faces
255+ } catch ( _ ) {
256+ // ignore
257+ }
258+
259+ ctx . font = `bold ${ textSize } px "${ fontName } ", sans-serif` ;
260+ ctx . fillStyle = textColor ;
261+ ctx . textAlign = textAlign ;
262+ ctx . textBaseline = textBaseline ;
263+ drawWrappedText (
264+ ctx ,
265+ titleText ,
266+ getCanvasWidth ( ) * 0.9 ,
267+ textSize * 1.2 ,
268+ textAlign ,
269+ textBaseline
270+ ) ;
226271 } ;
227272
228273 const formatTitleForFileName = ( title : string ) => {
@@ -232,19 +277,19 @@ export default function App() {
232277 . toLowerCase ( ) ;
233278 } ;
234279
235- const downloadImage = ( ) => {
236- const canvas = canvasRef . current ;
237- if ( ! canvas ) return ;
238-
280+ const downloadImage = async ( ) => {
281+ if ( ! image ) return ;
282+ const exportCanvas = document . createElement ( ' canvas' ) ;
283+ await renderCanvas ( exportCanvas , image , title , downloadScale ) ;
239284 const link = document . createElement ( 'a' ) ;
240285 link . download = `jellyfin-cover-${ formatTitleForFileName ( title ) } .png` ;
241- link . href = canvas . toDataURL ( 'image/png' ) ;
286+ link . href = exportCanvas . toDataURL ( 'image/png' ) ;
242287 link . click ( ) ;
243288 } ;
244289
245290 useEffect ( ( ) => {
246291 if ( image && canvasRef . current ) {
247- drawCanvas ( image , title ) ;
292+ renderCanvas ( canvasRef . current , image , title ) ;
248293 }
249294 } , [
250295 image ,
@@ -270,7 +315,7 @@ export default function App() {
270315 } ,
271316 active : ( ) => {
272317 if ( image ) {
273- drawCanvas ( image , title ) ;
318+ renderCanvas ( canvasRef . current , image , title ) ;
274319 }
275320 } ,
276321 } ) ;
@@ -284,6 +329,12 @@ export default function App() {
284329 defaultImg . src = '/default-bg.webp' ;
285330 } , [ ] ) ;
286331
332+ useEffect ( ( ) => {
333+ setDownloadScale ( 1 ) ;
334+ setDownloadWidth ( Math . round ( getCanvasWidth ( ) ) ) ;
335+ setDownloadHeight ( Math . round ( getCanvasHeight ( ) ) ) ;
336+ } , [ imageType , customAspectRatioWidth , customAspectRatioHeight ] ) ;
337+
287338 const handleImageTypeChange = ( type : 'cover' | 'poster' | 'custom' ) => {
288339 setImageType ( type ) ;
289340 setTextSize (
@@ -316,7 +367,7 @@ export default function App() {
316367 bgDim = { bgDim }
317368 setBgDim = { setBgDim }
318369 defaultBgDim = { DEFAULT_BG_DIM }
319- downloadImage = { downloadImage }
370+ downloadImage = { ( ) => setDownloadModalOpen ( true ) }
320371 font = { fontName }
321372 setFont = { setFontName }
322373 textColor = { textColor }
@@ -350,6 +401,82 @@ export default function App() {
350401 />
351402 </ div >
352403 </ div >
404+ < Modal isOpen = { downloadModalOpen } onClose = { ( ) => setDownloadModalOpen ( false ) } >
405+ < div >
406+ < h2 class = "text-lg font-bold mb-4" > Download Image</ h2 >
407+ < p class = "mb-4" >
408+ Select the desired width and height for the downloaded image.
409+ </ p >
410+
411+ < div class = "grid grid-cols-12 gap-4 mb-4" >
412+ < div class = "flex flex-col col-span-5" >
413+ < label for = "downloadWidth" class = "text-sm text-muted-foreground" >
414+ Width:
415+ </ label >
416+ < input
417+ type = "number"
418+ id = "downloadWidth"
419+ class = "input"
420+ value = { downloadWidth }
421+ onChange = { ( e ) => syncFromWidth ( Number ( e . currentTarget . value ) ) }
422+ />
423+ </ div >
424+ < div class = "flex flex-col col-span-5" >
425+ < label for = "downloadHeight" class = "text-sm text-muted-foreground" >
426+ Height:
427+ </ label >
428+
429+ < input
430+ type = "number"
431+ id = "downloadHeight"
432+ class = "input"
433+ value = { downloadHeight }
434+ onChange = { ( e ) => syncFromHeight ( Number ( e . currentTarget . value ) ) }
435+ />
436+ </ div >
437+ < div class = "flex flex-col col-span-2" >
438+ < label for = "downloadScale" class = "text-sm text-muted-foreground" >
439+ Scale:
440+ </ label >
441+ < input
442+ type = "number"
443+ id = "downloadScale"
444+ class = "input"
445+ value = { downloadScale }
446+ step = { 0.1 }
447+ min = { 0.1 }
448+ onChange = { ( e ) => {
449+ const scale = Math . max (
450+ 0.1 ,
451+ Number ( e . currentTarget . value ) || 0.1
452+ ) ;
453+ setDownloadScale ( scale ) ;
454+ setDownloadWidth ( Math . round ( getCanvasWidth ( ) * scale ) ) ;
455+ setDownloadHeight ( Math . round ( getCanvasHeight ( ) * scale ) ) ;
456+ } }
457+ />
458+ </ div >
459+ </ div >
460+ < div class = "flex justify-end gap-2" >
461+ < button
462+ class = "btn btn-secondary mr-2 mb-2"
463+ onClick = { ( ) => setDownloadModalOpen ( false ) }
464+ >
465+ Cancel
466+ </ button >
467+ < button
468+ class = "btn btn-primary"
469+ onClick = { ( ) => {
470+ downloadImage ( ) ;
471+ setDownloadModalOpen ( false ) ;
472+ } }
473+ >
474+ < Download />
475+ Download
476+ </ button >
477+ </ div >
478+ </ div >
479+ </ Modal >
353480 </ >
354481 ) ;
355482}
0 commit comments