完成文字阴影效果

This commit is contained in:
ymk
2025-08-19 16:56:28 +08:00
parent 3d72b3b253
commit 956f1f6c56
9 changed files with 193 additions and 30 deletions

View File

@@ -89,6 +89,11 @@
"faqQuestion1": "Why do text characters sometimes appear as question marks in the preview?",
"faqAnswer1": "Because the selected font doesn't support the language of the text. Try changing the font or uploading a custom font that supports the language."
},
"Effects": {
"title": "Effects",
"shadowOption": "Shadow",
"shadowOptionHelp": "Shadow works only with Pure Color background"
},
"Footer": {
"copyright": "© {year} Screen Designer. All rights reserved."
},

View File

@@ -89,6 +89,11 @@
"faqQuestion1": "为什么预览框内的文字内容会显示问号?",
"faqAnswer1": "因为该内容的语言没有被选中字体支持,建议更换字体或者上传自定义字体"
},
"Effects": {
"title": "特效",
"shadowOption": "阴影",
"shadowOptionHelp": "阴影只在纯色背景下有效"
},
"Footer": {
"copyright": "© {year} 3D文字设计工具 版权所有"
},

View File

@@ -7,12 +7,13 @@ import PreviewToolbar from "./common/PreviewToolbar";
import { useState } from "react";
import { useTranslations } from "next-intl";
import TextSetting, { TextProp } from "./common/TextSetting";
import Effects, { EffectProp } from "./common/Effects";
/**
* 全特性工具栏
* @returns
*/
export default function Page({ textProp, backgroundProp }: { textProp: TextProp | undefined, backgroundProp: BackgroundProp | undefined }) {
export default function Page({ textProp, backgroundProp, effectProp }: { textProp: TextProp | undefined, backgroundProp: BackgroundProp | undefined, effectProp: EffectProp | undefined }) {
const t = useTranslations("TextEditor");
@@ -22,9 +23,11 @@ export default function Page({ textProp, backgroundProp }: { textProp: TextProp
} satisfies BackgroundProp;
textProp = textProp || TextProp.default(t("defaultText"));
effectProp = effectProp || { enableShadow: true, shadowColor: "#000000" } satisfies EffectProp;
const [background, setBackground] = useState<BackgroundProp>(backgroundProp!);
const [text, setText] = useState<TextProp>(textProp!);
const [effect, setEffect] = useState<EffectProp>(effectProp);
return (
<Flex gap={"2"}>
@@ -34,10 +37,12 @@ export default function Page({ textProp, backgroundProp }: { textProp: TextProp
setBackground={setBackground}
/>
<TextSetting text={text} setText={setText} />
<Effects effect={effect} setEffect={setEffect} background={background} />
</Flex>
<Flex className="w-2/3" direction={"column"} justify={"between"}>
<PreviewToolbar background={background} text={text} />
<PreviewToolbar background={background} text={text} effect={effect} />
</Flex>
</Flex>
);

View File

@@ -83,7 +83,7 @@ export default function BackgroundSelector({
};
return (
<Box className="p-4 border rounded-lg min-w-64">
<Box className="p-4 border rounded-lg min-w-64 border-t-2 border-t-purple-500 shadow">
<Heading as="h2" size="4" className="font-medium text-lg">{t("title")}</Heading>
<Flex gap={"2"} p="2" direction={"column"}>
<Flex gap="2" align={"center"}>
@@ -95,14 +95,14 @@ export default function BackgroundSelector({
id="color-picker"
value={color || "black"}
onChange={handleColorChange}
className="w-1/3 h-10 rounded-md cursor-pointer"
className="w-1/3 h-8 rounded-md cursor-pointer"
/>
{color && (<input
type="text"
value={color}
onChange={handleColorChange}
className="w-1/2 h-10 rounded-md cursor-pointer pl-4"
className="w-1/2 h-8 rounded-md cursor-pointer pl-4"
/>)}
</Flex>
</Flex>
@@ -117,7 +117,7 @@ export default function BackgroundSelector({
accept="image/*"
onChange={handleImageUpload}
className="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:mr-4 file:py-1 file:px-2
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary file:text-primary-foreground

View File

@@ -0,0 +1,93 @@
'use client'
import { Box, Checkbox, Flex, Heading, IconButton, Tooltip } from "@radix-ui/themes";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { BackgroundProp } from "./BackgroundSelector";
import { CircleQuestionMarkIcon } from "lucide-react";
export interface EffectProp {
enableShadow: boolean;
shadowColor: string;
}
export default function EffectsPage({
effect,
setEffect,
background
}: {
effect: EffectProp;
setEffect: (e: EffectProp) => void;
background: BackgroundProp
}) {
const t = useTranslations("Effects");
// const [shadowValid, setShadowValid] = useState(true);
// useEffect(() => {
// setShadowValid(!background.image);
// }, [background])
return (
<Box className="p-4 border rounded-lg min-w-64 border-t-2 border-t-purple-500 shadow">
<Heading as="h2" size="4" className="font-medium text-lg">{t("title")}</Heading>
<Flex gap={"2"} p="2" direction={"column"}>
<Flex gap="2" align={"center"}>
<Checkbox checked={effect.enableShadow} onClick={(e) => setEffect({ ...effect, enableShadow: !effect.enableShadow })} />
{t("shadowOption")}
<input
type="color"
id="color-picker"
value={effect.shadowColor}
onChange={e => setEffect({ ...effect, shadowColor: e.target.value })}
className="w-1/3 h-8 rounded-md cursor-pointer"
/>
<input
type="text"
value={effect.shadowColor}
onChange={e => setEffect({ ...effect, shadowColor: e.target.value })}
className="w-1/3 h-8 rounded-md cursor-pointer pl-4"
/>
</Flex>
{/* <Flex gap="2" align={"center"}>
<Checkbox checked={backgroundType.includes("color")} onClick={(e) => handleBackgroundTypeChange("color")} className="cursor-pointer" />
<Heading as="h3" size={"3"}>{t("colorOption")}</Heading>
<Flex gap={"4"} >
<input
type="color"
id="color-picker"
value={color || "black"}
onChange={handleColorChange}
className="w-1/3 h-10 rounded-md cursor-pointer"
/>
{color && (<input
type="text"
value={color}
onChange={handleColorChange}
className="w-1/2 h-10 rounded-md cursor-pointer pl-4"
/>)}
</Flex>
</Flex>
<Flex gap="2" align={"center"}>
<Checkbox checked={backgroundType.includes("image")} onClick={(e) => handleBackgroundTypeChange("image")} className="cursor-pointer" />
<Heading as="h3" size={"3"}>{t("imageOption")}</Heading>
<Flex gap={"4"} >
<input
type="file"
id="file-upload"
accept="image/*"
onChange={handleImageUpload}
className="block w-full text-sm text-muted-foreground
file:mr-4 file:py-2 file:px-4
file:rounded-md file:border-0
file:text-sm file:font-semibold
file:bg-primary file:text-primary-foreground
hover:file:bg-primary/90"
/>
</Flex>
</Flex> */}
</Flex>
</Box>
);
}

View File

@@ -4,9 +4,10 @@ import { useLocale, useTranslations } from "next-intl";
import { Eye, Download, Share } from "lucide-react";
import { BackgroundProp } from "./BackgroundSelector";
import { Text, Flex, Button, Select, AlertDialog, Code, AspectRatio } from "@radix-ui/themes";
import { getPicture, resize, init as threeInit, updateBackground, updateTextProps } from "./ThreeTools";
import { getPicture, resize, init as threeInit, updateBackground, updateEffectProp, updateTextProp } from "./ThreeTools";
import { TextProp } from "./TextSetting";
import { encodeText, getShareLink } from "@/lib/utils";
import { EffectProp } from "./Effects";
const Sizes = [
"1920x1080",
@@ -27,9 +28,11 @@ const AspectRatios = Sizes.map(o => {
export default function PreviewToolbar({
background,
text,
effect
}: {
background: BackgroundProp;
text: TextProp;
effect: EffectProp
}) {
let host = process.env.NEXT_PUBLIC_HOST?.substring("https://".length);
const t = useTranslations("PreviewBar");
@@ -70,12 +73,17 @@ export default function PreviewToolbar({
useEffect(() => {
updateTextProps(text);
updateTextProp(text);
console.log("text change", text);
}, [text]);
useEffect(() => {
updateEffectProp(effect);
console.log("effect change", effect);
}, [effect]);
const generateImage = async (w: number, h: number): Promise<string> => {
return new Promise((resolve, reject) => {
@@ -126,9 +134,6 @@ export default function PreviewToolbar({
}
});
}
const handleDownload = async () => {
@@ -228,7 +233,7 @@ export default function PreviewToolbar({
}, [handleFullScreen]);
return (
<Flex direction={"column"} justify={"center"} align={"center"} p="2" className="rounded-lg border w-full" gap={"2"}>
<Flex direction={"column"} justify={"center"} align={"center"} p="2" className="shadow rounded-lg border w-full border-t-2 border-t-purple-500" gap={"2"}>
<Flex gap={"4"} >
{t("tipsTitle")}:
<Text>{t("mouseLeft")}</Text>

View File

@@ -167,16 +167,14 @@ export default function TextSetting({
setFontWeightEnabled(map);
};
return (
<Flex className="p-4 border rounded-lg " gap={"3"} direction={"column"}>
<Flex className="p-4 border rounded-lg border-t-2 border-t-purple-500 shadow" gap={"3"} direction={"column"}>
<Heading as="h2" size="4" className="font-medium text-lg" >{t("title")}</Heading>
<textarea
value={text.text}
onChange={e => setText({ ...text, text: e.target.value })}
className="w-full p-3 border rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
rows={4}
rows={2}
/>
<div className="space-y-1">
<Heading as="h3" size={"3"} >{t("textColor")}</Heading>
@@ -220,13 +218,13 @@ export default function TextSetting({
type="color"
value={textGradientColor[0]}
onChange={e => setTextGradientColor([e.target.value, text.color[1]])}
className="w-1/2 h-10 rounded-md cursor-pointer"
className="w-1/2 h-8 rounded-md cursor-pointer"
/>
<input
type="text"
value={textGradientColor[0]}
onChange={e => setTextGradientColor([e.target.value, text.color[1]])}
className="w-1/2 h-10 rounded-md cursor-pointer pl-4"
className="w-1/2 h-8 rounded-md cursor-pointer pl-4"
/>
</Flex>
<Flex gap={"4"}>
@@ -235,13 +233,13 @@ export default function TextSetting({
value={textGradientColor[1]}
onChange={e => setTextGradientColor([text.color[0], e.target.value])}
className="w-1/2 h-10 rounded-md cursor-pointer"
className="w-1/2 h-8 rounded-md cursor-pointer"
/>
<input
type="text"
value={textGradientColor[1]}
onChange={e => setTextGradientColor([text.color[0], e.target.value])}
className="w-1/2 h-10 rounded-md cursor-pointer pl-4"
className="w-1/2 h-8 rounded-md cursor-pointer pl-4"
/>
</Flex>
</Box>

View File

@@ -2,10 +2,12 @@ import * as THREE from "three";
import { BackgroundProp } from "./BackgroundSelector";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { ShadowMapViewer } from "three/addons/utils/ShadowMapViewer.js";
THREE.Cache.enabled = true;
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { ColorGradientDir, TextProp } from "./TextSetting";
import { EffectProp } from "./Effects";
let camera: THREE.PerspectiveCamera,
scene: THREE.Scene,
@@ -28,9 +30,10 @@ export function init(
renderer.setPixelRatio(1);
renderer.setSize(width, height, false);
renderer.setAnimationLoop(animate);
renderer.shadowMap.enabled = true;
camera = new THREE.PerspectiveCamera(60, width / height, 0.1, 1000);
camera.position.set(0, 80, 0);
camera.position.set(0, 0, 50);
// const domWidth = container.clientWidth;
// const domHeight = container.clientHeight;
@@ -49,9 +52,10 @@ export function init(
// controls
controls = new OrbitControls(camera, renderer.domElement);
controls.screenSpacePanning = false;
controls.screenSpacePanning = true;
controls.enabled = true;
// controls.enablePan = false;
//controls.addEventListener( 'change', render ); // call this only in static scenes (i.e., if there is no animation loop)
@@ -63,8 +67,6 @@ export function init(
controls.minDistance = 0.1;
controls.maxDistance = 50000;
controls.maxPolarAngle = Math.PI / 2;
// lights
const dirLight1 = new THREE.DirectionalLight(0xffffff, 3);
@@ -72,9 +74,25 @@ export function init(
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x002288, 3);
dirLight2.position.set(-1, -1, -1);
dirLight2.position.set(-10, 10, 50);
dirLight2.castShadow = true;
dirLight2.shadow.camera.left = -100;
dirLight2.shadow.camera.top = 100;
dirLight2.shadow.camera.bottom = -100;
dirLight2.shadow.camera.right = 100;
dirLight2.shadow.camera.near = 0;
dirLight2.shadow.camera.far = 200;
dirLight2.shadow.bias = -0.000222;
dirLight2.shadow.mapSize.width = 2048;
dirLight2.shadow.mapSize.height = 2048;
scene.add(dirLight2);
// const helper = new THREE.DirectionalLightHelper(dirLight2, 50);
// scene.add(helper);
// const helper2 = new THREE.CameraHelper(dirLight2.shadow.camera);
// scene.add(helper2);
const ambientLight = new THREE.AmbientLight(0x555555);
scene.add(ambientLight);
}
@@ -113,7 +131,7 @@ export function resize(
let textMesh: THREE.Mesh;
let lastTextProps: TextProp | null = null;
export async function updateTextProps(textProps: TextProp) {
export async function updateTextProp(textProps: TextProp) {
// const mirror = true;
// const plane = new THREE.Mesh(
// new THREE.PlaneGeometry(10000, 10000),
@@ -143,8 +161,10 @@ export async function updateTextProps(textProps: TextProp) {
let size = new THREE.Vector3();
geo.boundingBox?.getSize(size);
let textMesh1 = new THREE.Mesh(geo, mat);
textMesh1.rotateX(-Math.PI / 2);
textMesh1.scale.multiplyScalar(100 / size.x);
// textMesh1.rotateX(-Math.PI / 2);
textMesh1.scale.multiplyScalar(50).divideScalar(size.x);
textMesh1.castShadow = true;
textMesh1.receiveShadow = true;
scene.add(textMesh1);
@@ -159,6 +179,9 @@ export async function updateTextProps(textProps: TextProp) {
let geo = await getTextGeometry(textProps);
textMesh.geometry.dispose();
textMesh.geometry = geo;
let size = new THREE.Vector3();
geo.boundingBox?.getSize(size);
textMesh.scale.set(1, 1, 1).multiplyScalar(50).divideScalar(size.x);
if (Array.isArray(textProps.color)) {
setGradient(
@@ -271,6 +294,7 @@ async function getTextGeometry(textProps: TextProp) {
textGeo.computeBoundingBox();
textGeo.center();
textGeo.translate(0, size / 2, depth / 2);
textGeo.computeVertexNormals();
return textGeo;
@@ -334,3 +358,28 @@ export function getPicture(width: number, height: number) {
renderer.setSize(lastWidth, lastHeight, false);
return img;
}
let shadowPlane: THREE.Mesh | null = null;
export function updateEffectProp(effect: EffectProp) {
// && background.color && !background.image
if (effect.enableShadow) {
if (!shadowPlane) {
shadowPlane = new THREE.Mesh(
new THREE.PlaneGeometry(100, 100, 10, 10),
new THREE.ShadowMaterial({
color: new THREE.Color(effect.shadowColor),
opacity: 0.3,
})
);
shadowPlane.receiveShadow = true;
}
scene.add(shadowPlane);
(shadowPlane.material as THREE.ShadowMaterial).color.set(
effect.shadowColor
);
shadowPlane.visible = true;
} else {
shadowPlane && (shadowPlane.visible = false);
}
}

View File

@@ -6,10 +6,12 @@ import Header from "@/components/Header";
import { Box, Container, Flex, Heading, Text, Card } from "@radix-ui/themes";
import { HelpCircle } from "lucide-react";
import { useTranslations } from "next-intl";
import { EffectProp } from "../common/Effects";
export function OnlyPage({ textProp, backgroundProp }: {
export function OnlyPage({ textProp, backgroundProp, effectProp }: {
textProp: TextProp | undefined;
backgroundProp: BackgroundProp | undefined
backgroundProp: BackgroundProp | undefined;
effectProp: EffectProp | undefined;
}) {
const t = useTranslations('TextEditor');
@@ -20,6 +22,7 @@ export function OnlyPage({ textProp, backgroundProp }: {
<FullEditor
textProp={textProp}
backgroundProp={backgroundProp}
effectProp={effectProp}
/>
</Container>
<Container p="4">