diff --git a/src/App.tsx b/src/App.tsx index 2e1d5fd..91e878f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,6 +48,7 @@ const CommunityCaseAddPage = lazy(() => import("./features/community-review/Comm const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage")); const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); +const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); const HomePage = lazy(() => import("./features/home/HomePage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); @@ -110,6 +111,7 @@ const VIEW_KEYS = new Set([ "resolutionUpscale", "watermarkRemoval", "subtitleRemoval", + "dialogGenerator", "digitalHuman", "avatarConsole", "characterMix", @@ -123,7 +125,7 @@ const VIEW_KEYS = new Set([ "not-found", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "userAgreement", "privacyPolicy", "not-found"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -1159,6 +1161,8 @@ function App() { onSelectView={handleSetView} /> ); + case "dialogGenerator": + return ; case "report": return ; case "providerHealth": diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index a03cf8a..ce0a772 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -86,6 +86,7 @@ function AppShell({ "imageWorkbench", "resolutionUpscale", "digitalHuman", + "dialogGenerator", "avatarConsole", "characterMix", ] as WebViewKey[]; diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx index a020281..058a5d9 100644 --- a/src/components/PageTransition.tsx +++ b/src/components/PageTransition.tsx @@ -23,6 +23,7 @@ const NAV_ORDER: string[] = [ "resolutionUpscale", "watermarkRemoval", "subtitleRemoval", + "dialogGenerator", "digitalHuman", "avatarConsole", "characterMix", @@ -87,4 +88,4 @@ export default function PageTransition({ viewKey, children }: PageTransitionProp {displayedChildren} ); -} \ No newline at end of file +} diff --git a/src/features/canvas/CanvasPage.tsx b/src/features/canvas/CanvasPage.tsx index 9306ec3..5f405ff 100644 --- a/src/features/canvas/CanvasPage.tsx +++ b/src/features/canvas/CanvasPage.tsx @@ -2824,7 +2824,7 @@ function CanvasPage({ if (targetPort) { connectCanvasPorts(connectorDrag.port, targetPort); } else { - const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, 0); + const menuPosition = positionFloatingMenu(event.clientX, event.clientY, 200, 160, -40); setConnectionDropMenu({ ...menuPosition, originLeft: event.clientX, diff --git a/src/features/dialog-generator/DialogGeneratorPage.tsx b/src/features/dialog-generator/DialogGeneratorPage.tsx new file mode 100644 index 0000000..4d99386 --- /dev/null +++ b/src/features/dialog-generator/DialogGeneratorPage.tsx @@ -0,0 +1,290 @@ +import { useCallback, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type TouchEvent as ReactTouchEvent } from "react"; + +type DialogStyle = "style1" | "style2" | "style3" | "style4"; + +interface DialogItem { + id: number; + style: DialogStyle; + x: number; + y: number; + text: string; + color: string; + confirmed: boolean; +} + +interface DragState { + id: number; + offsetX: number; + offsetY: number; +} + +const dialogStyles: Array<{ + key: DialogStyle; + label: string; + description: string; + swatchClass: string; +}> = [ + { key: "style1", label: "白色圆角对话框", description: "适合浅色说明与标注", swatchClass: "is-white" }, + { key: "style2", label: "蓝色气泡对话框", description: "适合角色台词与重点提示", swatchClass: "is-blue" }, + { key: "style3", label: "黄色提示对话框", description: "适合醒目提醒与强调", swatchClass: "is-amber" }, + { key: "style4", label: "灰色简约对话框", description: "适合信息备注与辅助说明", swatchClass: "is-gray" }, +]; + +const textColorOptions = [ + { value: "#ffffff", label: "白色" }, + { value: "#111827", label: "黑色" }, + { value: "#ef4444", label: "红色" }, + { value: "#f59e0b", label: "黄色" }, + { value: "#165dff", label: "蓝色" }, + { value: "#00ff88", label: "绿色" }, +]; + +function DialogGeneratorPage() { + const fileInputRef = useRef(null); + const previewRef = useRef(null); + const dragRef = useRef(null); + const nextIdRef = useRef(0); + const [backgroundUrl, setBackgroundUrl] = useState(""); + const [dialogs, setDialogs] = useState([]); + const [selectedTextColor, setSelectedTextColor] = useState(textColorOptions[0].value); + const [activeDragId, setActiveDragId] = useState(null); + + const handleFile = useCallback((file?: File | null) => { + if (!file || !file.type.startsWith("image/")) return; + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + setBackgroundUrl(reader.result); + } + }; + reader.readAsDataURL(file); + }, []); + + const addDialog = useCallback((style: DialogStyle) => { + nextIdRef.current += 1; + const id = nextIdRef.current; + setDialogs((current) => [ + ...current, + { + id, + style, + x: 30 + (id * 25) % 200, + y: 30 + (id * 20) % 150, + text: "", + color: selectedTextColor, + confirmed: false, + }, + ]); + }, [selectedTextColor]); + + const updateDialog = useCallback((id: number, patch: Partial) => { + setDialogs((current) => current.map((item) => (item.id === id ? { ...item, ...patch } : item))); + }, []); + + const deleteDialog = useCallback((id: number) => { + setDialogs((current) => current.filter((item) => item.id !== id)); + }, []); + + const startDrag = useCallback((id: number, clientX: number, clientY: number) => { + const dialogEl = document.querySelector(`[data-dialog-id="${id}"]`); + if (!dialogEl) return; + const rect = dialogEl.getBoundingClientRect(); + dragRef.current = { + id, + offsetX: clientX - rect.left, + offsetY: clientY - rect.top, + }; + setActiveDragId(id); + }, []); + + const moveDrag = useCallback((clientX: number, clientY: number) => { + const drag = dragRef.current; + const preview = previewRef.current; + if (!drag || !preview) return; + const dialogEl = document.querySelector(`[data-dialog-id="${drag.id}"]`); + if (!dialogEl) return; + + const bounds = preview.getBoundingClientRect(); + const nextX = Math.max(0, Math.min(clientX - drag.offsetX - bounds.left, bounds.width - dialogEl.offsetWidth)); + const nextY = Math.max(0, Math.min(clientY - drag.offsetY - bounds.top, bounds.height - dialogEl.offsetHeight)); + updateDialog(drag.id, { x: nextX, y: nextY }); + }, [updateDialog]); + + const endDrag = useCallback(() => { + dragRef.current = null; + setActiveDragId(null); + }, []); + + const handleCanvasMouseMove = useCallback((event: ReactMouseEvent) => { + moveDrag(event.clientX, event.clientY); + }, [moveDrag]); + + const handleCanvasTouchMove = useCallback((event: ReactTouchEvent) => { + const touch = event.touches[0]; + if (!touch) return; + moveDrag(touch.clientX, touch.clientY); + }, [moveDrag]); + + return ( +
+
+ + +
+
+
+ Preview +

预览区域

+
+

拖动文字定位,输入文字后点击确认,确认后只保留文字图层,双击可重新编辑。

+
+ +
+ {backgroundUrl ?
: null} + {!backgroundUrl ? ( +
+ 🖼 +

上传图片后开始编辑

+
+ ) : null} + + {dialogs.map((dialog) => ( +
{ + const target = event.target as HTMLElement; + if (target.closest("textarea,button")) return; + startDrag(dialog.id, event.clientX, event.clientY); + event.preventDefault(); + }} + onTouchStart={(event) => { + const target = event.target as HTMLElement; + if (target.closest("textarea,button")) return; + const touch = event.touches[0]; + if (touch) startDrag(dialog.id, touch.clientX, touch.clientY); + }} + onDoubleClick={() => { + if (dialog.confirmed) updateDialog(dialog.id, { confirmed: false }); + }} + > + {!dialog.confirmed ? ( + + ) : null} + {dialog.confirmed ? ( +
{dialog.text}
+ ) : ( +