又推进图片故事

This commit is contained in:
海龙 2025-08-18 18:58:15 +08:00
parent 76169e790d
commit 4904c29ce6
8 changed files with 555 additions and 596 deletions

View File

@ -19,7 +19,7 @@ export interface CharacterAnalysis {
/** 角色名称 */ /** 角色名称 */
role_name: string; role_name: string;
/** 角色区域 */ /** 角色区域 */
region: CharacterRegion; region: CharacterRegion|null;
/** 角色头像URL可选用于存储裁剪后的头像 */ /** 角色头像URL可选用于存储裁剪后的头像 */
avatarUrl?: string; avatarUrl?: string;
} }

View File

@ -27,7 +27,7 @@ export const actionTemplateStory = async (template: StoryTemplateEntity) => {
* AI分析图片 * AI分析图片
*/ */
export const AIGenerateImageStory = async (request: { export const AIGenerateImageStory = async (request: {
imageUrl: string; image_url: string;
user_text: string; user_text: string;
}) => { }) => {
return await post<ApiResponse<MovieStartDTO>>( return await post<ApiResponse<MovieStartDTO>>(

View File

@ -146,3 +146,32 @@ body {
outline-offset: 0 !important; outline-offset: 0 !important;
box-shadow: none !important; box-shadow: none !important;
} }
/* Tiptap 编辑器焦点样式覆盖 */
.ProseMirror:focus,
.ProseMirror:focus-visible,
.ProseMirror-focused {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.ProseMirror {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 确保编辑器内容区域没有默认样式 */
.ProseMirror p {
margin: 0;
padding: 0;
}
.ProseMirror p.is-editor-empty:first-child::before {
color: rgba(255, 255, 255, 0.4);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}

View File

@ -1,7 +1,7 @@
import { ImageStoryEntity } from "../domain/Entities"; import { ImageStoryEntity } from "../domain/Entities";
import { useUploadFile } from "../domain/service"; import { useUploadFile } from "../domain/service";
import { ImageStoryUseCase } from "../usecase/imageStoryUseCase"; import { ImageStoryUseCase } from "../usecase/imageStoryUseCase";
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, Dispatch, SetStateAction } from "react";
import { CharacterAnalysis } from "@/api/DTO/movie_start_dto"; import { CharacterAnalysis } from "@/api/DTO/movie_start_dto";
interface UseImageStoryService { interface UseImageStoryService {
@ -24,9 +24,9 @@ interface UseImageStoryService {
/** 计算后的角色头像数据 */ /** 计算后的角色头像数据 */
avatarComputed: Array<{ name: string; url: string }>; avatarComputed: Array<{ name: string; url: string }>;
/** 上传图片并分析 */ /** 上传图片并分析 */
uploadAndAnalyzeImage: (imageUrl: string) => Promise<void>; uploadAndAnalyzeImage: () => Promise<void>;
/** 触发文件选择并自动分析 */ /** 触发文件选择 */
triggerFileSelectionAndAnalyze: () => Promise<void>; triggerFileSelection: () => Promise<void>;
/** 触发生成剧本函数 */ /** 触发生成剧本函数 */
generateScript: () => Promise<string>; generateScript: () => Promise<string>;
/** 更新故事类型 */ /** 更新故事类型 */
@ -35,10 +35,11 @@ interface UseImageStoryService {
updateStoryContent: (content: string) => void; updateStoryContent: (content: string) => void;
/** 更新角色名称并同步到相关数据 */ /** 更新角色名称并同步到相关数据 */
updateCharacterName: (oldName: string, newName: string) => void; updateCharacterName: (oldName: string, newName: string) => void;
/** 同步角色名称到故事内容 */
syncRoleNameToContent: (oldName: string, newName: string) => void;
/** 重置图片故事数据 */ /** 重置图片故事数据 */
resetImageStory: () => void; resetImageStory: (showAnalysisState?: boolean) => void;
/** 完全重置到初始状态(包括预置数据) */ setCharactersAnalysis: Dispatch<SetStateAction<CharacterAnalysis[]>>
resetToInitialState: () => void;
} }
export const useImageStoryServiceHook = (): UseImageStoryService => { export const useImageStoryServiceHook = (): UseImageStoryService => {
@ -49,62 +50,25 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
}); });
// 图片相关状态 // 图片相关状态
const [activeImageUrl, setActiveImageUrl] = useState<string>("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1"); const [activeImageUrl, setActiveImageUrl] = useState<string>("");
// 故事内容状态统一管理用户输入和AI分析结果预置假数据 // 故事内容状态统一管理用户输入和AI分析结果
const [storyContent, setStoryContent] = useState<string>( const [storyContent, setStoryContent] = useState<string>("");
"在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。"
);
// 分析结果状态 // 分析结果状态
/** 角色头像及名称,预置假数据 */ /** 角色头像及名称 */
const [charactersAnalysis, setCharactersAnalysis] = useState< const [charactersAnalysis, setCharactersAnalysis] = useState<
CharacterAnalysis[] CharacterAnalysis[]
>([ >([]);
{ /** 分类数组 */
role_name: "艾米丽", const [potentialGenres, setPotentialGenres] = useState<string[]>([]);
region: {
x: 0.2,
y: 0.2,
width: 0.2,
height: 0.2,
},
id: "1"
},
{
role_name: "阿尔法",
region: {
x: 0.4,
y: 0.4,
width: 0.2,
height: 0.2,
},
id: "2"
},
{
role_name: "博士",
region: {
x: 0.6,
y: 0.6,
width: 0.2,
height: 0.2,
},
id: "3"
},
]);
/** 分类数组,预置假数据 */
const [potentialGenres, setPotentialGenres] = useState<string[]>([
"科幻",
"冒险",
"悬疑",
]);
// 分类状态 // 分类状态
const [selectedCategory, setSelectedCategory] = useState<string>("Auto"); const [selectedCategory, setSelectedCategory] = useState<string>("Auto");
// 流程状态 // 流程状态
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [hasAnalyzed, setHasAnalyzed] = useState(true); const [hasAnalyzed, setHasAnalyzed] = useState(false);
// 使用上传文件Hook // 使用上传文件Hook
const { uploadFile } = useUploadFile(); const { uploadFile } = useUploadFile();
@ -117,38 +81,44 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
* @param character - * @param character -
* @param imageUrl - URL * @param imageUrl - URL
*/ */
const generateAvatarFromRegion = useCallback((character: CharacterAnalysis, imageUrl: string) => { const generateAvatarFromRegion = useCallback(
(character: CharacterAnalysis, imageUrl: string) => {
if (!character.region || !character.region.width || !character.region.height) {
return;
}
// 创建图片对象 // 创建图片对象
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; // 处理跨域问题 img.crossOrigin = "anonymous"; // 处理跨域问题
img.onload = () => { img.onload = () => {
try { try {
// 根据百分比计算实际的像素坐标 // 根据百分比计算实际的像素坐标
const cropX = Math.round(character.region.x * img.width); const cropX = Math.round(character.region!.x * img.width);
const cropY = Math.round(character.region.y * img.height); const cropY = Math.round(character.region!.y * img.height);
const cropWidth = Math.round(character.region.width * img.width); const cropWidth = Math.round(character.region!.width * img.width);
const cropHeight = Math.round(character.region.height * img.height); const cropHeight = Math.round(character.region!.height * img.height);
console.log(cropX, cropY, cropWidth, cropHeight); console.log(cropX, cropY, cropWidth, cropHeight);
// 验证裁剪区域是否有效 // 验证裁剪区域是否有效
if (cropWidth <= 0 || cropHeight <= 0) { if (cropWidth <= 0 || cropHeight <= 0) {
console.error('裁剪区域无效:', { cropWidth, cropHeight }); console.error("裁剪区域无效:", { cropWidth, cropHeight });
return; return;
} }
if (cropX + cropWidth > img.width || cropY + cropHeight > img.height) { if (
console.error('裁剪区域超出图片边界'); cropX + cropWidth > img.width ||
cropY + cropHeight > img.height
) {
console.error("裁剪区域超出图片边界");
return; return;
} }
// 创建canvas元素用于图片裁剪 // 创建canvas元素用于图片裁剪
const canvas = document.createElement('canvas'); const canvas = document.createElement("canvas");
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext("2d");
if (!ctx) { if (!ctx) {
console.error('无法创建canvas上下文'); console.error("无法创建canvas上下文");
return; return;
} }
@ -162,43 +132,55 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
// 在canvas上绘制裁剪后的图片部分 // 在canvas上绘制裁剪后的图片部分
ctx.drawImage( ctx.drawImage(
img, img,
cropX, cropY, cropWidth, cropHeight, // 源图片裁剪区域 cropX,
0, 0, cropWidth, cropHeight // 目标canvas区域 cropY,
cropWidth,
cropHeight, // 源图片裁剪区域
0,
0,
cropWidth,
cropHeight // 目标canvas区域
); );
// 将canvas转换为blob并创建临时URL // 将canvas转换为blob并创建临时URL
canvas.toBlob((blob) => { canvas.toBlob(
(blob) => {
if (blob) { if (blob) {
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
console.log('成功生成头像URL:', url, '大小:', blob.size); console.log("成功生成头像URL:", url, "大小:", blob.size);
// 更新角色头像URL // 更新角色头像URL
setCharactersAnalysis(prev => setCharactersAnalysis((prev) =>
prev.map(char => prev.map((char) =>
char.role_name === character.role_name char.role_name === character.role_name
? { ...char, avatarUrl: url } ? { ...char, avatarUrl: url }
: char : char
) )
); );
} else { } else {
console.error('Canvas转Blob失败'); console.error("Canvas转Blob失败");
} }
}, 'image/jpeg', 0.9); },
"image/jpeg",
0.9
);
// 清理canvas // 清理canvas
canvas.remove(); canvas.remove();
} catch (error) { } catch (error) {
console.error('生成角色头像失败:', error); console.error("生成角色头像失败:", error);
} }
}; };
img.onerror = () => { img.onerror = () => {
console.error('加载图片失败:', imageUrl); console.error("加载图片失败:", imageUrl);
}; };
// 开始加载图片 // 开始加载图片
img.src = imageUrl; img.src = imageUrl;
}, [setCharactersAnalysis]); },
[setCharactersAnalysis]
);
/** /**
* URL * URL
@ -223,7 +205,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
return { return {
name: character.role_name, name: character.role_name,
url: '', // 初始为空,异步生成完成后会更新 url: "", // 初始为空,异步生成完成后会更新
}; };
}); });
}, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]); }, [charactersAnalysis, activeImageUrl, generateAvatarFromRegion]);
@ -232,18 +214,19 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
* @param {string} imageUrl - URL * @param {string} imageUrl - URL
*/ */
const uploadAndAnalyzeImage = useCallback( const uploadAndAnalyzeImage = useCallback(
async (imageUrl: string): Promise<void> => { async (): Promise<void> => {
try { try {
console.log('123123123', 123123123)
setIsLoading(true); setIsLoading(true);
// 调用用例处理图片上传和分析 // 调用用例处理图片上传和分析
await imageStoryUseCase.handleImageUpload(imageUrl); await imageStoryUseCase.handleImageUpload(activeImageUrl);
// 获取更新后的数据 // 获取更新后的数据
const updatedStory = imageStoryUseCase.getStoryLogline(); const updatedStory = imageStoryUseCase.storyLogline;
const updatedCharacters = imageStoryUseCase.getCharactersAnalysis(); const updatedCharacters = imageStoryUseCase.charactersAnalysis;
const updatedGenres = imageStoryUseCase.getPotentialGenres(); const updatedGenres = imageStoryUseCase.potentialGenres;
const updatedImageStory = imageStoryUseCase.getImageStory(); const updatedImageStory = imageStoryUseCase.imageStory;
// 更新所有响应式状态 // 更新所有响应式状态
setCharactersAnalysis(updatedCharacters); setCharactersAnalysis(updatedCharacters);
@ -251,12 +234,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
setImageStory(updatedImageStory); setImageStory(updatedImageStory);
// 将AI分析的故事内容直接更新到统一的故事内容字段 // 将AI分析的故事内容直接更新到统一的故事内容字段
setStoryContent(updatedStory || ""); updateStoryContent(updatedStory || "");
setSelectedCategory("Auto");
// 设置第一个分类为默认选中
if (updatedGenres.length > 0) {
setSelectedCategory(updatedGenres[0]);
}
// 标记已分析 // 标记已分析
setHasAnalyzed(true); setHasAnalyzed(true);
@ -267,7 +246,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
setIsLoading(false); setIsLoading(false);
} }
}, },
[imageStoryUseCase] [activeImageUrl, imageStoryUseCase]
); );
/** /**
@ -323,30 +302,47 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
*/ */
const updateStoryContent = useCallback((content: string): void => { const updateStoryContent = useCallback((content: string): void => {
setStoryContent(content); setStoryContent(content);
}, []); imageStoryUseCase.updateStoryContent(content);
}, [imageStoryUseCase]);
/**
*
* @param {string} oldName -
* @param {string} newName -
*/
const syncRoleNameToContent = useCallback(
(oldName: string, newName: string) => {
// 更新故事内容中的角色标签
setStoryContent((prev) => {
const regex = new RegExp(`<role_name>${oldName}<\/role_name>`, "g");
const content = prev.replace(regex, `<role_name>${newName}</role_name>`);
imageStoryUseCase.updateStoryContent(content);
return content;
});
},
[imageStoryUseCase]
);
/** /**
* *
* @param {string} oldName - * @param {string} oldName -
* @param {string} newName - * @param {string} newName -
*/ */
const updateCharacterName = useCallback((oldName: string, newName: string): void => { const updateCharacterName = useCallback(
(oldName: string, newName: string): void => {
// 更新角色分析数据中的名称 // 更新角色分析数据中的名称
setCharactersAnalysis(prev => setCharactersAnalysis((prev) =>
prev.map(char => prev.map((char) =>
char.role_name === oldName char.role_name === oldName ? { ...char, role_name: newName } : char
? { ...char, role_name: newName }
: char
) )
); );
// 同步更新故事内容中的角色名称 // 同步更新故事内容中的角色名称
setStoryContent(prev => { syncRoleNameToContent(oldName, newName);
// 使用正则表达式进行全局替换,确保大小写匹配 },
const regex = new RegExp(`\\b${oldName}\\b`, 'g'); [syncRoleNameToContent]
return prev.replace(regex, newName); );
});
}, []);
/** /**
* *
@ -355,8 +351,8 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
imageStoryUseCase.resetImageStory(); imageStoryUseCase.resetImageStory();
// 清理生成的头像URL避免内存泄漏 // 清理生成的头像URL避免内存泄漏
setCharactersAnalysis(prev => { setCharactersAnalysis((prev) => {
prev.forEach(char => { prev.forEach((char) => {
if (char.avatarUrl) { if (char.avatarUrl) {
URL.revokeObjectURL(char.avatarUrl); URL.revokeObjectURL(char.avatarUrl);
} }
@ -370,76 +366,17 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
storyType: "auto", storyType: "auto",
}); });
setActiveImageUrl(""); setActiveImageUrl("");
setStoryContent(""); updateStoryContent("");
setPotentialGenres([]); setPotentialGenres([]);
setSelectedCategory("auto"); setSelectedCategory("auto");
setHasAnalyzed(false); setHasAnalyzed(false);
setIsLoading(false); setIsLoading(false);
}, [imageStoryUseCase]); }, [imageStoryUseCase]);
/**
*
*/
const resetToInitialState = useCallback((): void => {
// 清理生成的头像URL避免内存泄漏
setCharactersAnalysis(prev => {
prev.forEach(char => {
if (char.avatarUrl) {
URL.revokeObjectURL(char.avatarUrl);
}
});
return [];
});
// 重置所有状态到初始值
setImageStory({
imageUrl: "",
storyType: "auto",
});
setActiveImageUrl("https://cdn.qikongjian.com/1755316261_gmw4yq.mp4?vframe/jpg/offset/1");
setStoryContent("在一个遥远的未来城市,机器人与人类共存,但一场突如其来的危机打破了平衡。艾米丽,阿尔法,博士互相帮助,共同解决危机。");
setCharactersAnalysis([
{
role_name: "艾米丽",
region: {
x: 20,
y: 20,
width: 20,
height: 20,
},
id: "1"
},
{
role_name: "阿尔法",
region: {
x: 40,
y: 40,
width: 20,
height: 20,
},
id: "2"
},
{
role_name: "博士",
region: {
x: 60,
y: 60,
width: 20,
height: 20,
},
id: "3"
},
]);
setPotentialGenres(["科幻", "冒险", "悬疑"]);
setSelectedCategory("auto");
setHasAnalyzed(true);
setIsLoading(false);
}, []);
/** /**
* *
*/ */
const triggerFileSelectionAndAnalyze = const triggerFileSelection =
useCallback(async (): Promise<void> => { useCallback(async (): Promise<void> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 创建文件输入元素 // 创建文件输入元素
@ -469,8 +406,6 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
imageUrl: uploadedImageUrl, imageUrl: uploadedImageUrl,
})); }));
// 自动开始分析
await uploadAndAnalyzeImage(uploadedImageUrl);
} }
resolve(); resolve();
} catch (error) { } catch (error) {
@ -490,7 +425,7 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
document.body.appendChild(fileInput); document.body.appendChild(fileInput);
fileInput.click(); fileInput.click();
}); });
}, [uploadFile, uploadAndAnalyzeImage]); }, [uploadFile]);
return { return {
imageStory, imageStory,
@ -502,13 +437,14 @@ export const useImageStoryServiceHook = (): UseImageStoryService => {
isLoading, isLoading,
hasAnalyzed, hasAnalyzed,
avatarComputed, avatarComputed,
setCharactersAnalysis,
uploadAndAnalyzeImage, uploadAndAnalyzeImage,
triggerFileSelectionAndAnalyze, triggerFileSelection,
generateScript, generateScript,
updateStoryType, updateStoryType,
updateStoryContent, updateStoryContent,
updateCharacterName, updateCharacterName,
syncRoleNameToContent,
resetImageStory, resetImageStory,
resetToInitialState,
}; };
}; };

View File

@ -112,7 +112,7 @@ export interface ImageStoryEntity {
width: number; width: number;
/** 高度 */ /** 高度 */
height: number; height: number;
}; } | null;
}[]; }[];
} }
/** /**

View File

@ -11,7 +11,7 @@ export class ImageStoryUseCase {
imageStory: Partial<ImageStoryEntity> = { imageStory: Partial<ImageStoryEntity> = {
imageUrl: "", imageUrl: "",
imageStory: "", imageStory: "",
storyType: "auto", storyType: "Auto",
}; };
/** 故事梗概 */ /** 故事梗概 */
@ -31,53 +31,7 @@ export class ImageStoryUseCase {
constructor() {} constructor() {}
/**
*
* @returns {Partial<ImageStoryEntity>}
*/
getImageStory(): Partial<ImageStoryEntity> {
return { ...this.imageStory };
}
/**
*
* @returns {string}
*/
getStoryLogline(): string {
return this.storyLogline;
}
/**
*
* @returns {CharacterAnalysis[]}
*/
getCharactersAnalysis(): CharacterAnalysis[] {
return [...this.charactersAnalysis];
}
/**
*
* @returns {string[]}
*/
getPotentialGenres(): string[] {
return [...this.potentialGenres];
}
/**
*
* @returns {boolean}
*/
getAnalyzingStatus(): boolean {
return this.isAnalyzing;
}
/**
*
* @returns {boolean}
*/
getUploadingStatus(): boolean {
return this.isUploading;
}
/** /**
* *
@ -94,7 +48,7 @@ export class ImageStoryUseCase {
this.imageStory = { this.imageStory = {
imageUrl: "", imageUrl: "",
imageStory: "", imageStory: "",
storyType: "auto", storyType: "Auto",
}; };
this.storyLogline = ""; this.storyLogline = "";
this.charactersAnalysis = []; this.charactersAnalysis = [];
@ -112,7 +66,7 @@ export class ImageStoryUseCase {
try { try {
this.isUploading = false; // 图片已上传设置上传状态为false this.isUploading = false; // 图片已上传设置上传状态为false
this.isAnalyzing = true; this.isAnalyzing = true;
console.log('imageUrl', imageUrl)
// 设置上传后的图片URL // 设置上传后的图片URL
this.setImageStory({ imageUrl }); this.setImageStory({ imageUrl });
@ -134,10 +88,11 @@ export class ImageStoryUseCase {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async analyzeImageWithAI(): Promise<void> { async analyzeImageWithAI(): Promise<void> {
console.log('this.imageStory.imageUrl', this.imageStory.imageUrl)
try { try {
// 调用AI分析接口 // 调用AI分析接口
const response = await AIGenerateImageStory({ const response = await AIGenerateImageStory({
imageUrl: this.imageStory.imageUrl || "", image_url: this.imageStory.imageUrl || "",
user_text: this.imageStory.imageStory || "", user_text: this.imageStory.imageStory || "",
}); });
@ -181,17 +136,17 @@ export class ImageStoryUseCase {
name: character.role_name, name: character.role_name,
avatar_url: "", // 这里需要根据实际情况设置头像URL avatar_url: "", // 这里需要根据实际情况设置头像URL
region: { region: {
x: character.region.x, x: character.region?.x || 0,
y: character.region.y, y: character.region?.y || 0,
width: character.region.width, width: character.region?.width || 0,
height: character.region.height, height: character.region?.height || 0,
} }
})) || []; })) || [];
// 更新ImageStoryEntity // 更新ImageStoryEntity
this.setImageStory({ this.setImageStory({
imageAnalysis: data.story_logline || "", imageAnalysis: data.story_logline || "",
storyType: data.potential_genres?.[0] || "auto", // 使用第一个分类作为故事类型 storyType: "Auto", // 使用第一个分类作为故事类型
roleImage, roleImage,
}); });
} }
@ -211,22 +166,6 @@ export class ImageStoryUseCase {
updateStoryContent(storyContent: string): void { updateStoryContent(storyContent: string): void {
this.setImageStory({ imageStory: storyContent }); this.setImageStory({ imageStory: storyContent });
} }
/**
*
* @returns {Array<{key: string, label: string}>
*/
getStoryTypeOptions(): Array<{ key: string; label: string }> {
return [
{ key: "auto", label: "Auto" },
{ key: "adventure", label: "Adventure" },
{ key: "romance", label: "Romance" },
{ key: "mystery", label: "Mystery" },
{ key: "fantasy", label: "Fantasy" },
{ key: "comedy", label: "Comedy" },
];
}
/** /**
* *
* @param {CharacterAnalysis[]} characters - * @param {CharacterAnalysis[]} characters -
@ -235,10 +174,10 @@ export class ImageStoryUseCase {
this.charactersAnalysis = characters.map(character => ({ this.charactersAnalysis = characters.map(character => ({
...character, ...character,
region: { region: {
x: character.region.x, x: character.region?.x || 0,
y: character.region.y, y: character.region?.y || 0,
width: character.region.width, width: character.region?.width || 0,
height: character.region.height, height: character.region?.height || 0,
} }
})); }));
} }

View File

@ -30,6 +30,10 @@ import { AudioRecorder } from "./AudioRecorder";
import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService"; import { useTemplateStoryServiceHook } from "@/app/service/Interaction/templateStoryService";
import { createScriptEpisodeNew } from "@/api/script_episode"; import { createScriptEpisodeNew } from "@/api/script_episode";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { HighlightTextExtension } from "@/components/ui/main-editor/HighlightText";
import Placeholder from "@tiptap/extension-placeholder";
// 自定义音频播放器样式 // 自定义音频播放器样式
const customAudioPlayerStyles = ` const customAudioPlayerStyles = `
@ -453,7 +457,7 @@ const RenderTemplateStoryMode = ({
}} }}
footer={null} footer={null}
width="60%" width="60%"
style={{ maxWidth: "800px" }} style={{ maxWidth: "800px", marginTop: "10vh" }}
className="template-modal" className="template-modal"
closeIcon={ closeIcon={
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors"> <div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
@ -591,37 +595,33 @@ export function ChatInputBox() {
{/* 右上角齿轮图标和配置项 */} {/* 右上角齿轮图标和配置项 */}
{!isExpanded && ( {!isExpanded && (
<div className="absolute top-2 right-2 z-10 flex items-center"> <div className="absolute top-2 right-2 z-10 flex items-center">
{/* 配置项显示区域 - 从齿轮图标右侧边缘滑入 */} {/* 使用 Dropdown 替代手动控制显示/隐藏 */}
<div <Dropdown
className={`relative bottom-16 flex items-center gap-2 bg-white/[0.18] backdrop-blur-[40px] border border-white/[0.12] rounded-lg p-2 transition-all duration-500 ease-out mr-2 ${ open={showConfigOptions}
showConfigOptions onOpenChange={setShowConfigOptions}
? " opacity-100" popupRender={() => (
: "opacity-0 pointer-events-none" <div className="bg-white/[0.08] border border-white/[0.12] rounded-lg shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
}`}
style={{
transformOrigin: "left center",
willChange: "transform, opacity",
}}
>
<ConfigOptions <ConfigOptions
config={configOptions} config={configOptions}
onConfigChange={(key, value) => onConfigChange={(key, value) =>
setConfigOptions((prev) => ({ ...prev, [key]: value })) setConfigOptions((prev) => ({ ...prev, [key]: value }))
} }
compact={true}
/> />
</div> </div>
)}
{/* 配置项显示控制按钮 - 齿轮图标,位置完全固定 */} placement={"left" as any}
trigger={["click"]}
>
{/* 配置项显示控制按钮 - 齿轮图标 */}
<Tooltip title="config" placement="top"> <Tooltip title="config" placement="top">
<button <button
data-alt="config-toggle-button" data-alt="config-toggle-button"
className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200" className="flex items-center justify-center w-8 h-8 bg-white/[0.1] hover:bg-white/[0.2] rounded-lg border border-white/[0.2] transition-all duration-200"
onClick={() => setShowConfigOptions(!showConfigOptions)}
> >
<Settings className="w-4 h-4 text-white/80" /> <Settings className="w-4 h-4 text-white/80" />
</button> </button>
</Tooltip> </Tooltip>
</Dropdown>
</div> </div>
)} )}
@ -711,6 +711,7 @@ export function ChatInputBox() {
<ActionButton <ActionButton
isCreating={isCreating} isCreating={isCreating}
handleCreateVideo={handleCreateVideo} handleCreateVideo={handleCreateVideo}
icon={<Clapperboard className="w-5 h-5" />}
/> />
</div> </div>
</div> </div>
@ -739,28 +740,27 @@ export function ChatInputBox() {
const ActionButton = ({ const ActionButton = ({
isCreating, isCreating,
handleCreateVideo, handleCreateVideo,
icon,
}: { }: {
isCreating: boolean; isCreating: boolean;
handleCreateVideo: () => void; handleCreateVideo: () => void;
icon: React.ReactNode;
}) => { }) => {
return ( return (
<div className="relative group"> <div className="relative group">
<div <div
data-alt="action-button" data-alt="action-button"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10" className="relative w-12 h-12 opacity-90 cursor-pointer overflow-hidden rounded-xl bg-black z-10"
onClick={isCreating ? undefined : handleCreateVideo}
> >
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div> <div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black"> <div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
<button <button
name="text" name="text"
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center" className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
onClick={isCreating ? undefined : handleCreateVideo}
> >
{isCreating ? ( {isCreating ? <Loader2 className="w-5 h-5 animate-spin" /> : icon}
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Clapperboard className="w-5 h-5" />
)}
</button> </button>
</div> </div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div> <div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
@ -776,7 +776,6 @@ const ActionButton = ({
const ConfigOptions = ({ const ConfigOptions = ({
config, config,
onConfigChange, onConfigChange,
compact = false,
}: { }: {
config: { config: {
mode: string; mode: string;
@ -785,7 +784,6 @@ const ConfigOptions = ({
videoDuration: string; videoDuration: string;
}; };
onConfigChange: (key: string, value: string) => void; onConfigChange: (key: string, value: string) => void;
compact?: boolean;
}) => { }) => {
const configItems = [ const configItems = [
{ {
@ -828,7 +826,7 @@ const ConfigOptions = ({
]; ];
return ( return (
<div className={`flex items-center gap-3 ${compact ? "gap-2" : "mt-3"}`}> <div className={`flex items-center p-2 gap-2`}>
{configItems.map((item) => { {configItems.map((item) => {
const IconComponent = item.icon; const IconComponent = item.icon;
const currentOption = item.options.find( const currentOption = item.options.find(
@ -856,22 +854,12 @@ const ConfigOptions = ({
> >
<button <button
data-alt={`config-${item.key}`} data-alt={`config-${item.key}`}
className={`flex items-center gap-2 px-2.5 py-1.5 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 ${ className={`flex items-center gap-2 bg-white/[0.05] backdrop-blur-xl border border-white/[0.1] rounded-lg text-white/80 hover:bg-white/[0.08] hover:border-white/[0.2] transition-all duration-200 px-2 py-1`}
compact ? "px-2 py-1" : ""
}`}
> >
<IconComponent <IconComponent className={"w-3 h-3"} />
className={`${compact ? "w-3 h-3" : "w-3.5 h-3.5"}`} <span className={"text-xs"}>{currentOption?.label}</span>
/>
<span className={`${compact ? "text-xs" : "text-xs"}`}>
{currentOption?.label}
</span>
{currentOption?.isVip && ( {currentOption?.isVip && (
<Crown <Crown className={`w-2 h-2 text-yellow-500`} />
className={`${
compact ? "w-2 h-2" : "w-2.5 h-2.5"
} text-yellow-500`}
/>
)} )}
</button> </button>
</Dropdown> </Dropdown>
@ -881,6 +869,134 @@ const ConfigOptions = ({
); );
}; };
/**
*
* 使 Tiptap
*/
const RoleHighlightEditor = ({
content,
onContentChange,
}: {
content: string;
onContentChange: (content: string) => void;
}) => {
const editor = useEditor({
extensions: [
StarterKit,
HighlightTextExtension,
Placeholder.configure({
placeholder:
"Share your creative ideas about the image and let AI create a movie story for you...",
emptyEditorClass: "is-editor-empty",
}),
],
content: "",
// 简化:移除复杂的 onUpdate 逻辑,只处理基本的文本变化
onUpdate: ({ editor }) => {
const textContent = editor.getText();
if (!textContent.trim()) {
onContentChange("");
return;
}
// 直接传递文本内容,不进行复杂的标签重建
onContentChange(textContent);
},
editorProps: {
handleKeyDown: (view, event) => {
const { from, to } = view.state.selection;
const doc = view.state.doc;
// 检查光标前后是否有角色标签
const textBefore =
from > 0 ? doc.textBetween(Math.max(0, from - 50), from) : "";
const textAfter =
to < doc.content.size
? doc.textBetween(to, Math.min(doc.content.size, to + 50))
: "";
// TODO role id 的结构
const beforeMatch = textBefore.match(/<role_name>[^<]*$/);
const afterMatch = textAfter.match(/^[^>]*<\/role_name>/);
if (beforeMatch || afterMatch) {
if (event.key !== "Backspace" && event.key !== "Delete") {
event.preventDefault();
return true;
}
}
return false;
},
},
immediatelyRender: false,
});
useEffect(() => {
if (editor) {
if (!content || content.trim() === "") {
editor.commands.clearContent(true);
return;
}
// 将带标签的内容转换为高亮显示
const htmlContent = content.replace(
/<role_name>([^<]+)<\/role_name>/g,
'<highlight-text type="role" text="$1" color="blue">$1</highlight-text>'
);
editor.commands.setContent(htmlContent, { emitUpdate: false });
}
}, [content, editor]);
return (
<div className="flex-1 min-w-0 relative pr-20">
<style jsx>{`
.role-name-highlight {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white !important;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
margin: 0 2px;
user-select: none;
cursor: default;
pointer-events: none;
}
/* 移除 Tiptap 编辑器的默认焦点样式 */
.ProseMirror:focus {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
.ProseMirror:focus-visible {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 移除编辑器容器的焦点样式 */
.ProseMirror-focused {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
/* 确保编辑器内容区域没有边框和轮廓 */
.ProseMirror {
outline: none !important;
border: none !important;
box-shadow: none !important;
}
`}</style>
<EditorContent
editor={editor}
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed min-h-[60px] focus:outline-none focus:ring-0 focus:border-0"
style={{ outline: "none", border: "none", boxShadow: "none" }}
/>
</div>
);
};
/** /**
* *
* AI分析和故事生成功能UI变化 * AI分析和故事生成功能UI变化
@ -907,9 +1023,10 @@ const PhotoStoryModal = ({
updateStoryContent, updateStoryContent,
updateCharacterName, updateCharacterName,
resetImageStory, resetImageStory,
resetToInitialState, triggerFileSelection,
triggerFileSelectionAndAnalyze,
avatarComputed, avatarComputed,
uploadAndAnalyzeImage,
setCharactersAnalysis,
} = useImageStoryServiceHook(); } = useImageStoryServiceHook();
// 重置状态 // 重置状态
@ -921,7 +1038,7 @@ const PhotoStoryModal = ({
// 处理图片上传 // 处理图片上传
const handleImageUpload = async () => { const handleImageUpload = async () => {
try { try {
await triggerFileSelectionAndAnalyze(); await triggerFileSelection();
} catch (error) { } catch (error) {
console.error("Failed to upload image:", error); console.error("Failed to upload image:", error);
} }
@ -940,7 +1057,7 @@ const PhotoStoryModal = ({
onCancel={handleClose} onCancel={handleClose}
footer={null} footer={null}
width="80%" width="80%"
style={{ maxWidth: "1000px" }} style={{ maxWidth: "1000px", marginTop: "10vh" }}
className="photo-story-modal" className="photo-story-modal"
closeIcon={ closeIcon={
<div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors"> <div className="w-6 h-6 bg-white/10 rounded-full flex items-center justify-center hover:bg-white/20 transition-colors">
@ -953,22 +1070,23 @@ const PhotoStoryModal = ({
<Spin spinning={isLoading} tip="Processing..."> <Spin spinning={isLoading} tip="Processing...">
<div className="rounded-2xl"> <div className="rounded-2xl">
{/* 弹窗头部 */} {/* 弹窗头部 */}
<div className="flex items-center gap-3 p-6 border-b border-white/[0.1]"> <div className="flex items-center gap-3 p-2 border-b border-white/[0.1]">
<ImagePlay className="w-6 h-6 text-blue-400" /> <ImagePlay className="w-6 h-6 text-blue-400" />
<h2 className="text-xl font-bold text-white"> <h2 className="text-xl font-bold text-white">
Photo Story Creation Movie Generation from Image
</h2> </h2>
</div> </div>
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4 mt-2">
<div className="p-6">
<div className="mb-6">
<div className="w-full bg-white/[0.05] border border-white/[0.1] rounded-xl p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* 左侧:图片上传 */} {/* 左侧:图片上传 */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div <div
data-alt="image-upload-area" data-alt="image-upload-area"
className="w-20 h-20 border-2 border-dashed border-white/20 rounded-lg flex flex-col items-center justify-center hover:border-white/40 transition-all duration-300 cursor-pointer bg-white/[0.02] hover:bg-white/[0.05] hover:scale-105" className={`w-20 h-20 rounded-lg flex flex-col items-center justify-center transition-all duration-300 cursor-pointer ${
activeImageUrl
? "border-2 border-white/20 bg-white/[0.05]"
: "border-2 border-dashed border-white/20 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05] hover:scale-105"
}`}
onClick={handleImageUpload} onClick={handleImageUpload}
> >
{activeImageUrl ? ( {activeImageUrl ? (
@ -983,7 +1101,6 @@ const PhotoStoryModal = ({
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
resetImageStory(); resetImageStory();
resetToInitialState();
}} }}
className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors" className="absolute top-1 right-1 w-4 h-4 bg-black/60 hover:bg-black/80 rounded-full flex items-center justify-center text-white/80 hover:text-white transition-colors"
data-alt="clear-all-button" data-alt="clear-all-button"
@ -1000,10 +1117,9 @@ const PhotoStoryModal = ({
)} )}
</div> </div>
</div> </div>
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
{/* 中间:头像展示(分析后显示) */} {/* 中间:头像展示(分析后显示) */}
{hasAnalyzed && avatarComputed.length > 0 && ( {hasAnalyzed && avatarComputed.length > 0 && (
<div className="flex-1 animate-in fade-in-0 slide-in-from-left-4 duration-300">
<div className="flex gap-2 n justify-start"> <div className="flex gap-2 n justify-start">
{avatarComputed.map((avatar, index) => ( {avatarComputed.map((avatar, index) => (
<div <div
@ -1022,15 +1138,21 @@ const PhotoStoryModal = ({
}} }}
/> />
{/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */} {/* 删除角色按钮 - 使用Tooltip并调整z-index避免被遮挡 */}
<Tooltip title="Remove this character from the movie" placement="top"> <Tooltip
title="Remove this character from the movie"
placement="top"
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// 从角色分析中删除该角色 // 从角色分析中删除该角色
setCharactersAnalysis((charactersAnalysis) => {
const updatedCharacters = const updatedCharacters =
charactersAnalysis.filter( charactersAnalysis.filter(
(char) => char.role_name !== avatar.name (char) => char.role_name !== avatar.name
); );
return updatedCharacters;
});
// 从故事内容中删除该角色名称 // 从故事内容中删除该角色名称
const updatedStory = storyContent const updatedStory = storyContent
.replace( .replace(
@ -1042,10 +1164,6 @@ const PhotoStoryModal = ({
// 更新状态 // 更新状态
updateStoryContent(updatedStory); updateStoryContent(updatedStory);
// 注意:这里需要直接更新 charactersAnalysis但 hook 中没有提供 setter // 注意:这里需要直接更新 charactersAnalysis但 hook 中没有提供 setter
// 暂时通过重新分析图片来刷新状态
if (activeImageUrl) {
triggerFileSelectionAndAnalyze();
}
}} }}
className="absolute top-1 right-1 w-4 h-4 bg-black/[0.05] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10" className="absolute top-1 right-1 w-4 h-4 bg-black/[0.05] border border-black/[0.1] text-white rounded-full flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 z-10"
> >
@ -1056,13 +1174,7 @@ const PhotoStoryModal = ({
<div className="relative group"> <div className="relative group">
<input <input
type="text" type="text"
value={avatar.name} defaultValue={avatar.name}
onChange={(e) => {
const newName = e.target.value.trim();
if (newName && newName !== avatar.name) {
updateCharacterName(avatar.name, newName);
}
}}
onBlur={(e) => { onBlur={(e) => {
const newName = e.target.value.trim(); const newName = e.target.value.trim();
if (newName && newName !== avatar.name) { if (newName && newName !== avatar.name) {
@ -1077,9 +1189,8 @@ const PhotoStoryModal = ({
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
</div>
{/* 右侧:分类选择(分析后显示) */} {/* 右侧:分类选择(分析后显示) */}
{hasAnalyzed && potentialGenres.length > 0 && ( {hasAnalyzed && potentialGenres.length > 0 && (
<div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300"> <div className="flex-shrink-0 animate-in fade-in-0 slide-in-from-right-4 duration-300">
@ -1101,78 +1212,38 @@ const PhotoStoryModal = ({
</div> </div>
)} )}
</div> </div>
<div className="flex items-start gap-4 mt-2"> <div className="flex items-start gap-4 mt-2 relative">
{/* 文本输入框 */} {/* 文本输入框 */}
<div className="flex-1 min-w-0 relative pr-20"> <RoleHighlightEditor
<textarea content={storyContent}
data-alt="story-description-input" onContentChange={updateStoryContent}
value={storyContent}
onChange={(e) => updateStoryContent(e.target.value)}
placeholder={
hasAnalyzed
? "AI analyzed story content..."
: "Describe your story idea..."
}
className="w-full bg-transparent border-none outline-none resize-none text-white placeholder:text-white/40 text-sm leading-relaxed"
style={{ minHeight: "60px" }}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement;
target.style.height = "auto";
target.style.height =
Math.max(target.scrollHeight, 60) + "px";
}}
/> />
<div className="absolute bottom-1 right-0 flex gap-2"> <div className="absolute bottom-1 right-0 flex gap-2">
{!hasAnalyzed ? ( {!hasAnalyzed ? (
// 分析按钮 - 使用ActionButton样式 // 分析按钮 - 使用ActionButton样式
<Tooltip title="Analyze image content" placement="top"> <Tooltip
<div className="relative group"> title={
<div activeImageUrl
data-alt="analyze-button" ? "Analyze image content"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10" : "Please upload an image first"
}
placement="top"
> >
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div> <ActionButton
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black"> isCreating={isLoading}
<button handleCreateVideo={uploadAndAnalyzeImage}
onClick={() => {}} // 分析已经在triggerFileSelectionAndAnalyze中自动完成 icon={<Sparkles className="w-5 h-5" />}
disabled={!activeImageUrl || isLoading} />
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Sparkles className="w-5 h-5" />
)}
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
</div>
</div>
</Tooltip> </Tooltip>
) : ( ) : (
<> <>
{/* Action按钮 - 使用ActionButton样式 */} {/* Action按钮 - 使用ActionButton样式 */}
<Tooltip <Tooltip title="Confirm story creation" placement="top">
title="Confirm story creation" <ActionButton
placement="top" isCreating={isLoading}
> handleCreateVideo={handleConfirm}
<div className="relative group"> icon={<Clapperboard className="w-5 h-5" />}
<div />
data-alt="action-button"
className="relative w-12 h-12 opacity-90 overflow-hidden rounded-xl bg-black z-10"
>
<div className="absolute z-10 -translate-x-12 group-hover:translate-x-12 transition-all duration-700 h-full w-12 bg-gradient-to-r from-gray-500 to-white/10 opacity-30 -skew-x-12"></div>
<div className="absolute flex items-center justify-center text-white z-[1] opacity-90 rounded-xl inset-0.5 bg-black">
<button
onClick={handleConfirm}
className="w-full h-full opacity-90 rounded-xl bg-black flex items-center justify-center"
>
<Clapperboard className="w-5 h-5" />
</button>
</div>
<div className="absolute duration-1000 group-hover:animate-spin w-full h-12 bg-gradient-to-r from-[rgb(106,244,249)] to-[rgb(199,59,255)] blur-[20px]"></div>
</div>
</div>
</Tooltip> </Tooltip>
</> </>
)} )}
@ -1180,9 +1251,6 @@ const PhotoStoryModal = ({
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div>
</Spin> </Spin>
</Modal> </Modal>
); );

View File

@ -1,8 +1,5 @@
import { Node, mergeAttributes } from '@tiptap/core' import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react' import { ReactNodeViewRenderer, NodeViewWrapper, ReactNodeViewProps } from '@tiptap/react'
import { motion, AnimatePresence } from 'framer-motion'
import { useState } from 'react'
import { Check } from 'lucide-react'
interface HighlightTextAttributes { interface HighlightTextAttributes {
type: string; type: string;
@ -17,30 +14,20 @@ interface HighlightTextOptions {
} }
export function HighlightText(props: ReactNodeViewProps) { export function HighlightText(props: ReactNodeViewProps) {
const { text: initialText, color } = props.node.attrs as HighlightTextAttributes const { type, text: initialText, color } = props.node.attrs as HighlightTextAttributes
const [text, setText] = useState(initialText) const isRoleName = type === 'role'
const handleInput = (e: React.FormEvent<HTMLSpanElement>) => {
const newText = e.currentTarget.textContent || ''
setText(newText)
// 通知Tiptap更新内容
props.updateAttributes({
text: newText
})
}
return ( return (
<NodeViewWrapper <NodeViewWrapper
as="span" as="span"
data-alt="highlight-text" data-alt="highlight-text"
contentEditable={true} contentEditable={!isRoleName}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
onInput={handleInput} className={`relative inline text-${color}-400 hover:text-${color}-300 transition-colors duration-200 ${
className={`relative inline text-${color}-400 hover:text-${color}-300 transition-colors duration-200`} isRoleName ? 'role-name-highlight' : ''
}`}
> >
{text} {initialText}
{/* 暂时空着 为后续可视化文本预留 */}
</NodeViewWrapper> </NodeViewWrapper>
) )
} }