diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerList.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerList.tsx new file mode 100644 index 000000000..4720b1403 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerList.tsx @@ -0,0 +1,57 @@ +import React from "react" +import type { StickerItem } from "../../../types" + +export interface StickerListProps { + items: StickerItem[] + selectedId: string | null + onSelect: (id: string) => void + onRemove: (id: string) => void +} + +export const StickerList: React.FC = ({ + items, + selectedId, + onSelect, + onRemove, +}) => { + if (items.length === 0) return null + + const getStickerDisplay = (item: StickerItem) => { + if (item.type === "emoji") return item.content + if (item.type === "text") return item.content.slice(0, 10) + return "🖼" + } + + const getStickerName = (item: StickerItem) => { + if (item.type === "text") return item.content.slice(0, 10) + if (item.type === "emoji") return "表情贴纸" + return "图片贴纸" + } + + return ( +
+
已添加贴纸 ({items.length})
+
+ {items.map((item) => ( +
onSelect(item.id)} + > + {getStickerDisplay(item)} + {getStickerName(item)} + +
+ ))} +
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.tsx new file mode 100644 index 000000000..5632cbd88 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.tsx @@ -0,0 +1,203 @@ +import React from "react" +import type { StickerItem, TextStickerPreset } from "../../../types" +import { TEXT_STICKER_PRESET_LABELS } from "../../../types" +import { TEXT_PRESET_STYLES } from "../constants" + +export interface StickerPropsEditorProps { + sticker: StickerItem + totalDuration: number + onChange: (id: string, partial: Partial) => void +} + +export const StickerPropsEditor: React.FC = ({ + sticker, + totalDuration, + onChange, +}) => { + const update = (partial: Partial) => { + onChange(sticker.id, partial) + } + + return ( +
+
属性调整
+ + {/* 位置 X */} +
+ 位置 X + update({ x: Number(e.target.value) })} + /> + {sticker.x}% +
+ + {/* 位置 Y */} +
+ 位置 Y + update({ y: Number(e.target.value) })} + /> + {sticker.y}% +
+ + {/* 尺寸 */} +
+ 大小 + + update({ + width: Number(e.target.value), + height: Number(e.target.value), + }) + } + /> + {sticker.width}% +
+ + {/* 旋转 */} +
+ 旋转 + update({ rotation: Number(e.target.value) })} + /> + {sticker.rotation}° +
+ + {/* 透明度 */} +
+ 透明度 + update({ opacity: Number(e.target.value) })} + /> + {sticker.opacity}% +
+ + {/* 时间 */} +
+ 开始 + { + const val = Number(e.target.value) + if (!isNaN(val) && val >= 0 && val <= totalDuration) update({ start_time: val }) + }} + /> + 时长 + { + const val = Number(e.target.value) + if (!isNaN(val) && val > 0 && val <= totalDuration) update({ duration: val }) + }} + /> +
+ + {/* 文字贴纸特有属性 */} + {sticker.type === "text" && ( + <> +
+ 花字 + +
+
+ 字号 + update({ font_size: Number(e.target.value) })} + /> + {sticker.font_size}px +
+
+ 颜色 + update({ text_color: e.target.value })} + /> +
+ + )} + + {/* 预览 */} +
+
+ {sticker.type === "emoji" && sticker.content} + {sticker.type === "text" && sticker.content} + {sticker.type === "image" && ( + sticker + )} +
+
+
+ ) +} diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerTabs.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerTabs.tsx new file mode 100644 index 000000000..566523f74 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/components/StickerTabs.tsx @@ -0,0 +1,136 @@ +import React, { useState } from "react" +import type { StickerType, TextStickerPreset } from "../../../types" +import { TEXT_STICKER_PRESET_LABELS } from "../../../types" +import { EMOJI_LIST, TEXT_PRESET_STYLES } from "../constants" + +export interface StickerTabsProps { + activeTab: StickerType + onTabChange: (tab: StickerType) => void + textInput: string + onTextInputChange: (val: string) => void + onAddEmoji: (emoji: string) => void + onAddImage: (url: string) => void + onAddText: (text: string) => void +} + +export const StickerTabs: React.FC = ({ + activeTab, + onTabChange, + textInput, + onTextInputChange, + onAddEmoji, + onAddImage, + onAddText, +}) => { + const [imageUrl, setImageUrl] = useState("") + + const handleImageAdd = () => { + if (imageUrl.trim()) { + onAddImage(imageUrl.trim()) + setImageUrl("") + } + } + + const handleTextAdd = () => { + if (textInput.trim()) { + onAddText(textInput.trim()) + } + } + + return ( + <> + {/* 类型 Tab */} +
+ {(["emoji", "image", "text"] as StickerType[]).map((t) => ( + + ))} +
+ + {/* Tab 内容区 */} +
+ {/* Emoji 素材库 */} + {activeTab === "emoji" && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + + {/* 图片贴纸 */} + {activeTab === "image" && ( +
+ setImageUrl(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && imageUrl.trim()) { + onAddImage(imageUrl.trim()) + setImageUrl("") + } + }} + /> + +
+ )} + + {/* 文字花字 */} + {activeTab === "text" && ( +
+
+ onTextInputChange(e.target.value)} + /> + +
+
+
花字预设预览
+
+ {(Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[]).map((p) => ( +
+ 示例 +
{TEXT_STICKER_PRESET_LABELS[p]}
+
+ ))} +
+
+
+ )} +
+ + ) +} diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel/constants.ts b/apps/web/src/pages/editing-planner/components/StickerPanel/constants.ts new file mode 100644 index 000000000..ef3def98f --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/constants.ts @@ -0,0 +1,54 @@ +import type { CSSProperties } from "react" +import type { TextStickerPreset } from "../../types" + +/** 常用 emoji 素材 */ +export const EMOJI_LIST = [ + "😀", + "😂", + "🥰", + "😎", + "🤩", + "😱", + "🤔", + "😴", + "🥳", + "😍", + "❤️", + "🔥", + "⭐", + "✨", + "💯", + "👍", + "👏", + "🎉", + "🎵", + "💪", + "📌", + "💡", + "🎯", + "✅", + "❌", + "⬆️", + "⬇️", + "➡️", + "⭕", + "🔔", +] + +/** 文字花字预设对应的 CSS 样式预览 */ +export const TEXT_PRESET_STYLES: Record = { + normal: { color: "#fff", textShadow: "none" }, + highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" }, + bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 }, + neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" }, + shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" }, + outline: { color: "#fff", WebkitTextStroke: "1px #000" }, + gradient: { + color: "transparent", + background: "linear-gradient(90deg,#f093fb,#f5576c)", + WebkitBackgroundClip: "text", + }, + handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" }, +} + +export const genStickerId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.ts b/apps/web/src/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.ts new file mode 100644 index 000000000..a80ef567f --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.ts @@ -0,0 +1,85 @@ +import { useState, useCallback, useMemo } from "react" +import type { StickerConfig, StickerItem, StickerType } from "../../../types" +import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM } from "../../../types" +import { genStickerId } from "../constants" + +interface UseStickerPanelOptions { + config: StickerConfig + onChange: (config: StickerConfig) => void + totalDuration: number +} + +export const useStickerPanel = ({ config, onChange, totalDuration }: UseStickerPanelOptions) => { + const [selectedId, setSelectedId] = useState(null) + const [activeTab, setActiveTab] = useState("emoji") + const [textInput, setTextInput] = useState("") + + const selectedSticker = useMemo( + () => config.items.find((s) => s.id === selectedId) ?? null, + [config.items, selectedId], + ) + + /** 更新单个贴纸 */ + const updateItem = useCallback( + (id: string, partial: Partial) => { + onChange({ + ...config, + items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)), + }) + }, + [config, onChange], + ) + + /** 添加贴纸 */ + const addSticker = useCallback( + (type: StickerType, content: string) => { + const newItem: StickerItem = { + ...DEFAULT_STICKER_ITEM, + id: genStickerId(), + type, + content, + duration: totalDuration > 0 ? totalDuration : 5, + z_index: config.items.length > 0 ? Math.max(...config.items.map((i) => i.z_index)) + 1 : 1, + } + onChange({ + ...config, + enabled: true, + items: [...config.items, newItem], + }) + setSelectedId(newItem.id) + }, + [config, onChange, totalDuration], + ) + + /** 删除贴纸 */ + const removeSticker = useCallback( + (id: string) => { + onChange({ + ...config, + items: config.items.filter((s) => s.id !== id), + }) + if (selectedId === id) setSelectedId(null) + }, + [config, onChange, selectedId], + ) + + /** 重置所有 */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }) + setSelectedId(null) + }, [config.enabled, onChange]) + + return { + selectedId, + setSelectedId, + selectedSticker, + activeTab, + setActiveTab, + textInput, + setTextInput, + updateItem, + addSticker, + removeSticker, + handleReset, + } +} diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel/index.tsx similarity index 56% rename from apps/web/src/pages/editing-planner/components/StickerPanel.tsx rename to apps/web/src/pages/editing-planner/components/StickerPanel/index.tsx index a92532867..d881e4689 100644 --- a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/StickerPanel/index.tsx @@ -1,16 +1,13 @@ -/** - * 贴纸配置面板 - * 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整 - */ import React from "react" import { Drawer, Switch } from "antd" -import type { StickerConfig } from "@/pages/editing-planner/types" -import StickerLibrary from "./sticker/StickerLibrary" -import StickerList from "./sticker/StickerList" -import StickerPropsEditor from "./sticker/StickerPropsEditor" -import { useStickerItems } from "@/pages/editing-planner/hooks/useStickerItems" +import type { StickerConfig } from "../../types" -interface StickerPanelProps { +import { useStickerPanel } from "./hooks/useStickerPanel" +import { StickerTabs } from "./components/StickerTabs" +import { StickerList } from "./components/StickerList" +import { StickerPropsEditor } from "./components/StickerPropsEditor" + +export interface StickerPanelProps { open: boolean onClose: () => void config: StickerConfig @@ -28,14 +25,28 @@ const StickerPanel: React.FC = ({ const { selectedId, setSelectedId, + selectedSticker, activeTab, setActiveTab, - selectedSticker, + textInput, + setTextInput, updateItem, addSticker, removeSticker, - handleReset, - } = useStickerItems({ config, onChange, totalDuration }) + } = useStickerPanel({ config, onChange, totalDuration }) + + const handleEmojiAdd = (emoji: string) => { + addSticker("emoji", emoji) + } + + const handleImageAdd = (url: string) => { + addSticker("image", url) + } + + const handleTextAdd = (text: string) => { + addSticker("text", text) + setTextInput("") + } return ( = ({ /> - {/* 素材库 + 类型Tab */} - + {/* Tab 选择器 + 内容 */} + {/* 已添加贴纸列表 */} {/* 选中贴纸的属性编辑 */} @@ -72,16 +91,9 @@ const StickerPanel: React.FC = ({ )} - - {/* 底部重置 */} -
- -
) } diff --git a/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerList.test.tsx b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerList.test.tsx new file mode 100644 index 000000000..edddceb5a --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerList.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for StickerList.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/StickerPanel/components/StickerList" + +describe("StickerList.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.test.tsx b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.test.tsx new file mode 100644 index 000000000..612eca228 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for StickerPropsEditor.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/StickerPanel/components/StickerPropsEditor" + +describe("StickerPropsEditor.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerTabs.test.tsx b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerTabs.test.tsx new file mode 100644 index 000000000..6cb69b6ff --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/StickerPanel/components/StickerTabs.test.tsx @@ -0,0 +1,11 @@ +/** + * Smoke test for StickerTabs.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/StickerPanel/components/StickerTabs" + +describe("StickerTabs.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/StickerPanel/constants.test.ts b/apps/web/src/test/pages/editing-planner/components/StickerPanel/constants.test.ts new file mode 100644 index 000000000..aabb63af4 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/StickerPanel/constants.test.ts @@ -0,0 +1,11 @@ +/** + * Smoke test for constants.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/StickerPanel/constants" + +describe("constants.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +}) diff --git a/apps/web/src/test/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.test.ts b/apps/web/src/test/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.test.ts new file mode 100644 index 000000000..5da150e22 --- /dev/null +++ b/apps/web/src/test/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel.test.ts @@ -0,0 +1,11 @@ +/** + * Smoke test for useStickerPanel.test + */ +import { describe, it, expect } from "vitest" +import "@/pages/editing-planner/components/StickerPanel/hooks/useStickerPanel" + +describe("useStickerPanel.test smoke", () => { + it("should load module successfully", () => { + expect(true).toBe(true) + }) +})