12 KiB
12 KiB
实现方案 — 2026-04-18-00-02-08
根因分析
问题1:拖拽插入后边框不消失
fillPlaceholderSrc(点击上传路径)设置了border='none'和background='transparent'。fillPlaceholder(拖拽路径)遗漏了这两行样式清除,导致拖拽后虚线框和灰色背景仍然可见。- 同时
fillPlaceholder中图片 style 缺少max-height:100%;object-fit:contain;,图片可能溢出占位符。
问题2:prompt 弹窗体验差 + 自动帧插入无区分
insertImage使用浏览器原生prompt询问宽高,交互体验不佳。- 所有
.image-placeholder一视同仁,autoCaptureFrames会自动填入任意空占位符。Logo、签名等位置不应被手术关键帧污染。 - 没有机制区分"接受关键帧"和"不接受关键帧"的占位符。
问题3:insertTable 使用 prompt
- 与 insertImage 同理,原生
prompt弹窗用户体验差,应替换为与项目风格一致的自定义 Modal。
修改文件清单
| 文件 | 修改内容 |
|---|---|
src/pages/ReportEditor.tsx |
① fillPlaceholder 补齐样式清除和图片约束;② insertImage 改为 placeholderModal;③ insertTable 改为 tableModal;④ autoCaptureFrames/insertFrameToPlaceholder 选择器增加 :not([data-mode="manual"]);⑤ handleDrop 拦截 manual 模式;⑥ JSX 底部新增 2 个 Modal |
src/pages/TemplateManage.tsx |
① insertImage 改为 placeholderModal;② insertTable 改为 tableModal;③ JSX 底部新增 2 个 Modal |
具体代码变更
1. ReportEditor.tsx
1.1 fillPlaceholder 修复
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
placeholder.innerHTML = `
<span class="delete-btn" contenteditable="false">×</span>
<img src="${frame.dataUrl}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;margin:0 auto;" draggable="false">
`;
placeholder.classList.add('has-image');
placeholder.style.border = 'none';
placeholder.style.background = 'transparent';
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
};
1.2 新增状态
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
1.3 insertImage 改为打开 Modal
const insertImage = () => {
editorRef.current?.focus();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
1.4 insertTable 改为打开 Modal
const insertTable = () => {
editorRef.current?.focus();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
1.5 autoCaptureFrames 中选择器修改
将 setTimeout 回调内的:
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
改为:
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
1.6 insertFrameToPlaceholder 选择器修改
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
1.7 handleDrop 拦截 manual 模式
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
e.preventDefault();
if (placeholder.getAttribute('data-mode') === 'manual') {
alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧');
return;
}
const frameId = e.dataTransfer.getData('frameId');
const frame = capturedFrames.find(f => f.id.toString() === frameId);
if (frame) {
fillPlaceholder(placeholder, frame);
}
};
1.8 JSX 底部新增 Modal
Placeholder Insert Modal(在 </div> 关闭之前,与现有 imagePickerOpen Modal 并列):
{placeholderModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">宽度(px)</label>
<input type="number" value={placeholderModal.width} onChange={e => setPlaceholderModal({...placeholderModal, width: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">高度(px)</label>
<input type="number" value={placeholderModal.height} onChange={e => setPlaceholderModal({...placeholderModal, height: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
<div>
<label className="block text-xs mb-1">占位符类型</label>
<div className="flex gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'frame'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'frame' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>手术影像占位<br/><span className="text-[10px] opacity-80">(支持自动/拖拽插入)</span></button>
<button onClick={() => setPlaceholderModal({...placeholderModal, mode: 'manual'})} className={`flex-1 py-1.5 text-xs rounded border ${placeholderModal.mode === 'manual' ? 'bg-accent text-white border-accent' : 'bg-white text-slate-600 border-border'}`}>静态图片占位<br/><span className="text-[10px] opacity-80">(仅支持点击插入)</span></button>
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setPlaceholderModal({...placeholderModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
<button onClick={() => {
const w = parseInt(placeholderModal.width) || 200;
const h = parseInt(placeholderModal.height) || 200;
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
const showShortText = w > 0 && w < 80;
const text = showShortText ? '插图' : '插入/点击放置图片';
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;';
styleStr += `width:${w}px;height:${h}px;`;
const id = 'ph_' + Date.now();
const html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${modeAttr} 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);
setPlaceholderModal({...placeholderModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
</div>
</div>
</div>
)}
Table Insert Modal:
{tableModal.isOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs mb-1">行数</label>
<input type="number" min="1" value={tableModal.rows} onChange={e => setTableModal({...tableModal, rows: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
<div className="flex-1">
<label className="block text-xs mb-1">列数</label>
<input type="number" min="1" value={tableModal.cols} onChange={e => setTableModal({...tableModal, cols: e.target.value})} className="w-full px-2 py-1 text-xs border border-border rounded" />
</div>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setTableModal({...tableModal, isOpen: false})} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
<button onClick={() => {
const rows = parseInt(tableModal.rows);
const cols = parseInt(tableModal.cols);
if (isNaN(rows) || isNaN(cols) || rows < 1 || cols < 1) {
setTableModal({...tableModal, isOpen: false});
return;
}
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);
setTableModal({...tableModal, isOpen: false});
}} className="px-4 py-2 bg-accent text-white rounded text-sm">确认插入</button>
</div>
</div>
</div>
)}
2. TemplateManage.tsx
结构与 ReportEditor.tsx 类似,但 insertImage 的 Modal 中也需要表格检测逻辑(已在上一轮修改中实现)。
2.1 新增状态
const [placeholderModal, setPlaceholderModal] = useState({
isOpen: false, width: '200', height: '200', mode: 'frame' as 'frame' | 'manual'
});
const [tableModal, setTableModal] = useState({
isOpen: false, rows: '2', cols: '3'
});
2.2 insertImage 改为打开 Modal
const insertImage = () => {
editorRef.current?.focus();
restoreSelection();
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
};
2.3 insertTable 改为打开 Modal
const insertTable = () => {
editorRef.current?.focus();
restoreSelection();
pushHistory();
setTableModal({ isOpen: true, rows: '2', cols: '3' });
};
2.4 JSX 底部新增 Modal
与 ReportEditor.tsx 的 Modal 结构一致。TemplateManage.tsx 的 insertImage Modal 中,确认按钮需要执行表格检测(沿用上一轮修改的逻辑),然后调用 execCmd('insertHTML', html) 和 pushHistory()。
风险点与应对措施
| 风险 | 应对措施 |
|---|---|
data-mode="manual" 的选择器 :not([data-mode="manual"]) 可能不兼容旧浏览器 |
项目使用 Chrome/Edge,完全支持属性选择器 |
新增 Modal 与现有 imagePickerOpen Modal 的 z-index 冲突 |
两者都使用 z-50,在同一时刻不会同时打开 |
| TemplateManage.tsx 的 insertImage 中 pushHistory() 调用位置 | 确认按钮中在 execCmd 之前调用 pushHistory() |
| 表格内的 insertImage(上一轮修改)与本次 Modal 的冲突 | 确认按钮中保留表格检测逻辑,在表格内时不使用 Modal 中的宽高值 |
回滚策略
- 删除新增的状态和 Modal JSX,恢复
insertImage和insertTable中的prompt弹窗逻辑。 - 恢复
fillPlaceholder到修改前状态。 - 恢复
autoCaptureFrames、insertFrameToPlaceholder、handleDrop中的选择器和拦截逻辑。