Skip to content

Commit 194c041

Browse files
Added option to scale download image
1 parent 7afc517 commit 194c041

File tree

2 files changed

+220
-29
lines changed

2 files changed

+220
-29
lines changed

src/app.tsx

Lines changed: 156 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'preact/hooks';
22
import Header from './components/Header';
33
import OptionsDisplay from './components/OptionsDisplay';
44
import WebFont from 'webfontloader';
5+
import Modal from './components/Modal';
6+
import { Download } from 'lucide-preact';
57

68
export type TextAlign = 'left' | 'center' | 'right';
79
export 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
}

src/components/Modal.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { X } from 'lucide-preact';
2+
import type { FunctionalComponent } from 'preact';
3+
import { useEffect, useRef } from 'preact/hooks';
4+
5+
export interface ModalProps {
6+
isOpen: boolean;
7+
onClose: () => void;
8+
title?: string;
9+
children: React.ReactNode;
10+
}
11+
12+
const Modal: FunctionalComponent<ModalProps> = ({ isOpen, onClose, children, title }) => {
13+
const modalRef = useRef<HTMLDivElement>(null);
14+
15+
useEffect(() => {
16+
const handleKeyDown = (e: KeyboardEvent) => {
17+
if (e.key === 'Escape') {
18+
onClose();
19+
}
20+
};
21+
22+
if (isOpen) {
23+
document.addEventListener('keydown', handleKeyDown);
24+
} else {
25+
document.removeEventListener('keydown', handleKeyDown);
26+
}
27+
28+
return () => document.removeEventListener('keydown', handleKeyDown);
29+
}, [isOpen, onClose]);
30+
31+
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
32+
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
33+
onClose();
34+
}
35+
};
36+
37+
if (!isOpen) return null;
38+
39+
return (
40+
<div
41+
className="fixed inset-0 z-50 p-4 flex items-center justify-center bg-black/75"
42+
onClick={handleOverlayClick}
43+
style={{ pointerEvents: 'auto' }}
44+
>
45+
<div
46+
ref={modalRef}
47+
className="card rounded-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto custom-scrollbar"
48+
style={{ pointerEvents: 'auto' }}
49+
>
50+
{title && (
51+
<div className="flex justify-between items-center">
52+
<h2 className="text-xl font-bold mb-0">{title}</h2>
53+
<button className="btn-icon-secondary" onClick={onClose}>
54+
<X />
55+
</button>
56+
</div>
57+
)}
58+
{children}
59+
</div>
60+
</div>
61+
);
62+
};
63+
64+
export default Modal;

0 commit comments

Comments
 (0)