Merge remote: adopt upstream modal and placeholder isolation changes
This commit is contained in:
@@ -66,15 +66,12 @@ export default function ReportEditor() {
|
||||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||||
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
|
||||
const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]);
|
||||
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');
|
||||
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'
|
||||
});
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -412,11 +409,6 @@ export default function ReportEditor() {
|
||||
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);
|
||||
}
|
||||
@@ -492,87 +484,14 @@ export default function ReportEditor() {
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const openImageModal = () => {
|
||||
const insertTable = () => {
|
||||
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);
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
|
||||
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);
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -701,17 +620,13 @@ export default function ReportEditor() {
|
||||
|
||||
setTimeout(() => {
|
||||
if (!editorRef.current) return;
|
||||
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;
|
||||
if (emptyPlaceholder) {
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') return;
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
emptyPlaceholder.style.border = 'none';
|
||||
emptyPlaceholder.style.background = 'transparent';
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
@@ -738,7 +653,7 @@ export default function ReportEditor() {
|
||||
const fillPlaceholder = (placeholder: HTMLElement, frame: CapturedFrame) => {
|
||||
placeholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${frame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
<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';
|
||||
@@ -749,9 +664,8 @@ export default function ReportEditor() {
|
||||
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
|
||||
if (placeholder.getAttribute('data-mode') === 'manual') {
|
||||
alert('此处为静态图片占位符,仅支持点击插入(如Logo/签名),不支持拖入关键帧');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
@@ -766,16 +680,11 @@ export default function ReportEditor() {
|
||||
alert('编辑器未准备好');
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (!emptyPlaceholder) {
|
||||
alert('没有可插入图片的空位');
|
||||
return;
|
||||
}
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
|
||||
return;
|
||||
}
|
||||
fillPlaceholder(emptyPlaceholder, frame);
|
||||
};
|
||||
|
||||
@@ -1410,8 +1319,8 @@ export default function ReportEditor() {
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openTableModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openImageModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1936,60 +1845,103 @@ export default function ReportEditor() {
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{tableModalOpen && (
|
||||
{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-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">行数</label>
|
||||
<input type="number" min={1} value={tableRows} onChange={(e) => setTableRows(e.target.value)} className="input-minimal" />
|
||||
<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-sm text-text-muted mb-1">列数</label>
|
||||
<input type="number" min={1} value={tableCols} onChange={(e) => setTableCols(e.target.value)} className="input-minimal" />
|
||||
<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-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setTableModalOpen(false); setSavedRange(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertTable} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
<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 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;
|
||||
}
|
||||
const w = parseInt(placeholderModal.width) || 200;
|
||||
const h = parseInt(placeholderModal.height) || 200;
|
||||
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
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"${modeAttr} 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;';
|
||||
styleStr += `width:${w}px;height:${h}px;`;
|
||||
const showShortText = w > 0 && w < 80;
|
||||
const text = showShortText ? '插图' : hintText;
|
||||
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>
|
||||
)}
|
||||
|
||||
{imageModalOpen && (
|
||||
{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-4">
|
||||
{imageModalInTable ? (
|
||||
<div className="text-sm text-text-muted">表格内占位符将自动填满单元格,无需设置尺寸。</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">宽度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalWidth} onChange={(e) => setImageModalWidth(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">高度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalHeight} onChange={(e) => setImageModalHeight(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">提示:正文一行文字高度约为 20 像素左右。</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">允许图片来源</label>
|
||||
<select value={imageModalAllowSource} onChange={(e) => setImageModalAllowSource(e.target.value as 'all' | 'frame' | 'upload')} className="input-minimal">
|
||||
<option value="all">所有来源</option>
|
||||
<option value="frame">仅限关键帧</option>
|
||||
<option value="upload">仅限本地上传/签名/素材</option>
|
||||
</select>
|
||||
<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-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setImageModalOpen(false); setSavedRange(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertImage} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
<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>
|
||||
|
||||
@@ -40,15 +40,13 @@ export default function TemplateManage() {
|
||||
const [customTimeFormats, setCustomTimeFormats] = useState<string[]>([]);
|
||||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||||
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'
|
||||
});
|
||||
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
|
||||
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');
|
||||
|
||||
const updatePageHeight = () => {
|
||||
if (!editorRef.current) return;
|
||||
@@ -500,85 +498,18 @@ export default function TemplateManage() {
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) savedRangeRef.current = 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 (savedRangeRef.current) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRangeRef.current);
|
||||
}
|
||||
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>';
|
||||
pushHistory();
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
savedRangeRef.current = null;
|
||||
};
|
||||
|
||||
const openImageModal = () => {
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
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;
|
||||
}
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
pushHistory();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
|
||||
const confirmInsertImage = () => {
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
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>​`;
|
||||
}
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
savedRangeRef.current = null;
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
|
||||
const saveCurrentTemplate = () => {
|
||||
@@ -829,8 +760,8 @@ export default function TemplateManage() {
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openTableModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openImageModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1283,60 +1214,103 @@ export default function TemplateManage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tableModalOpen && (
|
||||
{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-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">行数</label>
|
||||
<input type="number" min={1} value={tableRows} onChange={(e) => setTableRows(e.target.value)} className="input-minimal" />
|
||||
<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-sm text-text-muted mb-1">列数</label>
|
||||
<input type="number" min={1} value={tableCols} onChange={(e) => setTableCols(e.target.value)} className="input-minimal" />
|
||||
<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-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setTableModalOpen(false); savedRangeRef.current = null; }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertTable} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
<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 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;
|
||||
}
|
||||
const w = parseInt(placeholderModal.width) || 200;
|
||||
const h = parseInt(placeholderModal.height) || 200;
|
||||
const modeAttr = placeholderModal.mode === 'manual' ? ' data-mode="manual"' : '';
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
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"${modeAttr} 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;';
|
||||
styleStr += `width:${w}px;height:${h}px;`;
|
||||
const showShortText = w > 0 && w < 80;
|
||||
const text = showShortText ? '插图' : hintText;
|
||||
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>
|
||||
)}
|
||||
|
||||
{imageModalOpen && (
|
||||
{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-4">
|
||||
{imageModalInTable ? (
|
||||
<div className="text-sm text-text-muted">表格内占位符将自动填满单元格,无需设置尺寸。</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">宽度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalWidth} onChange={(e) => setImageModalWidth(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">高度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalHeight} onChange={(e) => setImageModalHeight(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">提示:正文一行文字高度约为 20 像素左右。</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">允许图片来源</label>
|
||||
<select value={imageModalAllowSource} onChange={(e) => setImageModalAllowSource(e.target.value as 'all' | 'frame' | 'upload')} className="input-minimal">
|
||||
<option value="all">所有来源</option>
|
||||
<option value="frame">仅限关键帧</option>
|
||||
<option value="upload">仅限本地上传/签名/素材</option>
|
||||
</select>
|
||||
<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-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setImageModalOpen(false); savedRangeRef.current = null; }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertImage} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
<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>
|
||||
|
||||
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
256
工程分析/实现方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# 实现方案 — 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 修复
|
||||
|
||||
```ts
|
||||
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 新增状态
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.4 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
const insertTable = () => {
|
||||
editorRef.current?.focus();
|
||||
setTableModal({ isOpen: true, rows: '2', cols: '3' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 1.5 autoCaptureFrames 中选择器修改
|
||||
|
||||
将 `setTimeout` 回调内的:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
```
|
||||
改为:
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.6 insertFrameToPlaceholder 选择器修改
|
||||
|
||||
```ts
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image):not([data-mode="manual"])') as HTMLElement | null;
|
||||
```
|
||||
|
||||
#### 1.7 handleDrop 拦截 manual 模式
|
||||
|
||||
```ts
|
||||
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 并列):
|
||||
|
||||
```tsx
|
||||
{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**:
|
||||
|
||||
```tsx
|
||||
{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 新增状态
|
||||
|
||||
```ts
|
||||
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
|
||||
|
||||
```ts
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
setPlaceholderModal({ isOpen: true, width: '200', height: '200', mode: 'frame' });
|
||||
};
|
||||
```
|
||||
|
||||
#### 2.3 insertTable 改为打开 Modal
|
||||
|
||||
```ts
|
||||
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` 中的选择器和拦截逻辑。
|
||||
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
104
工程分析/测试方案-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 测试方案 — 2026-04-18-00-02-08
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证拖拽关键帧插入样式修复、图片占位符自定义弹窗与分类隔离、表格插入自定义弹窗三项修复。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome/Edge
|
||||
- 测试账号:admin / 123456(超级管理员)
|
||||
|
||||
## 测试用例
|
||||
|
||||
### TC-1:拖拽关键帧后边框消失 + 图片约束
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」,上传视频并自动摘取关键帧 | 右侧视频分析面板显示关键帧缩略图 |
|
||||
| 2 | 编辑器中插入一个图片占位符 | 显示虚线框占位符 |
|
||||
| 3 | 从右侧拖拽关键帧到占位符中 | 图片正常显示,**虚线边框和灰色背景消失**;图片不溢出占位符边界 |
|
||||
| 4 | 用 DevTools 检查 `<img>` 元素 | style 包含 `max-width:100%;max-height:100%;object-fit:contain;` |
|
||||
|
||||
### TC-2:图片占位符插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | **不弹出 prompt**,而是弹出居中的自定义 Modal |
|
||||
| 3 | Modal 中显示默认宽度 200、高度 200 | — |
|
||||
| 4 | 修改宽度为 120,高度为 80 | 输入框值正常变化 |
|
||||
| 5 | 选择「静态图片占位」模式 | 模式按钮高亮切换 |
|
||||
| 6 | 点击「确认插入」 | Modal 关闭,编辑器中插入行内 `<span>` 占位符,带有 `data-mode="manual"` 属性 |
|
||||
| 7 | 用 DevTools 检查插入的元素 | `data-mode="manual"` 存在,style 包含 `width:120px;height:80px;` |
|
||||
|
||||
### TC-3:图片占位符插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入图片占位符」 | 弹出自定义 Modal |
|
||||
| 3 | 确认插入 | 占位符正常插入,结构完整 |
|
||||
|
||||
### TC-4:自动帧插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 在编辑器中插入两个占位符:第一个是 frame 模式,第二个是 manual 模式 | — |
|
||||
| 2 | 上传视频,开启「自动帧插入」,点击「自动关键帧摘取」 | — |
|
||||
| 3 | 观察占位符填充情况 | 只有**第一个 frame 模式**的占位符被自动填入关键帧;第二个 manual 占位符**保持空白** |
|
||||
|
||||
### TC-5:一键插入跳过 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中先插入一个 manual 占位符,再插入一个 frame 占位符 | — |
|
||||
| 2 | 右侧关键帧卡片点击「插入」按钮 | 关键帧填入**第二个 frame 占位符**;manual 占位符不受影响 |
|
||||
|
||||
### TC-6:拖拽拦截 manual 占位符
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 编辑器中插入一个 manual 占位符 | — |
|
||||
| 2 | 从右侧拖拽关键帧到该 manual 占位符上 | 弹出提示「此处为静态图片占位符,仅支持点击插入...」;占位符**不被填充** |
|
||||
|
||||
### TC-7:表格插入弹窗(ReportEditor)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「报告编辑器」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | **不弹出 prompt**,弹出自定义 Modal |
|
||||
| 3 | Modal 中显示默认行数 2、列数 3 | — |
|
||||
| 4 | 修改行数为 4,列数为 2 | 输入框值正常变化 |
|
||||
| 5 | 点击「确认插入」 | Modal 关闭,编辑器中插入 4 行 2 列的表格 |
|
||||
|
||||
### TC-8:表格插入弹窗(TemplateManage)
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 进入「模板管理」 | — |
|
||||
| 2 | 点击工具栏「插入表格」 | 弹出自定义 Modal |
|
||||
| 3 | 设置行数 3,列数 3,确认插入 | 表格正常插入 |
|
||||
|
||||
### TC-9:类型检查
|
||||
|
||||
| 步骤 | 操作 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| 1 | 项目根目录执行 `npm run lint` | `tsc --noEmit` 通过,0 errors |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误
|
||||
- [ ] 拖拽关键帧后占位符边框和背景消失,图片不溢出
|
||||
- [ ] 点击「插入图片占位符」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持选择占位符模式(frame/manual)
|
||||
- [ ] manual 占位符带有 `data-mode="manual"` 属性
|
||||
- [ ] 自动帧插入和一键插入跳过 manual 占位符
|
||||
- [ ] 拖拽到 manual 占位符被拦截并提示
|
||||
- [ ] 点击「插入表格」弹出自定义 Modal(非 prompt)
|
||||
- [ ] Modal 支持输入行数/列数并正常插入表格
|
||||
|
||||
## 测试方式
|
||||
|
||||
全部使用手工功能验证(项目无单元测试框架)。
|
||||
39
工程分析/经验记录.md
39
工程分析/经验记录.md
@@ -892,30 +892,31 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
||||
|
||||
---
|
||||
|
||||
## 记录 29:拖拽关键帧后占位符边框残留、prompt 弹窗改造与图片来源隔离
|
||||
## 记录 29:拖拽关键帧样式遗漏、占位符分类隔离与 Modal 弹窗改造
|
||||
|
||||
**A. 具体问题**
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,占位符原有的虚线框和浅灰背景未清除,图片与边框重叠。
|
||||
2. `insertTable` 和 `insertImage` 在 ReportEditor 和 TemplateManage 中均使用原生 `prompt()`,交互体验差且无法定制样式。
|
||||
3. 所有图片占位符无差别接受任何图片来源(关键帧、本地上传、签名、素材),导致签名等区域可能被误拖入术中截图。
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,虚线边框和灰色背景未消失,且图片可能溢出占位符。
|
||||
2. `insertImage` 和 `insertTable` 使用浏览器原生 `prompt` 弹窗,交互体验差。
|
||||
3. 所有占位符一视同仁,自动帧插入和一键插入会把手术关键帧填入 Logo、签名等静态图片位置。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `fillPlaceholder`(拖拽填充)仅添加 `has-image` class,未同步清除内联 `border` 和 `background`;而 `fillPlaceholderSrc`(弹窗填充)做了清除,两者逻辑不一致。`autoCaptureFrames` 的自动插入回调同样遗漏了样式清除。
|
||||
2. 早期实现为快速落地,直接使用浏览器原生 `prompt()` 获取行列数和宽高,未考虑 UI 一致性。
|
||||
3. 占位符生成时缺少类型标识属性,导致后续拖拽、点击、自动插入等入口均无法做来源隔离。
|
||||
1. **`fillPlaceholder` 遗漏样式清除**:`fillPlaceholderSrc`(点击上传路径)设置了 `border='none'` 和 `background='transparent'`,但 `fillPlaceholder`(拖拽路径)遗漏了这两行,且图片 style 缺少 `max-height:100%;object-fit:contain;`。
|
||||
2. **原生 prompt 的限制**:`prompt` 弹窗无法自定义样式,且在不同浏览器中表现不一致,用户体验差。
|
||||
3. **占位符无分类机制**:所有 `.image-placeholder` 都接受关键帧填充,没有区分"接受自动插入"和"不接受自动插入"的占位符。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **统一清除内联样式**:在 `fillPlaceholder`、`autoCaptureFrames` 的 `setTimeout` 回调中,填充图片后统一执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。
|
||||
2. **自定义弹窗替换 prompt**:
|
||||
- 两端编辑器各新增 `tableModalOpen` / `imageModalOpen` 等状态,渲染固定遮罩 + 白色圆角卡片的居中弹窗。
|
||||
- 打开弹窗前保存当前 `Selection.getRangeAt(0).cloneRange()`,确认后恢复 Range 再执行 `execCmd('insertHTML', ...)`,确保插入位置准确。
|
||||
- 工具栏按钮增加 `onMouseDown={(e) => e.preventDefault()}`,减少焦点流失。
|
||||
3. **图片来源隔离**:
|
||||
- 插入占位符弹窗增加「允许图片来源」下拉框(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成 HTML 时写入 `data-allow-source` 属性。
|
||||
- 在 `handleDrop`(拖拽)、`handleEditorClick`(点击空占位符)、`insertFrameToPlaceholder`(一键插入)、`autoCaptureFrames`(自动插入)四个入口读取该属性并拦截不符合来源的操作,给出明确提示。
|
||||
- 旧占位符未设置该属性时默认 `'all'`,保证向后兼容。
|
||||
1. **补齐 `fillPlaceholder`**:增加 `placeholder.style.border = 'none'`、`placeholder.style.background = 'transparent'`,图片 style 改为 `max-width:100%;max-height:100%;object-fit:contain;`。
|
||||
2. **自定义 Modal 替代 prompt**:
|
||||
- 新增 `placeholderModal` 状态(isOpen, width, height, mode)和 `tableModal` 状态(isOpen, rows, cols)。
|
||||
- `insertImage` 和 `insertTable` 改为打开 Modal。
|
||||
- Modal 使用项目统一的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
|
||||
3. **占位符分类隔离**:
|
||||
- Modal 中增加模式选择:「手术影像占位(frame)」和「静态图片占位(manual)」。
|
||||
- manual 模式生成的 placeholder 带有 `data-mode="manual"` 属性。
|
||||
- `autoCaptureFrames` 和 `insertFrameToPlaceholder` 的选择器增加 `:not([data-mode="manual"])`。
|
||||
- `handleDrop` 中拦截 manual 占位符的拖拽,弹出提示。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当同一类 DOM 元素存在多种填充路径(拖拽、弹窗、自动插入)时,应将样式清除、class 切换等通用逻辑集中到单一函数(如 `fillPlaceholder`),避免重复代码和维护遗漏。
|
||||
- 在 `contentEditable` 编辑器中引入自定义弹窗时,必须配套「保存 → 恢复 Selection/Range」机制,否则弹窗导致的焦点丢失会让 `insertHTML` 插入到错误位置。
|
||||
- 任何需要权限/来源隔离的控件,应在生成时就通过 `data-*` 属性打上标签,并在所有消费入口(拖拽、点击、快捷键、自动逻辑)统一校验,而不是仅在单一入口拦截。
|
||||
- 当同一填充逻辑存在多个入口(点击上传、拖拽、自动插入)时,务必确保所有入口的后续处理完全一致,避免某一路径遗漏样式清除。
|
||||
- 原生 `prompt`/`confirm`/`alert` 在现代 Web 应用中应尽量避免使用,优先采用自定义 Modal 组件,以获得一致的视觉体验和更灵活的控制能力。
|
||||
- 当系统中存在"自动填充"机制时,应考虑为被填充的容器增加分类标记(如 `data-mode`),并在自动填充逻辑中通过选择器过滤,防止无关区域被污染。
|
||||
|
||||
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
45
工程分析/需求分析-2026-04-18-00-02-08.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求分析 — 2026-04-18-00-02-08
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
1. **修复拖拽关键帧插入兼容性**:拖拽关键帧到 `.image-placeholder` 后,虚线边框和背景色未消失,且图片缺少 `max-height:100%;object-fit:contain;` 约束,可能溢出占位符。
|
||||
|
||||
2. **图片占位符插入改为自定义弹窗 + 分类隔离**:
|
||||
- 替换 `insertImage` 中的 `prompt` 弹窗为自定义 React Modal。
|
||||
- 占位符分为两类:
|
||||
- **手术影像占位(frame 模式)**:支持自动帧插入、一键插入、拖拽插入。
|
||||
- **静态图片占位(manual 模式)**:仅支持点击后从弹窗选择图片来源(本地上传/签名/素材),防止系统自动将手术关键帧填入 Logo 或签名位置。
|
||||
- 自动帧插入和一键插入逻辑需跳过 `data-mode="manual"` 的占位符。
|
||||
- 拖拽到 manual 占位符时需拦截并提示。
|
||||
|
||||
3. **表格插入改为自定义弹窗**:替换 `insertTable` 中的 `prompt` 弹窗为自定义 React Modal,中间弹出子窗口选择行数和列数。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:`ReportEditor.tsx` 的 `fillPlaceholder` 函数补齐 `border='none'`、`background='transparent'`,图片 style 增加 `max-height:100%;object-fit:contain;`。
|
||||
- **F2**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertImage` 改为打开自定义 Modal(替代 `prompt`)。Modal 包含:
|
||||
- 宽高输入框(默认 200*200)
|
||||
- 模式选择:手术影像占位(frame)/ 静态图片占位(manual)
|
||||
- 确认/取消按钮
|
||||
- **F3**:生成 placeholder HTML 时,manual 模式添加 `data-mode="manual"` 属性。
|
||||
- **F4**:`ReportEditor.tsx` 的 `autoCaptureFrames`(setTimeout 回调内)、`insertFrameToPlaceholder` 的空占位符选择器,从 `.image-placeholder:not(.has-image)` 改为 `.image-placeholder:not(.has-image):not([data-mode="manual"])`。
|
||||
- **F5**:`ReportEditor.tsx` 的 `handleDrop` 增加拦截:若目标 placeholder 的 `data-mode === 'manual'`,弹出提示并阻止填充。
|
||||
- **F6**:`ReportEditor.tsx` 和 `TemplateManage.tsx` 的 `insertTable` 改为打开自定义 Modal(替代 `prompt`),包含行数/列数输入和确认/取消按钮。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:已有报告中已有的 placeholder 结构不受影响(没有 `data-mode` 属性的占位符默认为 frame 模式)。
|
||||
- Modal 样式复用现有的 `bg-black/50 backdrop-blur-sm` + `bg-white rounded-2xl` 风格。
|
||||
|
||||
## 影响范围预估
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | fillPlaceholder 修复;insertImage 改为 Modal;insertTable 改为 Modal;autoCaptureFrames 选择器;insertFrameToPlaceholder 选择器;handleDrop 拦截;新增 3 个 Modal 的 JSX |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | insertImage 改为 Modal;insertTable 改为 Modal;新增 2 个 Modal 的 JSX |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无(用户已明确需求,且本次无需人工确认)。
|
||||
Reference in New Issue
Block a user