1.购买服务器阿里云:服务器购买地址https://t.aliyun.com/U/Bg6shY若失效,可用地址
阿里云:
服务器购买地址
https://t.aliyun.com/U/Bg6shY若失效,可用地址
https://www.aliyun.com/daily-act/ecs/activity_selection?source=5176.29345612&userCode=49hts92d腾讯云:
https://curl.qcloud.com/wJpWmSfU若失效,可用地址
https://cloud.tencent.com/act/cps/redirect?redirect=2446&cps_key=ad201ee2ef3b771157f72ee5464b1fea&from=console华为云
https://activity.huaweicloud.com/cps.html?fromacct=64b5cf7cc11b4840bb4ed2ea0b2f4468&utm_source=V1g3MDY4NTY=&utm_medium=cps&utm_campaign=2019052.部署教程
3.代码如下
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>短视频分镜脚本编辑器 - Pro版</title><!-- Tailwind CSS --><script src="https://cdn.tailwindcss.com"></script><!-- React & ReactDOM --><script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script><script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script><!-- Babel for JSX --><script src="https://unpkg.com/@babel/standalone/babel.min.js"></script><!-- PDF Libraries --><script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script><script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script><script>tailwind.config = {theme: {extend: {colors: {slate: {850: '#1e293b',}},fontFamily: {sans: ['Inter', 'Segoe UI', 'Roboto', 'system-ui', 'sans-serif'],},boxShadow: {'soft': '0 4px 20px -2px rgba(0, 0, 0, 0.05)','glow': '0 0 15px rgba(99, 102, 241, 0.1)',}}}}</script><style>body { font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }::-webkit-scrollbar { width: 6px; height: 6px; }::-webkit-scrollbar-track { background: transparent; }::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }::-webkit-scrollbar-thumb:hover { background: #94a3b8; }.glass-panel {background: rgba(255, 255, 255, 0.95);backdrop-filter: blur(10px);}</style></head><body class="bg-slate-100 text-slate-800"><div id="root"></div><script type="text/babel">const { useState, useRef, useEffect } = React;// --- Icons ---const Icon = ({ children, className, ...props }) => (<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>{children}</svg>);const Plus = (props) => <Icon {...props}><path d="M5 12h14"/><path d="M12 5v14"/></Icon>;const Trash2 = (props) => <Icon {...props}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></Icon>;const GripVertical = (props) => <Icon {...props}><circlecx="9"cy="12"r="1"/><circlecx="9"cy="5"r="1"/><circlecx="9"cy="19"r="1"/><circlecx="15"cy="12"r="1"/><circlecx="15"cy="5"r="1"/><circlecx="15"cy="19"r="1"/></Icon>;const FileDown = (props) => <Icon {...props}><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></Icon>;const Settings = (props) => <Icon {...props}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></Icon>;const X = (props) => <Icon {...props}><path d="M18 6 6 18"/><path d="m6 6 12 12"/></Icon>;const LayoutList = (props) => <Icon {...props}><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/><path d="M14 4h7"/><path d="M14 9h7"/><path d="M14 15h7"/><path d="M14 20h7"/></Icon>;const LayoutGrid = (props) => <Icon {...props}><rectwidth="7"height="7"x="3"y="3"rx="1"/><rectwidth="7"height="7"x="14"y="3"rx="1"/><rectwidth="7"height="7"x="14"y="14"rx="1"/><rectwidth="7"height="7"x="3"y="14"rx="1"/></Icon>;const ImageIcon = (props) => <Icon {...props}><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></Icon>;const Minus = (props) => <Icon {...props}><line x1="5" y1="12" x2="19" y2="12"/></Icon>;// --- Main Component ---function StoryboardEditor() {const [activeTab, setActiveTab] = useState('table');const [fontSize, setFontSize] = useState(14);// Initial Dataconst [columns, setColumns] = useState([{ id: 'shot', name: '镜号', type: 'text', width: 60 },{ id: 'visual', name: '画面', type: 'image', width: 260 },{ id: 'reference', name: '参考', type: 'image', width: 260 },{ id: 'size', name: '景别', type: 'text', width: 100 },{ id: 'duration', name: '时长 (秒)', type: 'text', width: 100 },{ id: 'content', name: '内容/台词', type: 'textarea', width: 260 },{ id: 'angle', name: '摄像机角度', type: 'text', width: 140 },]);const [rows, setRows] = useState([{ id: 1, height: 200, data: { shot: '1', size: '全景', duration: '3s', content: '主角走进房间,环顾四周', angle: '平视' } },{ id: 2, height: 200, data: { shot: '2', size: '特写', duration: '2s', content: '特写主角惊讶的眼神', angle: '微仰' } },{ id: 3, height: 200, data: { shot: '3', size: '', duration: '', content: '', angle: '' } },]);const contentRef = useRef(null);const [isExporting, setIsExporting] = useState(false);// --- States for Interactions ---const [resizing, setResizing] = useState(null);const [draggedRowIndex, setDraggedRowIndex] = useState(null);const [contextMenu, setContextMenu] = useState(null);// --- Effects ---useEffect(() => {const handleMouseMove = (e) => {if (!resizing) return;if (resizing.type === 'col') {const deltaX = e.clientX - resizing.startX;const newWidth = Math.max(50, resizing.startSize + deltaX);setColumns(cols => cols.map(col => col.id === resizing.id ? { ...col, width: newWidth } : col));} else if (resizing.type === 'row') {const deltaY = e.clientY - resizing.startY;const newHeight = Math.max(60, resizing.startSize + deltaY);setRows(rs => rs.map(row => row.id === resizing.id ? { ...row, height: newHeight } : row));}};const handleMouseUp = () => {setResizing(null);document.body.style.cursor = 'default';};const handleClickOutside = () => {if (contextMenu) setContextMenu(null);};if (resizing) {window.addEventListener('mousemove', handleMouseMove);window.addEventListener('mouseup', handleMouseUp);}window.addEventListener('click', handleClickOutside);return () => {window.removeEventListener('mousemove', handleMouseMove);window.removeEventListener('mouseup', handleMouseUp);window.removeEventListener('click', handleClickOutside);};}, [resizing, contextMenu]);// --- Helper Functions ---const startResizeCol = (e, colId, currentWidth) => {e.preventDefault(); e.stopPropagation();setResizing({ type: 'col', id: colId, startX: e.clientX, startSize: currentWidth });document.body.style.cursor = 'col-resize';};const startResizeRow = (e, rowId, currentHeight) => {e.preventDefault(); e.stopPropagation();setResizing({ type: 'row', id: rowId, startY: e.clientY, startSize: currentHeight });document.body.style.cursor = 'row-resize';};const handleDragStart = (e, index) => {setDraggedRowIndex(index);e.dataTransfer.effectAllowed = "move";};const handleDragOver = (e, index) => {e.preventDefault();if (draggedRowIndex === null || draggedRowIndex === index) return;const newRows = [...rows];const draggedRow = newRows[draggedRowIndex];newRows.splice(draggedRowIndex, 1);newRows.splice(index, 0, draggedRow);setRows(newRows);setDraggedRowIndex(index);};const handleDrop = (e) => {e.preventDefault();setDraggedRowIndex(null);};const handleContextMenu = (e, colId) => {e.preventDefault();setContextMenu({ x: e.clientX, y: e.clientY, colId: colId });};const deleteColumnFromMenu = () => {if (contextMenu && contextMenu.colId !== 'shot') {setColumns(columns.filter(col => col.id !== contextMenu.colId));}setContextMenu(null);};const addRow = () => {const newId = rows.length > 0 ? Math.max(...rows.map(r => r.id)) + 1 : 1;const nextShotNum = rows.length + 1;setRows([...rows, { id: newId, height: 200, data: { shot: nextShotNum.toString() } }]);};const deleteRow = (id) => {setRows(rows.filter(row => row.id !== id));};const updateCell = (rowId, colId, value) => {setRows(rows.map(row => {if (row.id === rowId) return { ...row, data: { ...row.data, [colId]: value } };return row;}));};const addColumn = () => {const newColId = `custom_${Date.now()}`;setColumns([...columns, { id: newColId, name: "新项目", type: 'text', width: 140 }]);};const updateColumnName = (colId, newName) => {setColumns(columns.map(col => col.id === colId ? { ...col, name: newName } : col));};const handleImageUpload = (rowId, colId, e) => {const file = e.target.files[0];if (file) {const reader = new FileReader();reader.onloadend = () => updateCell(rowId, colId, reader.result);reader.readAsDataURL(file);}};const increaseFontSize = () => setFontSize(prev => Math.min(prev + 2, 32));const decreaseFontSize = () => setFontSize(prev => Math.max(prev - 2, 10));// --- PDF Export Logic (Fixed for text disappearance & image distortion) ---const exportToPDF = async () => {setIsExporting(true);try {const { jsPDF } = window.jspdf;const pdf = new jsPDF('l', 'mm', 'a4');const pdfPageWidth = pdf.internal.pageSize.getWidth();const pdfPageHeight = pdf.internal.pageSize.getHeight();const margin = 10;const printableWidthMM = pdfPageWidth - (margin * 2);const element = contentRef.current;const clone = element.cloneNode(true);// Style the clone for captureconst mmToPx = 3.78;const printableWidthPx = printableWidthMM * mmToPx;clone.style.position = 'absolute';clone.style.top = '-9999px';clone.style.left = '0';clone.style.zIndex = '-1';clone.style.backgroundColor = '#ffffff';clone.style.padding = '0';if (activeTab === 'board') {clone.style.width = `${printableWidthPx}px`;clone.classList.remove('max-w-[1920px]');clone.style.maxWidth = 'none';} else {clone.style.width = `${element.scrollWidth}px`;}document.body.appendChild(clone);// --- 修复1:将 Input 替换为普通文本 Div (解决文字消失) ---const originalInputs = element.querySelectorAll('input, textarea');const cloneInputs = clone.querySelectorAll('input, textarea');originalInputs.forEach((input, index) => {if (cloneInputs[index]) {const replacementDiv = document.createElement('div');const value = input.value;replacementDiv.innerText = value || '';const computedStyle = window.getComputedStyle(input);replacementDiv.style.font = computedStyle.font;replacementDiv.style.fontSize = computedStyle.fontSize;replacementDiv.style.fontWeight = computedStyle.fontWeight;replacementDiv.style.color = computedStyle.color;replacementDiv.style.textAlign = computedStyle.textAlign;replacementDiv.style.padding = computedStyle.padding;replacementDiv.style.lineHeight = computedStyle.lineHeight;replacementDiv.style.whiteSpace = 'pre-wrap';replacementDiv.style.wordBreak = 'break-word';replacementDiv.style.width = '100%';replacementDiv.style.height = '100%';replacementDiv.style.display = 'flex';replacementDiv.style.alignItems = 'center';if (input.tagName === 'TEXTAREA') {replacementDiv.style.alignItems = 'flex-start';}cloneInputs[index].parentNode.replaceChild(replacementDiv, cloneInputs[index]);}});// --- 修复2:将图片替换为背景图 (解决图片被拉伸变形) ---const cloneImages = clone.querySelectorAll('img');cloneImages.forEach((img) => {if (img.classList.contains('object-contain') && img.src) {const div = document.createElement('div');div.style.width = '100%';div.style.height = '100%';div.style.backgroundImage = `url("${img.src}")`;div.style.backgroundSize = 'contain';div.style.backgroundPosition = 'center center';div.style.backgroundRepeat = 'no-repeat';img.parentNode.replaceChild(div, img);}});// Wait for renderingawait new Promise(r => setTimeout(r, 800));const canvas = await window.html2canvas(clone, {scale: 2,useCORS: true,backgroundColor: '#ffffff',windowWidth: clone.scrollWidth,windowHeight: clone.scrollHeight,scrollY: 0});document.body.removeChild(clone);const imgData = canvas.toDataURL('image/png');const imgProps = pdf.getImageProperties(imgData);const pdfImgRatio = imgProps.width / imgProps.height;const renderWidth = printableWidthMM;const renderHeight = renderWidth / pdfImgRatio;const pageContentHeight = pdfPageHeight - (margin * 2);let heightLeft = renderHeight;let positionY = margin;pdf.addImage(imgData, 'PNG', margin, positionY, renderWidth, renderHeight);heightLeft -= pageContentHeight;while (heightLeft > 0) {positionY -= pageContentHeight;pdf.addPage();pdf.addImage(imgData, 'PNG', margin, positionY, renderWidth, renderHeight);heightLeft -= pageContentHeight;}pdf.save(`分镜脚本_${activeTab === 'table' ? '表格' : '故事版'}.pdf`);} catch (error) {console.error(error);alert("导出失败,请重试:" + error.message);} finally {setIsExporting(false);}};return (<div className="min-h-screen pb-10 flex flex-col bg-slate-50" onClick={() => setContextMenu(null)}>{/* Header */}<header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-20 shadow-lg px-8 py-4 flex justify-between items-center text-white"><div className="flex items-center gap-8"><div className="flex items-center gap-3"><div className="bg-indigo-500 w-8 h-8 rounded-lg flex items-center justify-center shadow-glow"><LayoutGrid className="w-5 h-5 text-white" /></div><h1 className="text-lg font-bold tracking-tight">分镜脚本 <span className="text-indigo-400 font-light">Pro</span></h1></div><div className="flex bg-slate-800 p-1 rounded-full border border-slate-700"><buttononClick={() => setActiveTab('table')}className={`flex items-center gap-2 px-5 py-1.5 text-xs font-bold uppercase tracking-wider rounded-full transition-all duration-300 ${activeTab === 'table'? 'bg-indigo-500 text-white shadow-md transform scale-105': 'text-slate-400 hover:text-white hover:bg-slate-700'}`}><LayoutList className="w-3 h-3" />列表模式</button><buttononClick={() => setActiveTab('board')}className={`flex items-center gap-2 px-5 py-1.5 text-xs font-bold uppercase tracking-wider rounded-full transition-all duration-300 ${activeTab === 'board'? 'bg-indigo-500 text-white shadow-md transform scale-105': 'text-slate-400 hover:text-white hover:bg-slate-700'}`}><LayoutGrid className="w-3 h-3" />故事版</button></div></div><div className="flex gap-4 items-center"><div className="flex items-center bg-slate-800/50 border border-slate-700 rounded-lg p-1 mr-2 backdrop-blur-sm"><button onClick={decreaseFontSize} className="p-1.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white transition-colors" title="减小字体"><Minus className="w-3 h-3" /></button><span className="px-3 text-xs font-mono text-slate-300 w-10 text-center select-none">{fontSize}px</span><button onClick={increaseFontSize} className="p-1.5 hover:bg-slate-700 rounded text-slate-400 hover:text-white transition-colors" title="增大字体"><Plus className="w-3 h-3" /></button></div><div className="h-6 w-px bg-slate-700 mx-1"></div>{activeTab === 'table' && (<buttononClick={addColumn}className="flex items-center gap-2 px-4 py-2 bg-transparent border border-slate-600 text-slate-300 rounded-lg hover:bg-slate-800 hover:border-slate-500 hover:text-white text-sm font-medium transition-colors"><Settings className="w-4 h-4" /><span className="hidden sm:inline">增加列</span></button>)}<buttononClick={exportToPDF}disabled={isExporting}className="flex items-center gap-2 px-5 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium transition-all shadow-lg hover:shadow-indigo-500/25 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed">{isExporting ? (<span className="flex items-center gap-2"><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div> 生成中...</span>) : (<><FileDown className="w-4 h-4" />导出 PDF</>)}</button></div></header>{/* Main Content */}<main className="p-8 flex-1 overflow-x-auto"><div ref={contentRef} className="bg-transparent min-w-full mx-auto max-w-[1920px]">{/* Table Mode */}{activeTab === 'table' && (<div className="bg-white rounded-2xl shadow-soft border border-slate-200 overflow-hidden select-none">{/* Table Header */}<div className="flex border-b border-slate-200 bg-slate-50 min-w-max"><div className="w-14 py-4 text-center font-bold text-slate-400 text-xs uppercase tracking-wider flex-shrink-0 flex items-center justify-center border-r border-slate-100">序号</div>{columns.map((col) => (<divkey={col.id}className="px-3 py-4 text-left font-bold text-slate-500 text-xs uppercase tracking-wider flex-shrink-0 border-r border-slate-100 group relative flex items-center transition-colors hover:bg-slate-100"style={{ width: `${col.width}px` }}onContextMenu={(e) => handleContextMenu(e, col.id)}><inputtype="text"value={col.name}onChange={(e) => updateColumnName(col.id, e.target.value)}className="bg-transparent border-none rounded px-2 py-1 w-full focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:bg-white transition-all z-10 relative font-bold text-slate-700"style={{ fontSize: `${Math.max(fontSize, 12)}px` }}/>{col.id !== 'shot' && (<buttononClick={() => { if (window.confirm('确定要删除这一列吗?')) setColumns(columns.filter(c => c.id !== col.id)); }}className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-slate-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity z-20"><X className="w-3 h-3" /></button>)}<divclassName="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-400 z-30 transition-colors"onMouseDown={(e) => startResizeCol(e, col.id, col.width)}/></div>))}<div className="w-12 flex-shrink-0 bg-slate-50"></div></div>{/* Table Body */}<div className="divide-y divide-slate-100 min-w-max bg-white">{rows.map((row, index) => (<divkey={row.id}className={`flex group hover:bg-slate-50/80 transition-colors relative ${draggedRowIndex === index ? 'opacity-50 bg-indigo-50 ring-2 ring-indigo-500 z-10' : ''}`}style={{ height: `${row.height}px` }}onDragOver={(e) => handleDragOver(e, index)}onDrop={handleDrop}><divclassName="w-14 border-r border-slate-100 flex flex-col items-center justify-center flex-shrink-0 relative bg-white group-hover:bg-slate-50 transition-colors cursor-move"draggable="true"onDragStart={(e) => handleDragStart(e, index)}><div className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-300 group-hover:text-indigo-500 group-hover:bg-indigo-50 transition-all"><span className="font-mono font-bold text-sm text-slate-400 group-hover:hidden">{index + 1}</span><GripVertical className="w-4 h-4 hidden group-hover:block" /></div><divclassName="absolute bottom-0 left-0 right-0 h-1 cursor-row-resize hover:bg-indigo-400 z-30 transition-colors opacity-0 group-hover:opacity-100"onMouseDown={(e) => startResizeRow(e, row.id, row.height)}/></div>{columns.map((col) => (<divkey={col.id}className="p-2 flex-shrink-0 border-r border-slate-100/50 border-dashed flex items-center h-full relative"style={{ width: `${col.width}px` }}>{col.type === 'text' && (<inputtype="text"value={row.data[col.id] || ''}onChange={(e) => updateCell(row.id, col.id, e.target.value)}placeholder="点击输入..."className="w-full h-full bg-transparent border border-transparent rounded-lg px-3 focus:border-indigo-200 focus:bg-white focus:ring-4 focus:ring-indigo-500/10 focus:outline-none text-slate-700 placeholder-slate-300 transition-all"style={{ fontSize: `${fontSize}px` }}/>)}{col.type === 'textarea' && (<textareavalue={row.data[col.id] || ''}onChange={(e) => updateCell(row.id, col.id, e.target.value)}placeholder="输入内容..."className="w-full h-full bg-transparent border border-transparent rounded-lg p-3 focus:border-indigo-200 focus:bg-white focus:ring-4 focus:ring-indigo-500/10 focus:outline-none text-slate-700 placeholder-slate-300 resize-none leading-relaxed transition-all"style={{ fontSize: `${fontSize}px` }}/>)}{col.type === 'image' && (<div className="w-full h-[90%] bg-slate-50 border-2 border-dashed border-slate-200 rounded-xl hover:border-indigo-400 hover:bg-indigo-50/30 transition-all relative group/image overflow-hidden flex items-center justify-center mx-1">{row.data[col.id] ? (<><img src={row.data[col.id]} alt="预览" className="w-full h-full object-contain" /><div className="absolute inset-0 bg-black/0 group-hover/image:bg-black/10 transition-colors flex items-start justify-end p-2"><buttononClick={() => updateCell(row.id, col.id, null)}className="p-1.5 bg-white text-red-500 rounded-lg shadow-sm opacity-0 group-hover/image:opacity-100 hover:bg-red-50 transition-all transform translate-y-2 group-hover/image:translate-y-0"><Trash2 className="w-3 h-3" /></button></div></>) : (<label className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer text-slate-300 hover:text-indigo-500 transition-colors"><div className="p-3 bg-white rounded-full shadow-sm mb-2 group-hover:scale-110 transition-transform"><ImageIcon className="w-5 h-5" /></div><span className="text-[10px] font-bold uppercase tracking-wide">上传</span><input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(row.id, col.id, e)} /></label>)}</div>)}</div>))}<div className="w-12 flex items-center justify-center flex-shrink-0 opacity-0 group-hover:opacity-100 transition-all"><button onClick={() => deleteRow(row.id)} className="p-2 bg-white border border-slate-200 hover:border-red-200 hover:bg-red-50 text-slate-400 hover:text-red-500 rounded-lg shadow-sm"><Trash2 className="w-4 h-4" /></button></div></div>))}</div><div onClick={addRow} className="p-4 bg-slate-50 border-t border-slate-200 flex items-center justify-center text-slate-400 cursor-pointer hover:bg-slate-100 hover:text-indigo-600 transition-all group"><div className="flex items-center gap-2 px-4 py-2 rounded-lg border border-dashed border-slate-300 group-hover:border-indigo-400 bg-white"><Plus className="w-4 h-4" /><span className="text-sm font-bold">添加新镜头</span></div></div></div>)}{/* Board Mode */}{activeTab === 'board' && (<div className="space-y-8"><div className="flex justify-between items-center px-1"><div><h2 className="text-2xl font-bold text-slate-800 tracking-tight">故事版预览</h2><p className="text-slate-500 text-sm">拖拽卡片调整顺序</p></div><button onClick={addRow} className="px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-bold text-sm flex items-center shadow-lg shadow-indigo-500/20 transition-all active:scale-95"><Plus className="w-4 h-4 mr-2" /> 新建镜头</button></div><div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">{rows.map((row, index) => (<divkey={row.id}draggable="true"onDragStart={(e) => handleDragStart(e, index)}onDragOver={(e) => handleDragOver(e, index)}onDrop={handleDrop}className={`bg-white rounded-2xl border border-slate-200 shadow-soft hover:shadow-xl hover:-translate-y-1 transition-all duration-300 overflow-hidden flex flex-col group ${draggedRowIndex === index ? 'opacity-40 scale-95 ring-2 ring-indigo-500' : ''}`}><div className="px-5 py-4 border-b border-slate-100 flex justify-between items-center bg-white"><div className="flex items-center gap-2"><span className="flex items-center justify-center px-2 py-1 rounded bg-slate-100 text-xs font-bold text-slate-500">镜头 {row.data.shot}</span></div><div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"><div className="cursor-move p-1.5 hover:bg-slate-100 rounded-md text-slate-400 hover:text-slate-600 transition-colors"><GripVertical className="w-4 h-4" /></div><button onClick={() => deleteRow(row.id)} className="p-1.5 hover:bg-red-50 rounded-md text-slate-400 hover:text-red-500 transition-colors"><Trash2 className="w-4 h-4" /></button></div></div><div className="aspect-video bg-slate-50 relative group/img border-b border-slate-100 flex items-center justify-center overflow-hidden"><div className="absolute inset-0 opacity-[0.03]" style={{ backgroundImage: 'radial-gradient(#000 1px, transparent 1px)', backgroundSize: '20px 20px' }}></div>{row.data.visual ? (<><img src={row.data.visual} alt="Story visual" className="w-full h-full object-contain z-10" /><div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/10 z-20 transition-all flex items-start justify-end p-3"><buttononClick={() => updateCell(row.id, 'visual', null)}className="p-2 bg-white text-red-500 rounded-lg shadow-lg opacity-0 group-hover/img:opacity-100 hover:bg-red-50 transition-all transform -translate-y-2 group-hover/img:translate-y-0"><Trash2 className="w-4 h-4" /></button></div></>) : (<label className="w-full h-full flex flex-col items-center justify-center cursor-pointer text-slate-300 hover:text-indigo-500 hover:bg-indigo-50/20 transition-all z-10 group/upload"><div className="p-4 bg-white rounded-full shadow-sm mb-3 group-hover/upload:scale-110 group-hover/upload:shadow-md transition-all"><ImageIcon className="w-6 h-6" /></div><span className="text-xs font-bold uppercase tracking-wide">上传画面</span><input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(row.id, 'visual', e)} /></label>)}</div><div className="p-5 flex-1 flex flex-col gap-4"><div><label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider block mb-2">内容描述</label><textareavalue={row.data.content || ''}onChange={(e) => updateCell(row.id, 'content', e.target.value)}placeholder="描述动作或台词..."rows={3}className="w-full bg-slate-50 border-none rounded-lg p-3 focus:ring-2 focus:ring-indigo-500/20 focus:bg-white text-slate-700 placeholder-slate-400 resize-none leading-relaxed text-sm transition-all"style={{ fontSize: `${fontSize}px` }}/></div><div className="grid grid-cols-2 gap-3 mt-auto pt-4 border-t border-slate-100"><div className="bg-slate-50 rounded-lg p-2 px-3"><label className="text-[10px] font-bold text-slate-400 uppercase block mb-1">景别</label><inputtype="text"value={row.data.size || ''}onChange={(e) => updateCell(row.id, 'size', e.target.value)}className="w-full bg-transparent border-none p-0 focus:ring-0 text-slate-700 font-medium"placeholder="-"style={{ fontSize: `${Math.max(fontSize - 2, 12)}px` }}/></div><div className="bg-slate-50 rounded-lg p-2 px-3"><label className="text-[10px] font-bold text-slate-400 uppercase block mb-1">时长</label><inputtype="text"value={row.data.duration || ''}onChange={(e) => updateCell(row.id, 'duration', e.target.value)}className="w-full bg-transparent border-none p-0 focus:ring-0 text-slate-700 font-medium"placeholder="-"style={{ fontSize: `${Math.max(fontSize - 2, 12)}px` }}/></div></div></div></div>))}<divonClick={addRow}className="min-h-[400px] rounded-2xl border-2 border-dashed border-slate-200 flex flex-col items-center justify-center text-slate-300 cursor-pointer hover:border-indigo-400 hover:text-indigo-500 hover:bg-indigo-50/10 transition-all gap-4 group"><div className="w-16 h-16 rounded-full bg-slate-50 group-hover:bg-white flex items-center justify-center shadow-sm group-hover:shadow-md transition-all group-hover:scale-110"><Plus className="w-8 h-8" /></div><span className="font-bold text-sm uppercase tracking-wider">创建新镜头</span></div></div></div>)}</div>{contextMenu && (<divclassName="fixed bg-white/90 backdrop-blur-md border border-slate-200 shadow-xl rounded-xl py-2 z-50 min-w-[160px] animate-in fade-in zoom-in-95 duration-100"style={{ top: contextMenu.y, left: contextMenu.x }}><div className="px-4 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-wider border-b border-slate-100 mb-1">列选项</div><buttononClick={deleteColumnFromMenu}className={`w-full text-left px-4 py-2.5 text-sm hover:bg-red-50 hover:text-red-600 flex items-center gap-2 transition-colors ${contextMenu.colId === 'shot' ? 'opacity-50 cursor-not-allowed' : 'text-slate-600'}`}disabled={contextMenu.colId === 'shot'}><Trash2 className="w-4 h-4" />删除此列</button></div>)}</main></div>);}const root = ReactDOM.createRoot(document.getElementById('root'));root.render(<StoryboardEditor />);</script></body></html>
这是在浏览器里跑的 短视频分镜脚本编辑器(Pro 版):
支持两种视图:
列表模式(表格):按行按列填分镜(镜号、画面、参考图、景别、时长、文案、机位等)。
故事版模式(卡片瀑布流):每个镜头一张卡片,上面有画面图+文案+景别+时长,更适合预览和展示。
支持:
拖拽调整行顺序(镜头顺序)。
拖拽调整列宽 / 行高。
动态增删列、增删镜头。
每格支持 文字输入 或 图片上传预览。
调整整体 字体大小。
一键将当前视图 导出为 PDF(做了针对 input/textarea/图片的修复,避免 PDF 里文字消失/图片变形)。
可以理解为:一个带表格+故事板视图的分镜脚本工具 + PDF 导出器。
主要结构
在 StoryboardEditor 组件里:
activeTab:当前是 "table"(列表)还是 "board"(故事版) 模式。fontSize:全局文字字号。columns:列配置数组,每个列包括:id、name、type(text/textarea/image)、width。rows:分镜行数据,每一行:id:行 IDheight:行高data:一个对象{ shot, visual, reference, size, duration, content, angle, ... }contentRef:指向导出 PDF 时要截图的 DOM 容器。isExporting:当前是否正在导出 PDF。resizing:当前是否在拖动调节行/列大小。draggedRowIndex:当前被拖拽的行索引,用来实现拖拽排序。contextMenu:右键列头时弹出的 "列选项" 菜单位置和目标列 ID。
主要方法
1. 列/行尺寸调整
const startResizeCol = (e, colId, currentWidth) => { ... }
const startResizeRow = (e, rowId, currentHeight) => { ... }
作用:在列头右边 / 行底部按下鼠标,开始 列宽 / 行高拖拽调整。
配合
resizing状态,在useEffect里监听mousemove/mouseup:鼠标移动时实时更新
columns.width或rows.height。鼠标抬起时结束拖拽。
2. 行拖拽排序相关
const handleDragStart = (e, index) => { ... }
const handleDragOver = (e, index) => { ... }
const handleDrop = (e) => { ... }
handleDragStart:开始拖拽某一行时记录draggedRowIndex。handleDragOver:拖拽到另一个行上方时,动态调整rows的顺序(实现行之间的交换/重排)。handleDrop:拖拽结束,清空draggedRowIndex。
列表模式和故事版模式都用这套逻辑。
3. 列右键菜单(删除列)
const handleContextMenu = (e, colId) => { ... }
const deleteColumnFromMenu = () => { ... }
handleContextMenu:在列头
onContextMenu触发,记录右键菜单的屏幕坐标 + 列 ID。显示一个简单的 "列选项"浮层。
deleteColumnFromMenu:根据
contextMenu.colId删除对应列(除了shot列被保护不能删)。
4. 行/列增删 & 单元格更新
const addRow = () => { ... }
const deleteRow = (id) => { ... }
const addColumn = () => { ... }
const updateCell = (rowId, colId, value) => { ... }
const updateColumnName = (colId, newName) => { ... }
addRow:新增一个镜头行,默认高度 200,并自动给shot填一个递增的镜号(1,2,3…)。deleteRow(id):删除指定id的行。addColumn:动态新增一列(默认类型text,名为 "新项目")。updateCell:更新某一行某一列的内容。updateColumnName:列头可编辑,修改列名。
5. 图片上传
const handleImageUpload = (rowId, colId, e) => { ... }
用
<input type="file">选中图片;FileReader 读为
base64;把
base64存进对应rows[rowId].data[colId];渲染时
<img src={row.data[colId]} />显示预览。
在列表模式的 image 单元格、故事版模式的 "画面" 区域都复用这套逻辑。
6. 字体大小调整
const increaseFontSize = () => { ... }
const decreaseFontSize = () => { ... }
改变全局
fontSize状态;各输入框/文本区域都用
style={{ fontSize:${fontSize}px}}绑定,整体字体跟着变。
7. 导出 PDF 核心逻辑
const exportToPDF = async () => { ... }
这是整个文件里最复杂、也是最关键的 "输出" 功能:
设置
isExporting=true,按钮进入 Loading 状态。拿到 jsPDF 实例,配置 A4 横向页面。
克隆
contentRef对应的 DOM 节点到一个隐藏区域(避免影响页面,专门用于截图)。修复点1:表单控件转纯文本
找出 clone 中所有
input, textarea;逐个用
<div>替换掉,把真实value写入,并复制原本的样式(字体、对齐等);这样
html2canvas截图时不会出现 "文字看不见 / 光标状态" 的问题。修复点2:图片转背景图,避免拉伸
对
class="object-contain"的img,换成div,用background-image + background-size: contain呈现;保证在不同缩放下不会变形。
等 DOM 渲染稳定一会儿(
setTimeout800ms)。调
html2canvas把整个 clone 渲为一张高分辨率 Canvas。canvas.toDataURL()生成 PNG,再用 jsPDF 按宽度等比缩放,自动分页(高度超出 A4 就新增 page,继续绘制)。最后
pdf.save('分镜脚本_表格/故事版.pdf')下载文件。无论成功失败,
finally里重置isExporting=false。
一句话:把当前模式下的分镜 UI 渲染成图片 → 按比例填充到 PDF,多页拆分 → 下载。
8. 视图渲染部分
Table 模式:
表头:可编辑列名、可右键、可拖动调整列宽、可删除列。
表体:每行可拖拽排序、拖动行底部调行高、最后一列是删行按钮。
单元格根据
col.type分三种渲染:文本输入、文本域、图片上传区。一个可横向滚动的表格:
Board 模式:
顶部:镜头编号 + 拖拽/删除按钮。
中间:画面区域(上传/预览)。
底部:内容描述 textarea + 景别/时长两个小块输入。
一个响应式网格(1~4列),每个镜头一张卡片:
注意:
本文部分变量已做脱敏处理,仅用于测试和学习研究,禁止用于商业用途,不能保证其合法性,准确性,完整性和有效性,请根据情况自行判断。技术层面需要提供帮助,可以通过打赏的方式进行探讨。
没有评论:
发表评论