forked from 77media/video-flow
新增语言选择功能,优化视频创建页面,移除不必要的状态管理,更新剧本渲染逻辑以支持动态加载和占位符显示。
This commit is contained in:
parent
508065107a
commit
2132805acd
@ -1,10 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2, Globe } from 'lucide-react';
|
||||||
import { ArrowLeft, ChevronDown, ChevronUp, Video, ListOrdered, Play, Loader2, Pause, MoreHorizontal, Edit2, Check, X, RefreshCw, Lightbulb, Package, Crown, ArrowUp, Search, Filter, Grid, Grid3X3, Calendar, Clock, Eye, Heart, Share2 } from 'lucide-react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetClose } from "@/components/ui/sheet";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import './style/create-to-video2.css';
|
import './style/create-to-video2.css';
|
||||||
import { Dropdown, Menu } from 'antd';
|
import { Dropdown, Menu } from 'antd';
|
||||||
@ -14,7 +12,6 @@ import { ModeEnum, ResolutionEnum } from "@/app/model/enums";
|
|||||||
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
import { createScriptEpisodeNew, getScriptEpisodeListNew, CreateScriptEpisodeRequest } from "@/api/script_episode";
|
||||||
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
import { getUploadToken, uploadToQiniu } from "@/api/common";
|
||||||
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
import { EmptyStateAnimation } from '@/components/common/EmptyStateAnimation2';
|
||||||
import { ScriptEditDialog } from '@/components/script-edit-dialog';
|
|
||||||
|
|
||||||
|
|
||||||
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
const ideaText = 'a cute capybara with an orange on its head, staring into the distance and walking forward';
|
||||||
@ -32,6 +29,7 @@ export function CreateToVideo2() {
|
|||||||
const [isFocus, setIsFocus] = useState(false);
|
const [isFocus, setIsFocus] = useState(false);
|
||||||
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
|
const [selectedMode, setSelectedMode] = useState<ModeEnum>(ModeEnum.AUTOMATIC);
|
||||||
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
|
const [selectedResolution, setSelectedResolution] = useState<ResolutionEnum>(ResolutionEnum.HD_720P);
|
||||||
|
const [selectedLanguage, setSelectedLanguage] = useState<string>('en');
|
||||||
const [script, setInputText] = useState('');
|
const [script, setInputText] = useState('');
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
const [runTour, setRunTour] = useState(true);
|
const [runTour, setRunTour] = useState(true);
|
||||||
@ -45,15 +43,12 @@ export function CreateToVideo2() {
|
|||||||
const [limit, setLimit] = useState(12);
|
const [limit, setLimit] = useState(12);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
|
||||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
|
||||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isSmartAssistantExpanded, setIsSmartAssistantExpanded] = useState(false);
|
|
||||||
const [userId, setUserId] = useState<number>(0);
|
const [userId, setUserId] = useState<number>(0);
|
||||||
const [isComposing, setIsComposing] = useState(false);
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
const [isScriptEditDialogOpen, setIsScriptEditDialogOpen] = useState(false);
|
const [loadingIdea, setLoadingIdea] = useState(false);
|
||||||
|
|
||||||
// 在客户端挂载后读取localStorage
|
// 在客户端挂载后读取localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@ -135,8 +130,6 @@ export function CreateToVideo2() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateVideo = async () => {
|
const handleCreateVideo = async () => {
|
||||||
setIsScriptEditDialogOpen(true);
|
|
||||||
return;
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
// 创建剧集数据
|
// 创建剧集数据
|
||||||
let episodeData: any = {
|
let episodeData: any = {
|
||||||
@ -242,6 +235,53 @@ export function CreateToVideo2() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 语言选项配置
|
||||||
|
const languageItems: MenuProps['items'] = [
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
label: (
|
||||||
|
<div className="text-white/50 text-xs px-2 pb-2">Language</div>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: 'en',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between p-1">
|
||||||
|
<span className="text-base">English</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'zh',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between p-1">
|
||||||
|
<span className="text-base">Chinese</span>
|
||||||
|
<Crown className="w-4 h-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ja',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between p-1">
|
||||||
|
<span className="text-base">Japanese</span>
|
||||||
|
<Crown className="w-4 h-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ko',
|
||||||
|
label: (
|
||||||
|
<div className="flex items-center justify-between p-1">
|
||||||
|
<span className="text-base">Korean</span>
|
||||||
|
<Crown className="w-4 h-4 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// 处理模式选择
|
// 处理模式选择
|
||||||
const handleModeSelect: MenuProps['onClick'] = ({ key }) => {
|
const handleModeSelect: MenuProps['onClick'] = ({ key }) => {
|
||||||
setSelectedMode(key as ModeEnum);
|
setSelectedMode(key as ModeEnum);
|
||||||
@ -252,6 +292,21 @@ export function CreateToVideo2() {
|
|||||||
setSelectedResolution(key as ResolutionEnum);
|
setSelectedResolution(key as ResolutionEnum);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理语言选择
|
||||||
|
const handleLanguageSelect: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
setSelectedLanguage(key as string);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理获取想法
|
||||||
|
const handleGetIdea = () => {
|
||||||
|
if (loadingIdea) return;
|
||||||
|
setLoadingIdea(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setInputText('idea');
|
||||||
|
setLoadingIdea(false);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
const handleStartCreating = () => {
|
const handleStartCreating = () => {
|
||||||
setActiveTab('script');
|
setActiveTab('script');
|
||||||
setInputText(ideaText);
|
setInputText(ideaText);
|
||||||
@ -402,6 +457,8 @@ export function CreateToVideo2() {
|
|||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
|
preload="none"
|
||||||
|
poster={`${episode.final_video_url}?vframe/jpg/offset/1`}
|
||||||
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
|
onMouseEnter={(e) => (e.target as HTMLVideoElement).play()}
|
||||||
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
|
onMouseLeave={(e) => (e.target as HTMLVideoElement).pause()}
|
||||||
/>
|
/>
|
||||||
@ -618,9 +675,15 @@ export function CreateToVideo2() {
|
|||||||
<span>Describe the content you want to action. Get an </span>
|
<span>Describe the content you want to action. Get an </span>
|
||||||
<b
|
<b
|
||||||
className='idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline'
|
className='idea-link inline-flex items-center gap-0.5 text-white/[0.50] font-normal cursor-pointer pointer-events-auto underline'
|
||||||
onClick={() => setInputText(ideaText)}
|
onClick={() => handleGetIdea()}
|
||||||
>
|
>
|
||||||
<Lightbulb className='w-4 h-4' />idea
|
{loadingIdea ? (
|
||||||
|
<Loader2 className='w-4 h-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lightbulb className='w-4 h-4' />idea
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -669,6 +732,28 @@ export function CreateToVideo2() {
|
|||||||
<Crown className={`w-4 h-4 text-yellow-500 ${selectedResolution === ResolutionEnum.HD_720P ? 'hidden' : ''}`} />
|
<Crown className={`w-4 h-4 text-yellow-500 ${selectedResolution === ResolutionEnum.HD_720P ? 'hidden' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
{/* 语言 */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: languageItems,
|
||||||
|
onClick: handleLanguageSelect,
|
||||||
|
selectedKeys: [selectedLanguage.toString()],
|
||||||
|
}}
|
||||||
|
trigger={['click']}
|
||||||
|
overlayClassName="mode-dropdown"
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div className='tool-operation-button ant-dropdown-trigger'>
|
||||||
|
<Globe className='w-4 h-4' />
|
||||||
|
<span className='text-nowrap opacity-70'>
|
||||||
|
{selectedLanguage === 'en' ? 'English' :
|
||||||
|
selectedLanguage === 'zh' ? 'Chinese' :
|
||||||
|
selectedLanguage === 'ja' ? 'Japanese' : 'Korean'}
|
||||||
|
</span>
|
||||||
|
<Crown className={`w-4 h-4 text-yellow-500 ${selectedLanguage === 'en' ? 'hidden' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -698,13 +783,6 @@ export function CreateToVideo2() {
|
|||||||
<EmptyStateAnimation className='' />
|
<EmptyStateAnimation className='' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isScriptEditDialogOpen && (
|
|
||||||
<ScriptEditDialog
|
|
||||||
isOpen={isScriptEditDialogOpen}
|
|
||||||
onClose={() => setIsScriptEditDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ export default function WorkFlow() {
|
|||||||
// 使用自定义 hooks 管理状态
|
// 使用自定义 hooks 管理状态
|
||||||
const {
|
const {
|
||||||
taskObject,
|
taskObject,
|
||||||
|
scriptData,
|
||||||
taskSketch,
|
taskSketch,
|
||||||
taskScenes,
|
taskScenes,
|
||||||
taskShotSketch,
|
taskShotSketch,
|
||||||
@ -170,6 +171,7 @@ export default function WorkFlow() {
|
|||||||
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
|
<div className="heroVideo-FIzuK1" style={{ aspectRatio: "16 / 9" }}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<MediaViewer
|
<MediaViewer
|
||||||
|
scriptData={scriptData}
|
||||||
currentStep={currentStep}
|
currentStep={currentStep}
|
||||||
currentSketchIndex={currentSketchIndex}
|
currentSketchIndex={currentSketchIndex}
|
||||||
taskSketch={taskSketch}
|
taskSketch={taskSketch}
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import { ProgressiveReveal, presets } from '@/components/ui/progressive-reveal';
|
|||||||
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
import { GlassIconButton } from '@/components/ui/glass-icon-button';
|
||||||
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
import { ScriptRenderer } from '@/components/script-renderer/ScriptRenderer';
|
||||||
import { mockScriptData } from '@/components/script-renderer/mock';
|
import { mockScriptData } from '@/components/script-renderer/mock';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
interface MediaViewerProps {
|
interface MediaViewerProps {
|
||||||
|
scriptData: any;
|
||||||
currentStep: string;
|
currentStep: string;
|
||||||
currentSketchIndex: number;
|
currentSketchIndex: number;
|
||||||
taskSketch: any[];
|
taskSketch: any[];
|
||||||
@ -26,6 +28,7 @@ interface MediaViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaViewer({
|
export function MediaViewer({
|
||||||
|
scriptData,
|
||||||
currentStep,
|
currentStep,
|
||||||
currentSketchIndex,
|
currentSketchIndex,
|
||||||
taskSketch,
|
taskSketch,
|
||||||
@ -801,7 +804,22 @@ export function MediaViewer({
|
|||||||
const renderScriptContent = () => {
|
const renderScriptContent = () => {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full bg-white/10 rounded-lg overflow-hidden p-2">
|
<div className="relative w-full h-full bg-white/10 rounded-lg overflow-hidden p-2">
|
||||||
<ScriptRenderer data={mockScriptData} />
|
{
|
||||||
|
scriptData ? (
|
||||||
|
<ScriptRenderer data={mockScriptData} />
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2 w-full h-full">
|
||||||
|
<div className="w-[70%] h-full rounded-lg gap-2 flex flex-col">
|
||||||
|
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||||
|
<Skeleton className="w-full h-[33%] rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[30%] h-full rounded-lg">
|
||||||
|
<Skeleton className="w-full h-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export function useWorkflowData() {
|
|||||||
|
|
||||||
// 更新 taskObject 的类型
|
// 更新 taskObject 的类型
|
||||||
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
|
const [taskObject, setTaskObject] = useState<TaskObject | null>(null);
|
||||||
|
const [scriptData, setScriptData] = useState<any>(null);
|
||||||
const [taskSketch, setTaskSketch] = useState<any[]>([]);
|
const [taskSketch, setTaskSketch] = useState<any[]>([]);
|
||||||
const [taskScenes, setTaskScenes] = useState<any[]>([]);
|
const [taskScenes, setTaskScenes] = useState<any[]>([]);
|
||||||
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
|
const [taskShotSketch, setTaskShotSketch] = useState<any[]>([]);
|
||||||
@ -554,6 +555,7 @@ export function useWorkflowData() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
taskObject,
|
taskObject,
|
||||||
|
scriptData,
|
||||||
taskSketch,
|
taskSketch,
|
||||||
taskScenes,
|
taskScenes,
|
||||||
taskShotSketch,
|
taskShotSketch,
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import React, { useRef, useState } from 'react';
|
import React, { useRef, useState, useMemo, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal } from 'lucide-react';
|
import { SquarePen, Lightbulb, Navigation, Globe, Copy, SendHorizontal, X, Plus } from 'lucide-react';
|
||||||
import { ScriptData, ScriptBlock, ScriptContent } from './types';
|
import { ScriptData, ScriptBlock, ScriptContent, ThemeTagBgColor, ThemeType } from './types';
|
||||||
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
|
import ContentEditable, { ContentEditableEvent } from 'react-contenteditable';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { SelectDropdown } from '@/components/ui/select-dropdown';
|
||||||
|
import { TypewriterText } from '@/components/workflow/work-office/common/TypewriterText';
|
||||||
|
|
||||||
interface ScriptRendererProps {
|
interface ScriptRendererProps {
|
||||||
data: ScriptData;
|
data: ScriptData;
|
||||||
@ -16,6 +17,12 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
const contentRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||||
const [editBlockId, setEditBlockId] = useState<string | null>(null);
|
const [editBlockId, setEditBlockId] = useState<string | null>(null);
|
||||||
const contentEditableRef = useRef<HTMLElement>(null);
|
const contentEditableRef = useRef<HTMLElement>(null);
|
||||||
|
const [addThemeTag, setAddThemeTag] = useState<string | null>(null);
|
||||||
|
const [isInit, setIsInit] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditBlockId(null);
|
||||||
|
}, [activeBlockId]);
|
||||||
|
|
||||||
const scrollToBlock = (blockId: string) => {
|
const scrollToBlock = (blockId: string) => {
|
||||||
const element = contentRefs.current[blockId];
|
const element = contentRefs.current[blockId];
|
||||||
@ -25,6 +32,14 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 使用 useMemo 缓存标签颜色映射
|
||||||
|
const randomThemeTagBgColor = useMemo(() => {
|
||||||
|
return Object.values(ThemeTagBgColor).reduce((acc: Record<string, string>, color: string) => {
|
||||||
|
acc[color] = color;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 用于渲染展示的 JSX
|
// 用于渲染展示的 JSX
|
||||||
const renderContent = (content: ScriptContent) => {
|
const renderContent = (content: ScriptContent) => {
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
@ -35,7 +50,15 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
case 'italic':
|
case 'italic':
|
||||||
return <em className="italic">{content.text}</em>;
|
return <em className="italic">{content.text}</em>;
|
||||||
default:
|
default:
|
||||||
return <p className="mb-2">{content.text}</p>;
|
return <p className="mb-2">
|
||||||
|
{
|
||||||
|
isInit ? (
|
||||||
|
<TypewriterText text={content.text || ''} stableId={content.type} />
|
||||||
|
) : (
|
||||||
|
<span>{content.text}</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</p>;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -65,6 +88,11 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
console.log(e.target.value);
|
console.log(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlockTextBlur = (block: ScriptBlock) => (e: ContentEditableEvent) => {
|
||||||
|
console.log(e.target.value);
|
||||||
|
setEditBlockId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const renderEditBlock = (block: ScriptBlock) => {
|
const renderEditBlock = (block: ScriptBlock) => {
|
||||||
let blockHtmlText = '';
|
let blockHtmlText = '';
|
||||||
block.content.forEach(item => {
|
block.content.forEach(item => {
|
||||||
@ -76,6 +104,8 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
innerRef={contentEditableRef}
|
innerRef={contentEditableRef}
|
||||||
html={formatTextToHtml(blockHtmlText)}
|
html={formatTextToHtml(blockHtmlText)}
|
||||||
onChange={handleBlockTextChange(block)}
|
onChange={handleBlockTextChange(block)}
|
||||||
|
onBlur={handleBlockTextBlur(block)}
|
||||||
|
autoFocus={true}
|
||||||
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
className="block w-full min-h-[120px] bg-white/5 backdrop-blur-md p-4 text-white/90
|
||||||
rounded-lg border-unset outline-none pb-12
|
rounded-lg border-unset outline-none pb-12
|
||||||
whitespace-pre-wrap break-words"
|
whitespace-pre-wrap break-words"
|
||||||
@ -84,6 +114,78 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderTypeBlock = (block: ScriptBlock, isHovered: boolean, isActive: boolean, isEditing: boolean) => {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'theme':
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{block.content.map((item, index) => (
|
||||||
|
<div key={index} className={`flex items-center gap-1 px-2 rounded-full ${Object.values(ThemeTagBgColor)[index]}`}>
|
||||||
|
<span className={`text-sm px-2 py-1 rounded-md`}>{item.text}</span>
|
||||||
|
<X className="w-4 h-4 cursor-pointer text-blue-500/80" onClick={() => console.log(item.text)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 新增主题标签 */}
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<div className='w-[10rem]'>
|
||||||
|
<SelectDropdown
|
||||||
|
dropdownId="theme-type"
|
||||||
|
label=""
|
||||||
|
options={Object.values(ThemeType).map(type => ({ label: type, value: type }))}
|
||||||
|
value={addThemeTag}
|
||||||
|
placeholder="Select Theme Type"
|
||||||
|
onChange={() => console.log('主题类型')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className='p-2 rounded-full bg-white/5 backdrop-blur-md'>
|
||||||
|
<Plus className="w-4 h-4 cursor-pointer text-white-600" onClick={() => console.log('新增主题标签')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{(isHovered || isActive) && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-4 right-4 flex gap-2"
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
>
|
||||||
|
<SquarePen
|
||||||
|
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setEditBlockId(block.id);
|
||||||
|
setActiveBlockId(block.id);
|
||||||
|
setIsInit(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Copy
|
||||||
|
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
|
||||||
|
toast.success('Copied!');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="leading-relaxed">
|
||||||
|
{isEditing ? (
|
||||||
|
renderEditBlock(block)
|
||||||
|
) : (
|
||||||
|
block.content.map((item, index) => (
|
||||||
|
<div key={index}>{renderContent(item)}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const renderBlock = (block: ScriptBlock) => {
|
const renderBlock = (block: ScriptBlock) => {
|
||||||
const isHovered = hoveredBlockId === block.id;
|
const isHovered = hoveredBlockId === block.id;
|
||||||
const isActive = activeBlockId === block.id;
|
const isActive = activeBlockId === block.id;
|
||||||
@ -93,7 +195,7 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={block.id}
|
key={block.id}
|
||||||
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
|
className={`relative p-4 mb-1 rounded-lg shadow-md transition-colors duration-300
|
||||||
${isActive ? 'bg-blue-500/20' : ''} hover:bg-blue-500/10`}
|
${isActive ? 'bg-slate-700/50' : ''} hover:bg-slate-700/30`}
|
||||||
ref={(el) => (contentRefs.current[block.id] = el)}
|
ref={(el) => (contentRefs.current[block.id] = el)}
|
||||||
onMouseEnter={() => setHoveredBlockId(block.id)}
|
onMouseEnter={() => setHoveredBlockId(block.id)}
|
||||||
onMouseLeave={() => setHoveredBlockId(null)}
|
onMouseLeave={() => setHoveredBlockId(null)}
|
||||||
@ -102,40 +204,9 @@ export const ScriptRenderer: React.FC<ScriptRendererProps> = ({ data }) => {
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
|
<h2 className="text-2xl font-semibold mb-1 text-blue-500">{block.title}</h2>
|
||||||
<AnimatePresence>
|
{
|
||||||
{(isHovered || isActive) && (
|
renderTypeBlock(block, isHovered, isActive, isEditing)
|
||||||
<motion.div
|
}
|
||||||
className="absolute top-4 right-4 flex gap-2"
|
|
||||||
initial={{ opacity: 0, y: -10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: -10 }}
|
|
||||||
>
|
|
||||||
<SquarePen
|
|
||||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
setEditBlockId(block.id);
|
|
||||||
setActiveBlockId(block.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Copy
|
|
||||||
className="w-6 h-6 p-1 cursor-pointer text-gray-600 hover:text-blue-500 transition-colors"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(block.content.map(item => item.text).join('\n'));
|
|
||||||
toast.success('Copied!');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
<div className="leading-relaxed">
|
|
||||||
{isEditing ? (
|
|
||||||
renderEditBlock(block)
|
|
||||||
) : (
|
|
||||||
block.content.map((item, index) => (
|
|
||||||
<div key={index}>{renderContent(item)}</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,16 +4,54 @@ export const mockScriptData: ScriptData = {
|
|||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: 'core',
|
id: 'core',
|
||||||
title: "SCENE'S CORE CONFLICT",
|
title: "SUMMARY",
|
||||||
type: 'core',
|
type: 'core',
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
text: 'The desperate, on-stage improvisation of a failing comedian triggers a physical and professional collapse, forcing him and his partner to confront the absurd meaninglessness of their ambitions. This scene serves as the inciting incident and rising action, where the internal conflict of artistic decay manifests as a literal, catastrophic failure, propelling them from a state of professional anxiety into a fight for survival.'
|
text: 'The desperate, on-stage improvisation of a failing comedian triggers a physical and professional collapse, forcing him and his partner to confront the absurd meaninglessness of their ambitions. This scene serves as the inciting incident and rising action, where the internal conflict of artistic decay manifests as a literal, catastrophic failure, propelling them from a state of professional anxiety into a fight for survival.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'theme',
|
||||||
|
title: 'THEME',
|
||||||
|
type: 'theme',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tag',
|
||||||
|
text: 'Satire'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tag',
|
||||||
|
text: 'Absurdist Comedy'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tag',
|
||||||
|
text: 'Disaster'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'roles',
|
||||||
|
title: 'ROLES',
|
||||||
|
type: 'roles',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'bold',
|
||||||
|
text: 'Anna'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
text: 'Anna is a young woman who is trying to find her place in the world. She is a bit of a mess, but she is also a bit of a mess.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'bold',
|
type: 'bold',
|
||||||
text: 'GENRE: Satire | Absurdist Comedy | Disaster'
|
text: 'Mark'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'card',
|
||||||
|
text: 'Mark starts as resentful and aimless, harboring guilt over past family conflicts. His involvement in Anna’s quest rekindles his sense of responsibility and belonging. He moves from avoidance and cynicism to active support, ultimately risking his own safety for Anna and the creature. Mark’s arc is one of redemption and reconnection.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,13 +2,20 @@ export interface ScriptBlock {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: ScriptContent[];
|
content: ScriptContent[];
|
||||||
type: 'core' | 'scene' | 'summary';
|
type: 'core' | 'scene' | 'summary' | 'theme' | 'roles';
|
||||||
sceneNumber?: number;
|
sceneNumber?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptContent {
|
export interface ScriptContent {
|
||||||
type: 'paragraph' | 'bold' | 'italic' | 'heading';
|
type: 'paragraph' | 'bold' | 'italic' | 'heading' | 'tag' | 'card';
|
||||||
text: string;
|
text?: string;
|
||||||
|
roleInfo?: {
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
role: string;
|
||||||
|
desc: string;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScriptData {
|
export interface ScriptData {
|
||||||
@ -23,4 +30,33 @@ export interface NavigationItem {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
type: 'core' | 'scene' | 'summary';
|
type: 'core' | 'scene' | 'summary';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题标签背景色 enum
|
||||||
|
export enum ThemeTagBgColor {
|
||||||
|
blue = 'bg-blue-500/20',
|
||||||
|
green = 'bg-green-500/20',
|
||||||
|
red = 'bg-red-500/20',
|
||||||
|
yellow = 'bg-yellow-500/20',
|
||||||
|
purple = 'bg-purple-500/20',
|
||||||
|
orange = 'bg-orange-500/20',
|
||||||
|
pink = 'bg-pink-500/20',
|
||||||
|
brown = 'bg-brown-500/20',
|
||||||
|
gray = 'bg-gray-500/20'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题类型 enum
|
||||||
|
export enum ThemeType {
|
||||||
|
satire = 'satire', // 讽刺
|
||||||
|
absurdistComedy = 'absurdistComedy', // 荒诞喜剧
|
||||||
|
disaster = 'disaster', // 灾难
|
||||||
|
tragedy = 'tragedy', // 悲剧
|
||||||
|
comedy = 'comedy', // 喜剧
|
||||||
|
drama = 'drama', // 戏剧
|
||||||
|
fantasy = 'fantasy', // 奇幻
|
||||||
|
horror = 'horror', // 恐怖
|
||||||
|
mystery = 'mystery', // 神秘
|
||||||
|
romance = 'romance', // 爱情
|
||||||
|
scienceFiction = 'scienceFiction', // 科幻
|
||||||
|
thriller = 'thriller', // 惊悚
|
||||||
}
|
}
|
||||||
@ -13,6 +13,7 @@ interface SelectDropdownProps {
|
|||||||
label: string;
|
label: string;
|
||||||
options: SettingOption[];
|
options: SettingOption[];
|
||||||
value: string;
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ export const SelectDropdown = (
|
|||||||
label,
|
label,
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
|
placeholder,
|
||||||
onChange
|
onChange
|
||||||
}: SelectDropdownProps
|
}: SelectDropdownProps
|
||||||
) => {
|
) => {
|
||||||
@ -35,7 +37,7 @@ export const SelectDropdown = (
|
|||||||
<div className="relative w-full h-full">
|
<div className="relative w-full h-full">
|
||||||
<motion.button
|
<motion.button
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-4 py-2 rounded-lg border text-left flex items-center justify-between",
|
"w-full p-2 rounded-lg border text-left flex items-center justify-between",
|
||||||
openDropdown === dropdownId
|
openDropdown === dropdownId
|
||||||
? "border-blue-500 bg-blue-500/10"
|
? "border-blue-500 bg-blue-500/10"
|
||||||
: "border-white/10 hover:border-white/20"
|
: "border-white/10 hover:border-white/20"
|
||||||
@ -44,7 +46,10 @@ export const SelectDropdown = (
|
|||||||
whileHover={{ scale: 1.01 }}
|
whileHover={{ scale: 1.01 }}
|
||||||
whileTap={{ scale: 0.99 }}
|
whileTap={{ scale: 0.99 }}
|
||||||
>
|
>
|
||||||
<span>{options.find(opt => opt.value === value)?.label || value}</span>
|
<div className="flex items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
|
<span>{options.find(opt => opt.value === value)?.label || value}</span>
|
||||||
|
{placeholder && <span className="text-gray-400/60">{placeholder}</span>}
|
||||||
|
</div>
|
||||||
<motion.div
|
<motion.div
|
||||||
animate={{ rotate: openDropdown === dropdownId ? 180 : 0 }}
|
animate={{ rotate: openDropdown === dropdownId ? 180 : 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user