cadeditfronttest/components/DrawingViewer.js

599 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Use React.* APIs without destructuring to avoid global redeclaration conflicts
// A helper function to create SVG text elements, handling the coordinate system flip.
function SvgText({ content, x, y, height, color = '#2563eb', alignment = 'BOTTOM_LEFT' }) {
const textAnchor = (alignment === 'BOTTOM_CENTER') ? 'middle' : (alignment === 'BOTTOM_RIGHT' ? 'end' : 'start');
// We apply a transform to flip the text right-side-up after the group's transform.
return React.createElement('text', {
x: x,
y: -y, // Y is flipped in the group, so we negate it here.
fontSize: height,
fill: color,
transform: 'scale(1, -1)', // Flip text back upright
textAnchor: textAnchor,
style: {
fontFamily: "'KaiTi', 'SimSun', sans-serif",
userSelect: 'none', // 禁用文字选择
pointerEvents: 'none' // 让文字不接收鼠标事件,事件将传递给父元素
}
}, content);
}
function GraphicalTable({ template, items, allItems, drawings }) {
if (!template) return null;
const { header_height, row_height, header_definition, column_definitions, column_boundaries } = template;
const strokeColor = "#334155", strokeWidth = 0.3;
const normalize = (name) => (name || '').replace(/\s+/g, '').replace('名称', '名称').replace('件号', '件号').replace('材料', '材料').replace('备注', '备注');
const tableWidth = Math.max(...column_boundaries);
const totalRows = items.length;
const tableHeight = header_height + (totalRows * row_height);
return React.createElement('g', null,
// Outer highlighted border (does not change coordinates)
React.createElement('rect', {
x: 0,
y: 0,
width: tableWidth,
height: tableHeight,
fill: 'none',
stroke: '#2563eb',
strokeWidth: 1.5,
opacity: 0.95
}),
// Header
React.createElement('g', { className: 'table-header' },
header_definition.lines.map((line, i) => React.createElement('line', {
key: `h-line-${i}`,
x1: line.start[0],
y1: line.start[1],
x2: line.end[0],
y2: line.end[1],
stroke: strokeColor,
strokeWidth: strokeWidth,
})),
header_definition.texts.map((text, i) => React.createElement(SvgText, {
key: `h-text-${i}`,
content: text.content,
x: text.relative_pos[0],
y: text.relative_pos[1],
height: text.height,
alignment: text.alignment,
}))
),
// Body Rows
React.createElement('g', { className: 'table-body' },
items.map((item, rowIndex) => {
const rowY = header_height + (rowIndex * row_height);
return React.createElement('g', { key: `row-${item.id}`, transform: `translate(0, ${rowY})`},
// Render horizontal line for the row
React.createElement('line', {
x1: 0, y1: row_height, x2: tableWidth, y2: row_height,
stroke: strokeColor, strokeWidth: strokeWidth
}),
// Render cell data
column_definitions.map(colDef => {
const normName = normalize(colDef.name);
const cell = {
'件号': item.id,
'图号或标准号': drawings[item.drawingId]?.drawingNumber || '',
'名称': {
chinese_name: item.chinese_name || item.name || '',
english_name: item.english_name || '',
specification: item.specification || ''
},
'数量': item.quantity,
'单': (item.weight ?? 0).toFixed(2),
'总': ((item.quantity ?? 0) * (item.weight ?? 0)).toFixed(2),
'材料': item.material?.main || item.material || item.chinese_material || 'Q235',
'备注': item.remark || '',
'质量': (item.weight ?? 0).toFixed(2),
'比例': item.scale || '1:1',
'所在图号': item.dwgNo || drawings[item.drawingId]?.drawingNumber || '',
'装配图号': item.assyDwgNo || (allItems[item.parentId] ? (drawings[allItems[item.parentId]?.drawingId]?.drawingNumber || '') : '')
};
const dataItem = cell[normName];
const isObject = typeof dataItem === 'object' && dataItem !== null;
// 名称列:按定义分别渲染中文/英文/规格
if (normName === '名称' && isObject) {
return colDef.text_definitions.map(textDef => {
const content = dataItem[textDef.data_key] || '';
if (content === null || content === undefined || content === '') return null;
return React.createElement(SvgText, {
key: `${item.id}-${normName}-${textDef.data_key || 'main'}`,
content,
x: colDef.relative_x_start + textDef.relative_pos[0],
y: textDef.relative_pos[1],
height: textDef.height,
alignment: textDef.alignment,
});
});
}
// 非对象数据:仅渲染一个主文本,避免重复(例如材料为字符串)
const primaryDef = colDef.text_definitions.find(td => td.data_key === 'main') || colDef.text_definitions[0];
const content = dataItem;
if (content === null || content === undefined) return null;
return React.createElement(SvgText, {
key: `${item.id}-${normName}-main` ,
content,
x: colDef.relative_x_start + primaryDef.relative_pos[0],
y: primaryDef.relative_pos[1],
height: primaryDef.height,
alignment: primaryDef.alignment,
});
})
);
}),
// Vertical lines
column_boundaries.map((xPos, i) => React.createElement('line', {
key: `v-line-${i}`,
x1: xPos, y1: 0, x2: xPos, y2: tableHeight,
stroke: strokeColor, strokeWidth: strokeWidth
}))
)
);
}
function DrawingViewer({ drawing, item, items, drawings, template, tablePositions, setTablePositions, onCycleDrawing, onItemRename, onItemUpdate, onBindDrawing, onUpdateImagePosition }) {
const isInClass = (node, className) => {
let el = node;
while (el && el !== viewerRef.current) {
// 检查CSS class
if (el.classList && el.classList.contains(className)) return true;
// 检查SVG元素的特殊属性
if (el.getAttribute && el.getAttribute('data-draggable') === className) return true;
// 检查className属性对于SVG元素
if (el.className && el.className.baseVal && el.className.baseVal.includes(className)) return true;
el = el.parentNode;
}
return false;
};
const [viewTransform, setViewTransform] = React.useState({ x: 0, y: 0, scale: 1 });
const [editedItemId, setEditedItemId] = React.useState('');
const [inputBox, setInputBox] = React.useState(null); // { x, y, drawingId, show: true }
const viewerRef = React.useRef(null);
const inputRef = React.useRef(null);
const isPanning = React.useRef(false);
const isDraggingTable = React.useRef(false);
const isDraggingImage = React.useRef(false);
const draggedImageName = React.useRef(null);
const lastMousePos = React.useRef({ x: 0, y: 0 });
React.useEffect(() => {
if (item) {
setEditedItemId(item.id);
}
}, [item]);
React.useEffect(() => {
if (drawing && viewerRef.current) {
const container = viewerRef.current;
const containerWidth = container.offsetWidth;
const containerHeight = container.offsetHeight;
// Compute based on the actual rendered image bounding box
const img = drawing.images && drawing.images[0];
if (!img) return;
const contentX = img.coords[0].x;
const contentY = img.coords[0].y - img.height; // top-left y in SVG coords
const contentWidth = img.width;
const contentHeight = img.height;
if (contentWidth <= 0 || contentHeight <= 0) return;
const scaleX = containerWidth / contentWidth;
const scaleY = containerHeight / contentHeight;
const scale = Math.min(scaleX, scaleY) * 0.9;
const initialX = (containerWidth - contentWidth * scale) / 2 - contentX * scale;
const initialY = (containerHeight - contentHeight * scale) / 2 - contentY * scale;
setViewTransform({ x: initialX, y: initialY, scale });
}
// Reset input when switching drawing
setEditedItemId('');
}, [drawing]);
const handleCanvasClick = (e) => {
// Ignore clicks within UI overlays or existing input boxes
if (isInClass(e.target, 'ui-overlay') || isInClass(e.target, 'input-overlay')) return;
// 检查是否点击了表格或图片(这些不应该触发添加件号)
if (isInClass(e.target, 'graphical-table') || isInClass(e.target, 'draggable-image')) {
return;
}
// 点击空白区域,显示输入框
const rect = viewerRef.current.getBoundingClientRect();
const svgX = (e.clientX - rect.left - viewTransform.x) / viewTransform.scale;
const svgY = (e.clientY - rect.top - viewTransform.y) / viewTransform.scale;
setInputBox({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
svgX: svgX,
svgY: svgY,
drawingId: drawing?.id,
show: true
});
e.stopPropagation();
};
const handleMouseDown = (e) => {
// Ignore clicks within UI overlays
if (isInClass(e.target, 'ui-overlay') || isInClass(e.target, 'input-overlay')) return;
// 重置所有拖拽状态
isPanning.current = false;
isDraggingTable.current = false;
isDraggingImage.current = false;
draggedImageName.current = null;
const target = e.target;
const hitTable = isInClass(e.target, 'graphical-table');
const hitImage = isInClass(e.target, 'draggable-image');
// 检查是否点击了表格
if (hitTable) {
isDraggingTable.current = true;
lastMousePos.current = { x: e.clientX, y: e.clientY };
if (viewerRef.current) viewerRef.current.style.cursor = 'move';
try {
if (e.target.setPointerCapture && typeof e.pointerId === 'number') {
e.target.setPointerCapture(e.pointerId);
}
} catch (err) {}
e.stopPropagation();
e.preventDefault();
return;
}
// 检查是否点击了图片
if (hitImage) {
isDraggingImage.current = true;
draggedImageName.current = e.target.getAttribute('data-image-name');
lastMousePos.current = { x: e.clientX, y: e.clientY };
if (viewerRef.current) viewerRef.current.style.cursor = 'move';
e.stopPropagation();
e.preventDefault();
return;
}
// 否则开始画布平移
isPanning.current = true;
lastMousePos.current = { x: e.clientX, y: e.clientY };
if (viewerRef.current) viewerRef.current.style.cursor = 'grabbing';
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isPanning.current && !isDraggingTable.current && !isDraggingImage.current) return;
const dx = (e.clientX - lastMousePos.current.x);
const dy = (e.clientY - lastMousePos.current.y);
if (isDraggingTable.current) {
const tableId = drawing.id;
const defaultPos = (() => {
const img = drawing.images && drawing.images[0];
const width = template ? Math.max(...template.column_boundaries) : 0;
if (img) {
return { x: img.coords[0].x + img.width - width - 10, y: img.coords[0].y - 10 };
}
return { x: drawing.maxX - width, y: drawing.minY };
})();
setTablePositions(prev => {
const base = prev[tableId] || defaultPos;
const updated = {
x: base.x + (dx / viewTransform.scale),
y: base.y + (dy / viewTransform.scale)
};
return { ...prev, [tableId]: updated };
});
} else if (isDraggingImage.current && draggedImageName.current && onUpdateImagePosition) {
// 图片拖拽:直接修改世界坐标
const worldDx = dx / viewTransform.scale;
const worldDy = -dy / viewTransform.scale; // Y 在SVG中是反向的
onUpdateImagePosition(drawing.id, draggedImageName.current, worldDx, worldDy);
} else if(isPanning.current) {
setViewTransform(prev => ({ ...prev, x: prev.x + dx, y: prev.y + dy }));
}
lastMousePos.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = (e) => {
isPanning.current = false;
isDraggingTable.current = false;
isDraggingImage.current = false;
draggedImageName.current = null;
if (viewerRef.current) {
viewerRef.current.style.cursor = 'grab';
}
};
const handleRenameItem = () => {
if (!editedItemId.trim() || editedItemId.trim() === item.id) return;
onItemRename(item.id, editedItemId.trim());
};
const handleCreateUnderRoot = () => {
const id = (editedItemId || '').trim();
if (!id) return;
onItemUpdate({
id,
name: `新建部件 ${id}`,
quantity: 1,
weight: 0,
drawingId: drawing ? drawing.id : null,
parentId: '__ROOT__',
children: []
});
setEditedItemId('');
};
const handleInputBoxSubmit = (newItemId) => {
if (!newItemId.trim() || !inputBox) return;
const trimmedId = newItemId.trim();
// 创建新件号
onItemUpdate({
id: trimmedId,
name: `新建部件 ${trimmedId}`,
quantity: 1,
weight: 0,
drawingId: inputBox.drawingId,
parentId: item?.id || '__ROOT__',
children: [],
// 保存件号标记的位置
markerPosition: {
svgX: inputBox.svgX,
svgY: inputBox.svgY
}
});
// 关闭输入框
setInputBox(null);
};
const handleInputBoxCancel = () => {
setInputBox(null);
};
// 自动聚焦输入框
React.useEffect(() => {
if (inputBox && inputRef.current) {
inputRef.current.focus();
}
}, [inputBox]);
const handleZoom = (factor, centerX, centerY) => {
setViewTransform(prev => {
const newScale = prev.scale * factor;
const dx = (centerX - prev.x) * (1 - factor);
const dy = (centerY - prev.y) * (1 - factor);
return { scale: newScale, x: prev.x + dx, y: prev.y + dy };
});
};
const handleWheel = (e) => {
e.preventDefault();
const rect = viewerRef.current.getBoundingClientRect();
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
handleZoom(factor, e.clientX - rect.left, e.clientY - rect.top);
};
React.useEffect(() => {
const viewer = viewerRef.current;
if (viewer) {
const opts = { passive: false };
viewer.addEventListener('pointerdown', handleMouseDown, opts);
viewer.addEventListener('click', handleCanvasClick, opts);
document.addEventListener('pointermove', handleMouseMove, opts);
document.addEventListener('pointerup', handleMouseUp, opts);
viewer.addEventListener('wheel', handleWheel, opts);
}
return () => {
if (viewer) {
viewer.removeEventListener('pointerdown', handleMouseDown);
viewer.removeEventListener('click', handleCanvasClick);
document.removeEventListener('pointermove', handleMouseMove);
document.removeEventListener('pointerup', handleMouseUp);
viewer.removeEventListener('wheel', handleWheel);
}
};
}, []);
if (!item) {
return React.createElement('div', { className: 'workspace-panel flex items-center justify-center h-full' },
React.createElement('p', { className: 'text-gray-500' }, '请在左侧选择一个件号')
);
}
if (!drawing) {
return React.createElement('div', { className: 'workspace-panel flex flex-col items-center justify-center h-full' },
React.createElement('p', { className: 'text-gray-500 mb-4' }, `件号 "${item.id}" 尚未绑定图纸`),
);
}
// 首先定义 isRoot 变量
const isRoot = item && item.id === '__ROOT__';
// 根据当前选中的item决定要显示的数据
// 如果是根节点,显示第一层子节点;否则显示当前节点的子节点
const displayItems = isRoot
? Object.values(items).filter(it => it.parentId === '__ROOT__')
: [item]; // 对于非根节点,显示自身信息
const tableWidth = template ? Math.max(...template.column_boundaries) : 0;
// Use position from state, or default to bottom-right
let tablePos = tablePositions[drawing.id];
if (!tablePos) {
const img = drawing.images && drawing.images[0];
if (img) {
const safeX = img.coords[0].x + img.width - tableWidth - 10;
const safeY = img.coords[0].y - 10;
tablePos = { x: safeX, y: safeY };
} else {
tablePos = { x: drawing.maxX - tableWidth, y: drawing.minY };
}
}
return React.createElement('div', { className: 'workspace-panel h-full relative bg-gray-50 overflow-hidden', ref: viewerRef, style: { cursor: 'grab', touchAction: 'none' } },
// 动态输入框 - 点击图片时出现
inputBox && React.createElement('div', {
className: 'absolute z-20 input-overlay',
style: {
left: inputBox.x - 50,
top: inputBox.y - 15,
transform: 'translate(-50%, -50%)'
}
},
React.createElement('div', { className: 'bg-white p-2 rounded shadow-lg border flex items-center gap-2' },
React.createElement('input', {
ref: inputRef,
type: 'text',
placeholder: '输入件号',
className: 'form-input w-24 text-sm py-1 px-2',
onKeyDown: (e) => {
if (e.key === 'Enter') {
handleInputBoxSubmit(e.target.value);
} else if (e.key === 'Escape') {
handleInputBoxCancel();
}
e.stopPropagation();
},
onClick: (e) => e.stopPropagation(),
onBlur: () => setTimeout(() => setInputBox(null), 200) // 延迟关闭,允许用户点击确认
}),
React.createElement('button', {
className: 'bg-blue-500 text-white px-2 py-1 text-xs rounded hover:bg-blue-600',
onClick: (e) => {
handleInputBoxSubmit(inputRef.current.value);
e.stopPropagation();
}
}, '确认')
)
),
// 左上:输入区(阻止事件冒泡,避免影响拖拽/缩放)
React.createElement('div', { className: 'absolute top-4 left-4 z-10 ui-overlay' },
React.createElement('div', { className: 'bg-white p-2 rounded-lg shadow-md flex items-center gap-2' },
React.createElement('span', { className: 'text-sm font-medium mr-2' }, '设置图片件号:'),
React.createElement('input', {
type: 'text',
placeholder: '输入件号,存在则绑定,不存在则创建',
className: 'form-input w-64',
value: editedItemId,
onChange: (e) => setEditedItemId(e.target.value),
onKeyDown: (e) => { if (e.key === 'Enter') (items[editedItemId] ? onBindDrawing(editedItemId, drawing.id) : handleCreateUnderRoot()); }
}),
React.createElement('button', { className: 'btn-primary', onClick: () => (items[editedItemId] ? onBindDrawing(editedItemId, drawing.id) : handleCreateUnderRoot()) }, '绑定')
)
),
// 右上:缩放按钮(内联 SVG
React.createElement('div', { className: 'absolute top-4 right-4 z-10 bg-white p-1 rounded-lg shadow-md flex flex-col items-center gap-1 ui-overlay' },
React.createElement('button', { className: 'p-2 hover:bg-gray-100 rounded', onClick: () => handleZoom(1.2, viewerRef.current.offsetWidth/2, viewerRef.current.offsetHeight/2) },
React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: '#1f2937', strokeWidth: 2 },
React.createElement('circle', { cx: 11, cy: 11, r: 7 }),
React.createElement('line', { x1: 21, y1: 21, x2: 16.65, y2: 16.65 }),
React.createElement('line', { x1: 11, y1: 8, x2: 11, y2: 14 }),
React.createElement('line', { x1: 8, y1: 11, x2: 14, y2: 11 })
)
),
React.createElement('button', { className: 'p-2 hover:bg-gray-100 rounded', onClick: () => handleZoom(1 / 1.2, viewerRef.current.offsetWidth/2, viewerRef.current.offsetHeight/2) },
React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: '#1f2937', strokeWidth: 2 },
React.createElement('circle', { cx: 11, cy: 11, r: 7 }),
React.createElement('line', { x1: 21, y1: 21, x2: 16.65, y2: 16.65 }),
React.createElement('line', { x1: 8, y1: 11, x2: 14, y2: 11 })
)
)
),
// 左右切换按钮(内联 SVG
React.createElement('button', {
className: 'absolute left-4 top-1/2 -translate-y-1/2 z-10 p-3 bg-white/60 rounded-full hover:bg-white transition-colors shadow-md',
onClick: () => onCycleDrawing(-1),
onMouseDown: (e) => e.stopPropagation()
},
React.createElement('svg', { width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: '#1f2937', strokeWidth: 2 },
React.createElement('polyline', { points: '15 18 9 12 15 6' })
)
),
React.createElement('button', {
className: 'absolute right-4 top-1/2 -translate-y-1/2 z-10 p-3 bg-white/60 rounded-full hover:bg-white transition-colors shadow-md',
onClick: () => onCycleDrawing(1),
onMouseDown: (e) => e.stopPropagation()
},
React.createElement('svg', { width: 28, height: 28, viewBox: '0 0 24 24', fill: 'none', stroke: '#1f2937', strokeWidth: 2 },
React.createElement('polyline', { points: '9 18 15 12 9 6' })
)
),
// SVG Canvas
React.createElement('svg', { width: '100%', height: '100%'},
React.createElement('g', { transform: `translate(${viewTransform.x}, ${viewTransform.y}) scale(${viewTransform.scale})` },
// Images
drawing.images.map(img => React.createElement('image', {
key: img.name,
className: 'draggable-image',
href: img.url,
x: img.coords[0].x,
y: img.coords[0].y - img.height, // SVG y starts from top, our coords from bottom
width: img.width,
height: img.height,
'data-image-name': img.name,
'data-draggable': 'draggable-image',
style: { cursor: 'move' },
onError: (e) => {
console.warn(`图片加载失败: ${img.url}`);
// 可以设置一个默认的占位符
e.target.style.opacity = '0.3';
e.target.style.fill = '#f3f4f6';
}
})),
// Graphical Table - 当件号绑定了图纸时渲染(包括根节点总装图)
template && item && item.drawingId && drawing && drawing.id === item.drawingId && React.createElement('g', {
className: 'graphical-table',
transform: `translate(${tablePos.x}, ${tablePos.y}) scale(1, -1)`,
style: { cursor: 'move' },
'data-draggable': 'graphical-table',
onPointerDown: (e) => { handleMouseDown(e); },
onPointerMove: (e) => { if (isDraggingTable.current) { handleMouseMove(e); } },
onPointerUp: (e) => { handleMouseUp(e); }
},
// 背景矩形 - 提供拖拽区域和视觉反馈
React.createElement('rect', {
x: -5,
y: -5,
width: Math.max(...template.column_boundaries) + 10,
height: template.header_height + (displayItems.length * template.row_height) + 10,
fill: 'rgba(37, 99, 235, 0.05)', // 轻微的蓝色背景
stroke: 'rgba(37, 99, 235, 0.2)',
strokeWidth: 1,
rx: 3, // 圆角
pointerEvents: 'all',
'data-draggable': 'graphical-table',
style: { cursor: 'move' },
onPointerDown: (e) => { handleMouseDown(e); },
onPointerMove: (e) => { if (isDraggingTable.current) { handleMouseMove(e); } },
onPointerUp: (e) => { handleMouseUp(e); }
}),
// 表格内容
React.createElement(GraphicalTable, { template, items: displayItems, allItems: items, drawings })
)
)
)
);
}