feat(home): animated typewriter placeholder for the prompt composer
Cycle through localized example prompts with a typing/deleting animation in the home composer placeholder, start the field empty instead of with a default prompt, and style the home editor with larger prompt text. Also rebrand the device-management copy from Lovart to Moteva. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
email: "电子邮箱",
|
||||
deviceManagement: "设备管理",
|
||||
removeAllDevices: "移除全部设备",
|
||||
removeAllDevicesDescription: "Lovart 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
||||
removeAllDevicesDescription: "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
||||
onlineStatus: "在线状态",
|
||||
deviceInfo: "设备信息",
|
||||
currentDevice: "当前设备",
|
||||
@@ -490,7 +490,7 @@ const dictionaries: Record<Locale, Dictionary> = {
|
||||
email: "Email",
|
||||
deviceManagement: "Device Management",
|
||||
removeAllDevices: "Remove All Devices",
|
||||
removeAllDevicesDescription: "A Lovart account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
|
||||
removeAllDevicesDescription: "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
|
||||
onlineStatus: "Online",
|
||||
deviceInfo: "Device Info",
|
||||
currentDevice: "Current Device",
|
||||
|
||||
@@ -75,6 +75,41 @@ type HomeImageModel = {
|
||||
credits: number;
|
||||
};
|
||||
|
||||
type HomePromptPlaceholderBlueprint = {
|
||||
prefix: string;
|
||||
phrases: string[];
|
||||
};
|
||||
|
||||
type TypewriterPhase = "typing" | "deleting" | "pausing";
|
||||
|
||||
const homePromptPlaceholderBlueprints: Record<Locale, HomePromptPlaceholderBlueprint> = {
|
||||
"zh-CN": {
|
||||
prefix: "让 Moteva ",
|
||||
phrases: [
|
||||
"设计一个令人惊艳的标志",
|
||||
"创建一个温馨的咖啡厅风格菜单海报",
|
||||
"生成一组水彩电影感插画分镜",
|
||||
"为一家日系面包店设计完整品牌视觉",
|
||||
"制作一张高转化的电商产品图"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
prefix: "Ask Moteva to ",
|
||||
phrases: [
|
||||
"design a striking logo",
|
||||
"create a warm cafe-style menu poster",
|
||||
"generate a watercolor cinematic storyboard",
|
||||
"build a complete brand identity for a Japanese bakery",
|
||||
"make a high-converting e-commerce product visual"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const HOME_PLACEHOLDER_TYPE_DELAY_MS = 48;
|
||||
const HOME_PLACEHOLDER_DELETE_DELAY_MS = 24;
|
||||
const HOME_PLACEHOLDER_HOLD_DELAY_MS = 1600;
|
||||
const HOME_PLACEHOLDER_PAUSE_DELAY_MS = 260;
|
||||
|
||||
const promptModeBlueprints = [
|
||||
{
|
||||
id: "poster",
|
||||
@@ -519,6 +554,55 @@ function hasPromptText(value: string) {
|
||||
return value.replace(/\u00a0/g, " ").trim().length > 0;
|
||||
}
|
||||
|
||||
function useHomePromptPlaceholder(locale: Locale) {
|
||||
const blueprint = homePromptPlaceholderBlueprints[locale];
|
||||
const [phraseIndex, setPhraseIndex] = useState(0);
|
||||
const [visibleCharacters, setVisibleCharacters] = useState(0);
|
||||
const [phase, setPhase] = useState<TypewriterPhase>("typing");
|
||||
const phrase = blueprint.phrases[phraseIndex] ?? blueprint.phrases[0] ?? "";
|
||||
const characters = useMemo(() => Array.from(phrase), [phrase]);
|
||||
|
||||
useEffect(() => {
|
||||
setPhraseIndex(0);
|
||||
setVisibleCharacters(0);
|
||||
setPhase("typing");
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === "typing") {
|
||||
if (visibleCharacters < characters.length) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisibleCharacters((count) => Math.min(count + 1, characters.length));
|
||||
}, HOME_PLACEHOLDER_TYPE_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => setPhase("deleting"), HOME_PLACEHOLDER_HOLD_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (phase === "deleting") {
|
||||
if (visibleCharacters > 0) {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisibleCharacters((count) => Math.max(count - 1, 0));
|
||||
}, HOME_PLACEHOLDER_DELETE_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => setPhase("pausing"), HOME_PLACEHOLDER_PAUSE_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setPhraseIndex((index) => (index + 1) % blueprint.phrases.length);
|
||||
setPhase("typing");
|
||||
}, HOME_PLACEHOLDER_PAUSE_DELAY_MS);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [blueprint, characters.length, phase, visibleCharacters]);
|
||||
|
||||
return `${blueprint.prefix}${characters.slice(0, visibleCharacters).join("")}`;
|
||||
}
|
||||
|
||||
type CreateProjectOptions = {
|
||||
allowEmptyPrompt?: boolean;
|
||||
};
|
||||
@@ -535,7 +619,7 @@ export function HomePage() {
|
||||
const promptComposerRef = useRef<PromptComposerHandle | null>(null);
|
||||
const referenceImagesRef = useRef<UploadedReferenceImage[]>([]);
|
||||
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
|
||||
const [prompt, setPrompt] = useState(() => t("defaultPrompt"));
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [referenceImages, setReferenceImages] = useState<UploadedReferenceImage[]>([]);
|
||||
const [activeModeId, setActiveModeId] = useState("poster");
|
||||
const [activeInspirationCategory, setActiveInspirationCategory] = useState("all");
|
||||
@@ -571,6 +655,7 @@ export function HomePage() {
|
||||
const selectedModel = imageModels.find((model) => model.id === selectedModelId) ?? imageModels[0];
|
||||
const inspirationCategories = useMemo(() => getInspirationCategories(locale), [locale]);
|
||||
const inspirationCatalog = useMemo(() => getInspirationCatalog(locale), [locale]);
|
||||
const promptPlaceholder = useHomePromptPlaceholder(locale);
|
||||
const activeMode = promptModes.find((mode) => mode.id === activeModeId) ?? promptModes[0];
|
||||
const projects = dashboard?.projects ?? [];
|
||||
const canSubmitPrompt = hasPromptText(prompt);
|
||||
@@ -728,8 +813,9 @@ export function HomePage() {
|
||||
<PromptComposer
|
||||
ref={promptComposerRef}
|
||||
text={prompt}
|
||||
placeholder={t("promptPlaceholder")}
|
||||
placeholder={promptPlaceholder}
|
||||
ariaLabel={t("promptPlaceholder")}
|
||||
className="home-prompt-editor"
|
||||
removeLabel={t("removeReference")}
|
||||
onTextChange={setPrompt}
|
||||
onReferencesChange={syncReferenceImages}
|
||||
|
||||
@@ -137,6 +137,13 @@ button:disabled {
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.prompt-rich-editor.home-prompt-editor {
|
||||
color: #1f2937;
|
||||
font-size: 24px;
|
||||
line-height: 1.45;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prompt-rich-editor[data-empty="true"]::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -146,6 +153,10 @@ button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.prompt-rich-editor.home-prompt-editor[data-empty="true"]::before {
|
||||
color: #b5b7bc;
|
||||
}
|
||||
|
||||
.prompt-card.has-references .prompt-rich-editor {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user