化学键线式Canvas编辑器设计
下面是一个基于HTML5 Canvas的化学键线式编辑器的基本实现方案。这个编辑器允许用户绘制有机化学中常见的键线式结构。
基本HTML结构
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>化学键线式编辑器</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
}
#editor-container {
display: flex;
flex-direction: column;
width: 800px;
margin: 0 auto;
}
#toolbar {
display: flex;
margin-bottom: 10px;
padding: 10px;
background: #f0f0f0;
border-radius: 5px;
}
.tool-btn {
margin-right: 10px;
padding: 5px 10px;
cursor: pointer;
}
.tool-btn.active {
background: #4CAF50;
color: white;
}
#canvas-container {
border: 1px solid #ccc;
margin-bottom: 10px;
}
canvas {
background: white;
display: block;
}
#status {
font-size: 12px;
color: #666;
}
/* 添加到style部分 */
#presets {
margin: 15px 0;
padding: 10px;
background: #f8f8f8;
border-radius: 5px;
}
#presets h3 {
margin-top: 0;
}
.preset-btn {
display: inline-block;
margin: 5px;
padding: 5px 10px;
background: #e0e0e0;
border-radius: 3px;
cursor: pointer;
}
.preset-btn:hover {
background: #d0d0d0;
}
#export-buttons {
margin-top: 15px;
}
#export-buttons button {
margin-right: 10px;
padding: 8px 15px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
#export-buttons button:hover {
background: #45a049;
}
</style>
</head>
<body>
<div id="editor-container">
<h1>化学键线式编辑器</h1>
<div id="toolbar">
<div class="tool-btn active" data-tool="select">选择</div>
<div class="tool-btn" data-tool="bond">单键</div>
<div class="tool-btn" data-tool="double-bond">双键</div>
<div class="tool-btn" data-tool="triple-bond">三键</div>
<div class="tool-btn" data-tool="wedge">楔形键</div>
<div class="tool-btn" data-tool="dash">虚线键</div>
<div class="tool-btn" data-tool="text">文本</div>
<div class="tool-btn" data-tool="erase">橡皮擦</div>
<div class="tool-btn" data-tool="clear">清空</div>
</div>
<!-- 在toolbar div后面添加以下内容 -->
<div id="presets">
<h3>常用结构</h3>
<div class="preset-btn" data-preset="benzene">苯环</div>
<div class="preset-btn" data-preset="cyclohexane">环己烷</div>
<div class="preset-btn" data-preset="cyclopentane">环戊烷</div>
<div class="preset-btn" data-preset="methane">甲烷</div>
<div class="preset-btn" data-preset="ethane">乙烷</div>
<div class="preset-btn" data-preset="ethene">乙烯</div>
<div class="preset-btn" data-preset="ethyne">乙炔</div>
</div>
<div id="export-buttons">
<button id="export-png">导出PNG</button>
<button id="export-svg">导出SVG</button>
<button id="copy-clipboard">复制到剪贴板</button>
</div>
<div id="canvas-container">
<canvas id="chem-canvas" width="800" height="500"></canvas>
</div>
<div id="status">就绪</div>
</div>
<script src="editor.js"></script>
</body>
</html>
JavaScript实现 (editor.js)
document.addEventListener('DOMContentLoaded', function() {
const canvas = document.getElementById('chem-canvas');
const ctx = canvas.getContext('2d');
const statusDiv = document.getElementById('status');
const toolButtons = document.querySelectorAll('.tool-btn');
let currentTool = 'select';
let isDrawing = false;
let startX, startY;
let elements = [];
let selectedElement = null;
// 工具按钮点击事件
toolButtons.forEach(button => {
button.addEventListener('click', function() {
toolButtons.forEach(btn => btn.classList.remove('active'));
this.classList.add('active');
currentTool = this.dataset.tool;
statusDiv.textContent = `当前工具: ${this.textContent}`;
});
});
// 鼠标事件处理
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);
function startDrawing(e) {
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
if (currentTool === 'select') {
// 检查是否点击了现有元素
selectedElement = getElementAtPosition(startX, startY);
if (selectedElement) {
isDrawing = true;
return;
}
}
isDrawing = true;
redrawCanvas();
}
function draw(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
redrawCanvas();
if (currentTool === 'select' && selectedElement) {
// 移动选中的元素
const dx = mouseX - startX;
const dy = mouseY - startY;
selectedElement.x += dx;
selectedElement.y += dy;
startX = mouseX;
startY = mouseY;
redrawCanvas();
return;
}
// 绘制预览
ctx.strokeStyle = '#999';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
switch(currentTool) {
case 'bond':
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(mouseX, mouseY);
ctx.stroke();
break;
case 'double-bond':
drawDoubleBond(startX, startY, mouseX, mouseY, true);
break;
case 'triple-bond':
drawTripleBond(startX, startY, mouseX, mouseY, true);
break;
case 'wedge':
drawWedgeBond(startX, startY, mouseX, mouseY, true);
break;
case 'dash':
drawDashBond(startX, startY, mouseX, mouseY, true);
break;
}
ctx.setLineDash([]);
}
function stopDrawing(e) {
if (!isDrawing) return;
const rect = canvas.getBoundingClientRect();
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
if (currentTool === 'select') {
isDrawing = false;
selectedElement = null;
return;
}
// 只添加元素如果移动了足够的距离
if (Math.abs(endX - startX) > 5 || Math.abs(endY - startY) > 5) {
switch(currentTool) {
case 'bond':
elements.push({
type: 'bond',
x1: startX,
y1: startY,
x2: endX,
y2: endY,
width: 2
});
break;
case 'double-bond':
elements.push({
type: 'double-bond',
x1: startX,
y1: startY,
x2: endX,
y2: endY,
width: 2
});
break;
case 'triple-bond':
elements.push({
type: 'triple-bond',
x1: startX,
y1: startY,
x2: endX,
y2: endY,
width: 2
});
break;
case 'wedge':
elements.push({
type: 'wedge',
x1: startX,
y1: startY,
x2: endX,
y2: endY,
width: 8
});
break;
case 'dash':
elements.push({
type: 'dash',
x1: startX,
y1: startY,
x2: endX,
y2: endY,
width: 2
});
break;
case 'text':
const text = prompt('输入原子符号或文本:', 'C');
if (text) {
elements.push({
type: 'text',
text: text,
x: startX,
y: startY,
fontSize: 16
});
}
break;
}
}
isDrawing = false;
redrawCanvas();
}
function redrawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制所有元素
elements.forEach(element => {
switch(element.type) {
case 'bond':
drawBond(element.x1, element.y1, element.x2, element.y2, element.width);
break;
case 'double-bond':
drawDoubleBond(element.x1, element.y1, element.x2, element.y2);
break;
case 'triple-bond':
drawTripleBond(element.x1, element.y1, element.x2, element.y2);
break;
case 'wedge':
drawWedgeBond(element.x1, element.y1, element.x2, element.y2);
break;
case 'dash':
drawDashBond(element.x1, element.y1, element.x2, element.y2);
break;
case 'text':
drawText(element.text, element.x, element.y, element.fontSize);
break;
}
});
// 高亮选中的元素
if (selectedElement) {
ctx.strokeStyle = '#4285F4';
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]);
switch(selectedElement.type) {
case 'bond':
case 'double-bond':
case 'triple-bond':
case 'wedge':
case 'dash':
ctx.beginPath();
ctx.moveTo(selectedElement.x1, selectedElement.y1);
ctx.lineTo(selectedElement.x2, selectedElement.y2);
ctx.stroke();
break;
case 'text':
ctx.strokeRect(
selectedElement.x - 5,
selectedElement.y - selectedElement.fontSize,
ctx.measureText(selectedElement.text).width + 10,
selectedElement.fontSize + 5
);
break;
}
ctx.setLineDash([]);
}
}
// 绘制单键
function drawBond(x1, y1, x2, y2, width = 2) {
ctx.strokeStyle = '#000';
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
// 绘制双键
function drawDoubleBond(x1, y1, x2, y2, isPreview = false) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const offset = isPreview ? 3 : 2;
// 计算垂直方向的偏移
const dx = -Math.sin(angle) * offset;
const dy = Math.cos(angle) * offset;
// 绘制两条平行线
drawBond(x1 + dx, y1 + dy, x2 + dx, y2 + dy);
drawBond(x1 - dx, y1 - dy, x2 - dx, y2 - dy);
}
// 绘制三键
function drawTripleBond(x1, y1, x2, y2, isPreview = false) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const offset = isPreview ? 5 : 3;
// 计算垂直方向的偏移
const dx = -Math.sin(angle) * offset;
const dy = Math.cos(angle) * offset;
// 绘制三条平行线
drawBond(x1, y1, x2, y2);
drawBond(x1 + dx, y1 + dy, x2 + dx, y2 + dy);
drawBond(x1 - dx, y1 - dy, x2 - dx, y2 - dy);
}
// 绘制楔形键
function drawWedgeBond(x1, y1, x2, y2, isPreview = false) {
const angle = Math.atan2(y2 - y1, x2 - x1);
const width = isPreview ? 8 : 6;
// 计算楔形的两个端点
const dx = -Math.sin(angle) * width / 2;
const dy = Math.cos(angle) * width / 2;
ctx.fillStyle = isPreview ? 'rgba(0, 0, 0, 0.3)' : '#000';
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2 + dx, y2 + dy);
ctx.lineTo(x2 - dx, y2 - dy);
ctx.closePath();
ctx.fill();
}
// 绘制虚线键
function drawDashBond(x1, y1, x2, y2, isPreview = false) {
ctx.strokeStyle = isPreview ? 'rgba(0, 0, 0, 0.3)' : '#000';
ctx.lineWidth = 2;
ctx.setLineDash([5, 3]);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.setLineDash([]);
}
// 绘制文本
function drawText(text, x, y, fontSize = 16) {
ctx.font = `${fontSize}px Arial`;
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, x, y);
}
// 获取指定位置的元素
function getElementAtPosition(x, y) {
// 首先检查文本元素
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (element.type === 'text') {
ctx.font = `${element.fontSize}px Arial`;
const textWidth = ctx.measureText(element.text).width;
const textHeight = element.fontSize;
if (x >= element.x - textWidth/2 - 5 &&
x <= element.x + textWidth/2 + 5 &&
y >= element.y - textHeight/2 - 5 &&
y <= element.y + textHeight/2 + 5) {
return element;
}
}
}
// 然后检查键
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (element.type !== 'text') {
// 计算点到线段的距离
if (isPointNearLine(x, y, element.x1, element.y1, element.x2, element.y2, 8)) {
return element;
}
}
}
return null;
}
// 检查点是否靠近线段
function isPointNearLine(px, py, x1, y1, x2, y2, threshold) {
// 线段长度
const lineLength = Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
// 计算点到线段的投影
const u = ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / (lineLength * lineLength);
// 如果投影在线段外,计算到端点的距离
if (u < 0) {
return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1)) <= threshold;
} else if (u > 1) {
return Math.sqrt((px - x2) * (px - x2) + (py - y2) * (py - y2)) <= threshold;
}
// 计算投影点
const projX = x1 + u * (x2 - x1);
const projY = y1 + u * (y2 - y1);
// 计算点到投影点的距离
const distance = Math.sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY));
return distance <= threshold;
}
// 清空画布
document.querySelector('[data-tool="clear"]').addEventListener('click', function() {
if (confirm('确定要清空画布吗?')) {
elements = [];
redrawCanvas();
}
});
// 橡皮擦工具
document.querySelector('[data-tool="erase"]').addEventListener('click', function() {
currentTool = 'erase';
canvas.style.cursor = 'url("data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'16\' height=\'16\' viewBox=\'0 0 16 16\'><circle cx=\'8\' cy=\'8\' r=\'8\' fill=\'red\' opacity=\'0.5\'/></svg>") 8 8, auto';
});
canvas.addEventListener('click', function(e) {
if (currentTool === 'erase') {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const element = getElementAtPosition(x, y);
if (element) {
elements = elements.filter(el => el !== element);
redrawCanvas();
}
}
});
// 在DOMContentLoaded事件监听器内添加以下代码
// 预设结构
const presets = {
benzene: {
name: "苯环",
elements: [
// 六边形的六个边
// { type: 'bond', x1: 200, y1: 100, x2: 250, y2: 100, width: 2 },
{ type: 'bond', x1: 250, y1: 100, x2: 275, y2: 135, width: 2 },
// { type: 'bond', x1: 275, y1: 135, x2: 250, y2: 170, width: 2 },
{ type: 'bond', x1: 250, y1: 170, x2: 200, y2: 170, width: 2 },
// { type: 'bond', x1: 200, y1: 170, x2: 175, y2: 135, width: 2 },
{ type: 'bond', x1: 175, y1: 135, x2: 200, y2: 100, width: 2 },
// 三个交替的双键
{ type: 'double-bond', x1: 200, y1: 100, x2: 250, y2: 100, width: 2 },
{ type: 'double-bond', x1: 275, y1: 135, x2: 250, y2: 170, width: 2 },
{ type: 'double-bond', x1: 200, y1: 170, x2: 175, y2: 135, width: 2 },
// 可选:添加圆圈表示芳香性
// { type: 'aromatic-circle', cx: 225, cy: 135, radius: 30, width: 1 }
]
},
cyclohexane: {
name: "环己烷",
elements: [
{ type: 'bond', x1: 200, y1: 100, x2: 250, y2: 100, width: 2 },
{ type: 'bond', x1: 250, y1: 100, x2: 275, y2: 140, width: 2 },
{ type: 'bond', x1: 275, y1: 140, x2: 250, y2: 180, width: 2 },
{ type: 'bond', x1: 250, y1: 180, x2: 200, y2: 180, width: 2 },
{ type: 'bond', x1: 200, y1: 180, x2: 175, y2: 140, width: 2 },
{ type: 'bond', x1: 175, y1: 140, x2: 200, y2: 100, width: 2 }
]
},
cyclopentane: {
name: "环戊烷",
elements: [
{ type: 'bond', x1: 200, y1: 100, x2: 240, y2: 100, width: 2 },
{ type: 'bond', x1: 240, y1: 100, x2: 260, y2: 130, width: 2 },
{ type: 'bond', x1: 260, y1: 130, x2: 220, y2: 160, width: 2 },
{ type: 'bond', x1: 220, y1: 160, x2: 180, y2: 130, width: 2 },
{ type: 'bond', x1: 180, y1: 130, x2: 200, y2: 100, width: 2 }
]
},
methane: {
name: "甲烷",
elements: [
{ type: 'bond', x1: 200, y1: 100, x2: 200, y2: 150, width: 2 },
{ type: 'bond', x1: 200, y1: 150, x2: 180, y2: 180, width: 2 },
{ type: 'bond', x1: 200, y1: 150, x2: 220, y2: 180, width: 2 },
{ type: 'wedge', x1: 200, y1: 150, x2: 190, y2: 120, width: 6 },
{ type: 'dash', x1: 200, y1: 150, x2: 210, y2: 120, width: 2 }
]
},
ethane: {
name: "乙烷",
elements: [
{ type: 'bond', x1: 150, y1: 150, x2: 200, y2: 150, width: 2 },
{ type: 'bond', x1: 200, y1: 150, x2: 250, y2: 150, width: 2 },
{ type: 'bond', x1: 150, y1: 150, x2: 130, y2: 180, width: 2 },
{ type: 'bond', x1: 150, y1: 150, x2: 170, y2: 180, width: 2 },
{ type: 'bond', x1: 250, y1: 150, x2: 230, y2: 180, width: 2 },
{ type: 'bond', x1: 250, y1: 150, x2: 270, y2: 180, width: 2 }
]
},
ethene: {
name: "乙烯",
elements: [
{ type: 'bond', x1: 150, y1: 150, x2: 200, y2: 150, width: 2 },
{ type: 'double-bond', x1: 200, y1: 150, x2: 250, y2: 150, width: 2 },
{ type: 'bond', x1: 150, y1: 150, x2: 150, y2: 180, width: 2 },
{ type: 'bond', x1: 250, y1: 150, x2: 250, y2: 180, width: 2 }
]
},
ethyne: {
name: "乙炔",
elements: [
{ type: 'bond', x1: 150, y1: 150, x2: 200, y2: 150, width: 2 },
{ type: 'triple-bond', x1: 200, y1: 150, x2: 250, y2: 150, width: 2 }
]
}
};
// 预设按钮点击事件
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', function() {
const presetName = this.dataset.preset;
if (presets[presetName]) {
elements = JSON.parse(JSON.stringify(presets[presetName].elements));
redrawCanvas();
statusDiv.textContent = `已加载预设: ${presets[presetName].name}`;
}
});
});
// 导出PNG
document.getElementById('export-png').addEventListener('click', function() {
const dataURL = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = 'chemical-structure.png';
link.href = dataURL;
link.click();
});
// 导出SVG
document.getElementById('export-svg').addEventListener('click', function() {
const svgData = generateSVG();
const blob = new Blob([svgData], {type: 'image/svg+xml'});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.download = 'chemical-structure.svg';
link.href = url;
link.click();
});
// 复制到剪贴板
document.getElementById('copy-clipboard').addEventListener('click', function() {
canvas.toBlob(function(blob) {
navigator.clipboard.write([
new ClipboardItem({
'image/png': blob
})
]).then(() => {
statusDiv.textContent = '图像已复制到剪贴板';
}).catch(err => {
statusDiv.textContent = '复制失败: ' + err;
});
});
});
// 生成SVG
function generateSVG() {
const width = canvas.width;
const height = canvas.height;
let svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="white"/>
`;
elements.forEach(element => {
switch(element.type) {
case 'bond':
svg += ` <line x1="${element.x1}" y1="${element.y1}" x2="${element.x2}" y2="${element.y2}" stroke="black" stroke-width="${element.width}"/>\n`;
break;
case 'double-bond':
const angle1 = Math.atan2(element.y2 - element.y1, element.x2 - element.x1);
const dx1 = -Math.sin(angle1) * 2;
const dy1 = Math.cos(angle1) * 2;
svg += ` <line x1="${element.x1 + dx1}" y1="${element.y1 + dy1}" x2="${element.x2 + dx1}" y2="${element.y2 + dy1}" stroke="black" stroke-width="${element.width}"/>\n`;
svg += ` <line x1="${element.x1 - dx1}" y1="${element.y1 - dy1}" x2="${element.x2 - dx1}" y2="${element.y2 - dy1}" stroke="black" stroke-width="${element.width}"/>\n`;
break;
case 'triple-bond':
const angle2 = Math.atan2(element.y2 - element.y1, element.x2 - element.x1);
const dx2 = -Math.sin(angle2) * 3;
const dy2 = Math.cos(angle2) * 3;
svg += ` <line x1="${element.x1}" y1="${element.y1}" x2="${element.x2}" y2="${element.y2}" stroke="black" stroke-width="${element.width}"/>\n`;
svg += ` <line x1="${element.x1 + dx2}" y1="${element.y1 + dy2}" x2="${element.x2 + dx2}" y2="${element.y2 + dy2}" stroke="black" stroke-width="${element.width}"/>\n`;
svg += ` <line x1="${element.x1 - dx2}" y1="${element.y1 - dy2}" x2="${element.x2 - dx2}" y2="${element.y2 - dy2}" stroke="black" stroke-width="${element.width}"/>\n`;
break;
case 'wedge':
const angle3 = Math.atan2(element.y2 - element.y1, element.x2 - element.x1);
const dx3 = -Math.sin(angle3) * element.width / 2;
const dy3 = Math.cos(angle3) * element.width / 2;
svg += ` <path d="M${element.x1},${element.y1} L${element.x2 + dx3},${element.y2 + dy3} L${element.x2 - dx3},${element.y2 - dy3} Z" fill="black"/>\n`;
break;
case 'dash':
svg += ` <line x1="${element.x1}" y1="${element.y1}" x2="${element.x2}" y2="${element.y2}" stroke="black" stroke-width="${element.width}" stroke-dasharray="5,3"/>\n`;
break;
case 'text':
svg += ` <text x="${element.x}" y="${element.y}" font-family="Arial" font-size="${element.fontSize}" text-anchor="middle" dominant-baseline="middle">${element.text}</text>\n`;
break;
}
});
svg += '</svg>';
return svg;
}
// 初始化
redrawCanvas();
});
功能说明
这个化学键线式编辑器提供以下功能:
基本工具:
- 选择工具:选择和移动已有元素
- 单键、双键、三键工具
- 楔形键(实心三角形)和虚线键工具
- 文本工具:添加原子符号或注释
- 橡皮擦工具:删除元素
- 清空画布
交互功能:
- 点击拖动绘制化学键
- 选择并移动已有元素
- 点击删除元素
- 实时预览绘制效果
化学键表示:
- 正确绘制不同类型的化学键
- 楔形键表示立体化学
- 虚线键表示远离观察者的键