fix(meme-generator): match preview and server render text sizing

- Preview font size now calculated from actual container width, not viewport
- Preview stroke scales proportionally with font size (4% ratio)
- Server stroke reduced from 6% to 4% to match preview
- Added ResizeObserver to track container width for accurate sizing
- Added paint-order: stroke fill to CSS preview for consistent rendering
This commit is contained in:
SnapOtter
2026-05-08 18:17:17 +08:00
parent a157af2ea9
commit dac30f14f0
2 changed files with 30 additions and 16 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ export function renderMemeTextSvg(opts: MemeTextOptions): Buffer {
const fontSize = fixedFontSize ?? autoSizeFontToFit(text, fontFamily, bw, bh); const fontSize = fixedFontSize ?? autoSizeFontToFit(text, fontFamily, bw, bh);
const lineHeight = fontSize * LINE_HEIGHT_FACTOR; const lineHeight = fontSize * LINE_HEIGHT_FACTOR;
const lines = wrapText(text, fontFamily, fontSize, bw); const lines = wrapText(text, fontFamily, fontSize, bw);
const strokeWidth = Math.max(1, Math.round(fontSize * 0.06)); const strokeWidth = Math.max(1, Math.round(fontSize * 0.04));
const fillAttr = escapeXmlAttr(textColor); const fillAttr = escapeXmlAttr(textColor);
const strokeAttr = escapeXmlAttr(strokeColor); const strokeAttr = escapeXmlAttr(strokeColor);
@@ -8,7 +8,7 @@ import {
Search, Search,
Sparkles, Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
CATEGORIES, CATEGORIES,
@@ -35,6 +35,7 @@ function TextPreviewOverlay({
strokeColor, strokeColor,
textAlign, textAlign,
allCaps, allCaps,
containerWidth,
}: { }: {
boxes: TemplateTextBox[]; boxes: TemplateTextBox[];
textValues: TextBoxValue[]; textValues: TextBoxValue[];
@@ -44,6 +45,7 @@ function TextPreviewOverlay({
strokeColor: string; strokeColor: string;
textAlign: string; textAlign: string;
allCaps: boolean; allCaps: boolean;
containerWidth: number;
}) { }) {
const cssFontFamily = FONT_FAMILY_MAP[fontFamily] ?? FONT_FAMILY_MAP.anton; const cssFontFamily = FONT_FAMILY_MAP[fontFamily] ?? FONT_FAMILY_MAP.anton;
@@ -53,8 +55,11 @@ function TextPreviewOverlay({
const value = textValues.find((v) => v.id === box.id); const value = textValues.find((v) => v.id === box.id);
const text = value?.text || box.defaultText || ""; const text = value?.text || box.defaultText || "";
const displayText = allCaps ? text.toUpperCase() : text; const displayText = allCaps ? text.toUpperCase() : text;
const autoSize = `clamp(10px, ${box.height * 0.35}vw, 60px)`; const boxPxW = (box.width / 100) * containerWidth;
const appliedSize = fontSize > 0 ? `${fontSize}px` : autoSize; const boxPxH = (box.height / 100) * containerWidth;
const autoSize = Math.max(10, Math.min(Math.floor(boxPxW / 8), Math.floor(boxPxH / 2), 48));
const appliedSize = fontSize > 0 ? fontSize : autoSize;
const stroke = Math.max(1, Math.round(appliedSize * 0.04));
return ( return (
<div <div
@@ -74,20 +79,12 @@ function TextPreviewOverlay({
className="w-full leading-tight break-words whitespace-pre-wrap" className="w-full leading-tight break-words whitespace-pre-wrap"
style={{ style={{
fontFamily: cssFontFamily, fontFamily: cssFontFamily,
fontSize: appliedSize, fontSize: `${appliedSize}px`,
color: textColor, color: textColor,
textAlign: textAlign as "left" | "center" | "right", textAlign: textAlign as "left" | "center" | "right",
WebkitTextStroke: `1.5px ${strokeColor}`, WebkitTextStroke: `${stroke}px ${strokeColor}`,
textShadow: [ paintOrder: "stroke fill",
`1px 1px 0 ${strokeColor}`, textShadow: `1px 1px 2px rgba(0,0,0,0.5)`,
`-1px -1px 0 ${strokeColor}`,
`1px -1px 0 ${strokeColor}`,
`-1px 1px 0 ${strokeColor}`,
`0 1px 0 ${strokeColor}`,
`0 -1px 0 ${strokeColor}`,
`1px 0 0 ${strokeColor}`,
`-1px 0 0 ${strokeColor}`,
].join(", "),
}} }}
> >
{displayText} {displayText}
@@ -334,6 +331,21 @@ function EditorPreview() {
const textAlign = useMemeStore((s) => s.textAlign); const textAlign = useMemeStore((s) => s.textAlign);
const allCaps = useMemeStore((s) => s.allCaps); const allCaps = useMemeStore((s) => s.allCaps);
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(600);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const imageSrc = selectedTemplate const imageSrc = selectedTemplate
? `/api/v1/meme-templates/full/${selectedTemplate.filename}` ? `/api/v1/meme-templates/full/${selectedTemplate.filename}`
: (customImageUrl ?? ""); : (customImageUrl ?? "");
@@ -346,6 +358,7 @@ function EditorPreview() {
<div className="h-full flex items-center justify-center p-4 overflow-auto"> <div className="h-full flex items-center justify-center p-4 overflow-auto">
<div className="max-w-2xl w-full"> <div className="max-w-2xl w-full">
<div <div
ref={containerRef}
data-testid="meme-editor-preview" data-testid="meme-editor-preview"
className="relative w-full rounded-lg overflow-hidden border border-border bg-muted/30" className="relative w-full rounded-lg overflow-hidden border border-border bg-muted/30"
> >
@@ -359,6 +372,7 @@ function EditorPreview() {
strokeColor={strokeColor} strokeColor={strokeColor}
textAlign={textAlign} textAlign={textAlign}
allCaps={allCaps} allCaps={allCaps}
containerWidth={containerWidth}
/> />
</div> </div>
</div> </div>