11 KiB
11 KiB
实现方案 — 2026-04-18-00-23-14
根因分析
-
拖拽后边框残留:
ReportEditor.tsx中存在两个填充函数:fillPlaceholderSrc(弹窗选择图片后填充)会执行placeholder.style.border = 'none'和placeholder.style.background = 'transparent'。fillPlaceholder(拖拽关键帧后填充)仅添加has-imageclass,未清除内联样式,导致style="border:1px dashed #cbd5e1;background:#f8fafc"仍然生效,覆盖 CSS class 的border-none/bg-transparent。- 同理,
autoCaptureFrames的setTimeout回调中也直接操作 DOM,未清除内联样式。
-
原生
prompt()体验差:insertTable和insertImage在两端编辑器中均连续调用prompt(),阻塞主线程且无法定制样式,与系统现代化 UI 脱节。 -
占位符无来源隔离:当前所有
.image-placeholder生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
修改文件清单
src/pages/ReportEditor.tsxsrc/pages/TemplateManage.tsx
具体代码变更
一、ReportEditor.tsx
1. 新增弹窗状态
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [savedRange, setSavedRange] = useState<Range | null>(null);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
2. 修复 fillPlaceholder(F1)
在 fillPlaceholder 函数中,添加 has-image class 后,同步清除内联样式:
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
3. 修复 autoCaptureFrames 中的自动插入(F2)
在 setTimeout 回调内,classList.add('has-image') 之后增加:
emptyPlaceholder.style.border = 'none';
emptyPlaceholder.style.background = 'transparent';
4. 替换 insertTable 为弹窗驱动(F3)
- 打开弹窗:
const openTableModal = () => { const sel = window.getSelection(); if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange()); setTableRows('2'); setTableCols('3'); setTableModalOpen(true); }; - 确认插入:
const confirmInsertTable = () => { const rows = parseInt(tableRows); const cols = parseInt(tableCols); if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) { setTableModalOpen(false); return; } if (savedRange) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange); } let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">'; for (let i = 0; i < rows; i++) { table += '<tr>'; for (let j = 0; j < cols; j++) { table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>'; } table += '</tr>'; } table += '</table><p></p>'; execCmd('insertHTML', table); setTableModalOpen(false); setSavedRange(null); }; - 工具栏按钮
onClick从insertTable改为openTableModal。
5. 替换 insertImage 为弹窗驱动(F4 / F5)
- 打开弹窗:
const openImageModal = () => { editorRef.current?.focus(); const sel = window.getSelection(); let node: Node | null = sel?.anchorNode ?? null; let inTable = false; while (node) { if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') { inTable = true; break; } node = node.parentNode; } if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange()); setImageModalInTable(inTable); setImageModalWidth('200'); setImageModalHeight('200'); setImageModalAllowSource('all'); setImageModalOpen(true); }; - 确认插入:
const confirmInsertImage = () => { if (savedRange) { const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(savedRange); } const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200); const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200); const allowSource = imageModalAllowSource; const hintText = '插入/点击放置图片'; const id = 'ph_' + Date.now(); const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : ''; let html: string; if (imageModalInTable) { const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;'; html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`; } else { let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;'; if (width > 0) styleStr += `width:${width}px;`; if (height > 0) styleStr += `height:${height}px;`; const showShortText = width > 0 && width < 80; const text = showShortText ? '插入图片' : hintText; html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`; } execCmd('insertHTML', html); setImageModalOpen(false); setSavedRange(null); }; - 工具栏按钮
onClick从insertImage改为openImageModal。
6. 拦截拖拽(F6)
修改 handleDrop:
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) fillPlaceholder(placeholder, frame);
};
7. 拦截点击空占位符(F7)
修改 handleEditorClick 中点击空占位符的分支:
if (!placeholder.classList.contains('has-image')) {
e.preventDefault();
e.stopPropagation();
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'frame') {
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
return;
}
setImagePickerTarget(placeholder);
setImagePickerOpen(true);
}
8. 拦截一键插入关键帧(F8)
修改 insertFrameToPlaceholder:
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
if (!editorRef.current) {
alert('编辑器未准备好');
return;
}
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
if (!emptyPlaceholder) {
alert('没有可插入图片的空位');
return;
}
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
if (allowSource === 'upload') {
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
return;
}
fillPlaceholder(emptyPlaceholder, frame);
};
9. 拦截自动帧插入(F9)
在 autoCaptureFrames 的 setTimeout 回调中,读取 data-allow-source,若值为 upload 则直接 return 跳过该帧。
10. 新增 JSX 弹窗(位于组件底部)
Table Modal:
- 遮罩层:
fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm - 卡片:
bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl - 输入:行数、列数(
<input type="number">) - 按钮:确认(
btn-accent样式)、取消
Image Placeholder Modal:
- 同样遮罩层和卡片布局
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
- 若不在表格内:宽度输入、高度输入(
type="number",默认 200),提示文字「正文一行文字高度约为 20 像素左右」。 - 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
- 按钮:确认、取消
二、TemplateManage.tsx
1. 新增弹窗状态
与 ReportEditor 类似,但使用已有的 savedRangeRef 恢复光标:
const [tableModalOpen, setTableModalOpen] = useState(false);
const [imageModalOpen, setImageModalOpen] = useState(false);
const [imageModalInTable, setImageModalInTable] = useState(false);
const [imageModalWidth, setImageModalWidth] = useState('200');
const [imageModalHeight, setImageModalHeight] = useState('200');
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
const [tableRows, setTableRows] = useState('2');
const [tableCols, setTableCols] = useState('3');
2. 替换 insertTable
openTableModal:保存savedRangeRef.current,打开弹窗。confirmInsertTable:先restoreSelection(),再执行pushHistory()+execCmd('insertHTML', table)。
3. 替换 insertImage
openImageModal:检测是否在表格内,保存savedRangeRef.current,打开弹窗。confirmInsertImage:先restoreSelection(),再执行pushHistory()+execCmd('insertHTML', html)。- HTML 中增加
data-allow-source属性。
4. 新增 JSX 弹窗
结构与 ReportEditor 完全一致,放置在组件底部 imagePickerOpen 弹窗之前或之后。
风险点与应对措施
| 风险 | 应对措施 |
|---|---|
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 Range.cloneRange(),确认后恢复 Selection 再执行 insertHTML。 |
autoCaptureFrames 的 setTimeout 异步回调中 DOM 引用失效 |
回调内部重新查询 editorRef.current,并做空值保护;contentRef.current 同步更新。 |
旧报告/模板中的占位符没有 data-allow-source 属性 |
所有读取逻辑使用 `getAttribute('data-allow-source') |
TemplateManage 工具栏按钮 onMouseDown={e=>e.preventDefault()} 已存在,ReportEditor 缺少 |
给 ReportEditor 的工具栏按钮也增加 onMouseDown={e=>e.preventDefault()},减少焦点流失概率。 |
回滚策略
- 所有修改集中在两个文件(
ReportEditor.tsx、TemplateManage.tsx),未改动types.ts、storage.ts等底层模块。 - 回滚时直接
git checkout还原两个文件即可恢复原有prompt()行为和占位符逻辑。