889 lines
28 KiB
JavaScript
889 lines
28 KiB
JavaScript
class PaintManager {
|
|
constructor(canvas, ctx) {
|
|
this.canvas = canvas;
|
|
this.ctx = ctx;
|
|
this.painting = false;
|
|
this.lastX = 0;
|
|
this.lastY = 0;
|
|
this.brushColor = "#000000";
|
|
this.brushSize = 2;
|
|
this.currentTool = null;
|
|
this.textElements = [];
|
|
this.lineSegments = [];
|
|
this.isTextPlacementMode = false;
|
|
this.draggingCanvasContext = null;
|
|
this.selectedTextElement = null;
|
|
this.isDraggingText = false;
|
|
this.dragOffsetX = 0;
|
|
this.dragOffsetY = 0;
|
|
this.textBold = false;
|
|
this.textItalic = false;
|
|
|
|
// Brush cursor indicator
|
|
this.brushCursor = null;
|
|
|
|
// Undo/Redo functionality
|
|
this.historyStack = [];
|
|
this.historyStep = -1;
|
|
this.MAX_HISTORY = 50;
|
|
|
|
// Bind event handlers
|
|
this.startPaint = this.startPaint.bind(this);
|
|
this.paint = this.paint.bind(this);
|
|
this.endPaint = this.endPaint.bind(this);
|
|
this.handleCanvasClick = this.handleCanvasClick.bind(this);
|
|
this.onTouchStart = this.onTouchStart.bind(this);
|
|
this.onTouchMove = this.onTouchMove.bind(this);
|
|
this.onTouchEnd = this.onTouchEnd.bind(this);
|
|
this.handleKeyboard = this.handleKeyboard.bind(this);
|
|
this.updateBrushCursor = this.updateBrushCursor.bind(this);
|
|
this.hideBrushCursor = this.hideBrushCursor.bind(this);
|
|
}
|
|
|
|
buildFontString(fontFamily, fontSize, bold = false, italic = false) {
|
|
const fontParts = [];
|
|
if (italic) fontParts.push('italic');
|
|
if (bold) fontParts.push('bold');
|
|
fontParts.push(`${fontSize}px`);
|
|
fontParts.push(fontFamily);
|
|
return fontParts.join(' ');
|
|
}
|
|
|
|
parseInlineMarkdown(text, baseStyle = {}) {
|
|
const patterns = [
|
|
{
|
|
regex: /`([^`]+)`/g,
|
|
style: { code: true, fontFamily: 'monospace', background: 'rgba(0, 0, 0, 0.08)' }
|
|
},
|
|
{ regex: /\*\*([^*]+)\*\*/g, style: { bold: true } },
|
|
{ regex: /__([^_]+)__/g, style: { bold: true } },
|
|
{ regex: /\*([^*]+)\*/g, style: { italic: true } },
|
|
{ regex: /_([^_]+)_/g, style: { italic: true } },
|
|
{ regex: /~~([^~]+)~~/g, style: { strike: true, color: '#666666' } }
|
|
];
|
|
|
|
let segments = [{ text, style: { ...baseStyle } }];
|
|
|
|
patterns.forEach(({ regex, style }) => {
|
|
const nextSegments = [];
|
|
segments.forEach((segment) => {
|
|
if (!segment.text) return;
|
|
regex.lastIndex = 0;
|
|
let lastIndex = 0;
|
|
let match = regex.exec(segment.text);
|
|
let hasMatch = false;
|
|
|
|
while (match) {
|
|
hasMatch = true;
|
|
if (match.index > lastIndex) {
|
|
nextSegments.push({
|
|
text: segment.text.slice(lastIndex, match.index),
|
|
style: { ...segment.style }
|
|
});
|
|
}
|
|
nextSegments.push({
|
|
text: match[1],
|
|
style: { ...segment.style, ...style }
|
|
});
|
|
lastIndex = match.index + match[0].length;
|
|
match = regex.exec(segment.text);
|
|
}
|
|
|
|
if (!hasMatch) {
|
|
nextSegments.push(segment);
|
|
return;
|
|
}
|
|
|
|
if (lastIndex < segment.text.length) {
|
|
nextSegments.push({
|
|
text: segment.text.slice(lastIndex),
|
|
style: { ...segment.style }
|
|
});
|
|
}
|
|
});
|
|
segments = nextSegments;
|
|
});
|
|
|
|
return segments.filter((segment) => segment.text);
|
|
}
|
|
|
|
parseMarkdownText(markdownText, fontFamily, fontSize, color, defaultBold = false, defaultItalic = false) {
|
|
const normalizedText = String(markdownText || '').replace(/\r\n/g, '\n');
|
|
const lines = normalizedText.split('\n');
|
|
const blocks = [];
|
|
|
|
lines.forEach((rawLine) => {
|
|
const line = rawLine || '';
|
|
const trimmed = line.trim();
|
|
|
|
if (!trimmed) {
|
|
blocks.push({ type: 'blank', height: Math.max(12, Math.round(fontSize * 0.75)) });
|
|
return;
|
|
}
|
|
|
|
let type = 'paragraph';
|
|
let content = line;
|
|
let level = 0;
|
|
let indent = 0;
|
|
let prefix = '';
|
|
|
|
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
|
const quoteMatch = line.match(/^\s*>\s?(.*)$/);
|
|
const unorderedListMatch = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
const orderedListMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
|
|
if (headingMatch) {
|
|
type = 'heading';
|
|
level = headingMatch[1].length;
|
|
content = headingMatch[2];
|
|
} else if (quoteMatch) {
|
|
type = 'quote';
|
|
content = quoteMatch[1];
|
|
} else if (unorderedListMatch) {
|
|
type = 'list';
|
|
content = unorderedListMatch[2];
|
|
indent = Math.floor((unorderedListMatch[1] || '').length / 2);
|
|
prefix = '•';
|
|
} else if (orderedListMatch) {
|
|
type = 'ordered-list';
|
|
content = orderedListMatch[3];
|
|
indent = Math.floor((orderedListMatch[1] || '').length / 2);
|
|
prefix = `${orderedListMatch[2]}.`;
|
|
}
|
|
|
|
const scale = type === 'heading' ? Math.max(1, 1.65 - (level - 1) * 0.16) : 1;
|
|
const blockFontSize = Math.max(10, Math.round(fontSize * scale));
|
|
const baseStyle = {
|
|
color: type === 'quote' ? '#444444' : color,
|
|
fontFamily,
|
|
fontSize: blockFontSize,
|
|
bold: type === 'heading' || defaultBold,
|
|
italic: defaultItalic
|
|
};
|
|
|
|
blocks.push({
|
|
type,
|
|
level,
|
|
indent,
|
|
prefix,
|
|
fontSize: blockFontSize,
|
|
lineHeight: Math.max(14, Math.round(blockFontSize * (type === 'heading' ? 1.45 : 1.35))),
|
|
segments: this.parseInlineMarkdown(content, baseStyle)
|
|
});
|
|
});
|
|
|
|
return blocks;
|
|
}
|
|
|
|
measureMarkdownBlock(block) {
|
|
if (block.type === 'blank') {
|
|
return { width: 0, height: block.height || 12 };
|
|
}
|
|
|
|
const indentWidth = block.indent ? block.indent * 18 : 0;
|
|
let prefixWidth = 0;
|
|
if (block.prefix) {
|
|
this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false);
|
|
prefixWidth = this.ctx.measureText(block.prefix).width + 8;
|
|
}
|
|
|
|
let contentWidth = 0;
|
|
block.segments.forEach((segment) => {
|
|
this.ctx.font = this.buildFontString(
|
|
segment.style.fontFamily || 'Arial',
|
|
segment.style.fontSize || block.fontSize,
|
|
!!segment.style.bold,
|
|
!!segment.style.italic
|
|
);
|
|
contentWidth += this.ctx.measureText(segment.text).width;
|
|
});
|
|
|
|
return {
|
|
width: indentWidth + prefixWidth + contentWidth + (block.type === 'quote' ? 16 : 0),
|
|
height: block.lineHeight
|
|
};
|
|
}
|
|
|
|
measureTextElement(textElement) {
|
|
if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) {
|
|
let width = 0;
|
|
let height = 0;
|
|
|
|
textElement.blocks.forEach((block) => {
|
|
const size = this.measureMarkdownBlock(block);
|
|
width = Math.max(width, size.width);
|
|
height += size.height;
|
|
});
|
|
|
|
return {
|
|
width,
|
|
height,
|
|
top: textElement.y - Math.max(14, textElement.baseFontSize || 16),
|
|
left: textElement.x
|
|
};
|
|
}
|
|
|
|
this.ctx.font = textElement.font;
|
|
const textWidth = this.ctx.measureText(textElement.text).width;
|
|
const fontSizeMatch = textElement.font.match(/(\d+)px/);
|
|
const textHeight = (fontSizeMatch ? parseInt(fontSizeMatch[1], 10) : 14) * 1.2;
|
|
|
|
return {
|
|
width: textWidth,
|
|
height: textHeight,
|
|
top: textElement.y - textHeight,
|
|
left: textElement.x
|
|
};
|
|
}
|
|
|
|
renderTextElement(textElement) {
|
|
if (textElement.type === 'markdown' && Array.isArray(textElement.blocks)) {
|
|
let currentY = textElement.y;
|
|
|
|
textElement.blocks.forEach((block) => {
|
|
if (block.type === 'blank') {
|
|
currentY += block.height || 12;
|
|
return;
|
|
}
|
|
|
|
let currentX = textElement.x + (block.indent ? block.indent * 18 : 0);
|
|
if (block.type === 'quote') {
|
|
this.ctx.strokeStyle = '#999999';
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(textElement.x + 6, currentY - block.lineHeight + 4);
|
|
this.ctx.lineTo(textElement.x + 6, currentY + 2);
|
|
this.ctx.stroke();
|
|
currentX += 16;
|
|
}
|
|
|
|
if (block.prefix) {
|
|
this.ctx.font = this.buildFontString('Arial', block.fontSize, true, false);
|
|
this.ctx.fillStyle = textElement.color;
|
|
this.ctx.fillText(block.prefix, currentX, currentY);
|
|
currentX += this.ctx.measureText(block.prefix).width + 8;
|
|
}
|
|
|
|
block.segments.forEach((segment) => {
|
|
const fontSize = segment.style.fontSize || block.fontSize;
|
|
this.ctx.font = this.buildFontString(
|
|
segment.style.fontFamily || textElement.fontFamily || 'Arial',
|
|
fontSize,
|
|
!!segment.style.bold,
|
|
!!segment.style.italic
|
|
);
|
|
|
|
const segmentWidth = this.ctx.measureText(segment.text).width;
|
|
|
|
if (segment.style.code) {
|
|
this.ctx.fillStyle = segment.style.background || 'rgba(0, 0, 0, 0.08)';
|
|
this.ctx.fillRect(currentX - 2, currentY - fontSize, segmentWidth + 4, block.lineHeight - 2);
|
|
}
|
|
|
|
this.ctx.fillStyle = segment.style.color || textElement.color;
|
|
this.ctx.fillText(segment.text, currentX, currentY);
|
|
|
|
if (segment.style.strike) {
|
|
this.ctx.strokeStyle = segment.style.color || textElement.color;
|
|
this.ctx.lineWidth = 1;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(currentX, currentY - fontSize * 0.35);
|
|
this.ctx.lineTo(currentX + segmentWidth, currentY - fontSize * 0.35);
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
currentX += segmentWidth;
|
|
});
|
|
|
|
currentY += block.lineHeight;
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
this.ctx.font = textElement.font;
|
|
this.ctx.fillStyle = textElement.color;
|
|
this.ctx.fillText(textElement.text, textElement.x, textElement.y);
|
|
}
|
|
|
|
saveToHistory() {
|
|
// Remove any states after current step (when user drew something after undoing)
|
|
this.historyStack = this.historyStack.slice(0, this.historyStep + 1);
|
|
|
|
// Save current canvas state along with text and line data
|
|
const canvasState = {
|
|
imageData: this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height),
|
|
textElements: JSON.parse(JSON.stringify(this.textElements)),
|
|
lineSegments: JSON.parse(JSON.stringify(this.lineSegments))
|
|
};
|
|
|
|
this.historyStack.push(canvasState);
|
|
this.historyStep++;
|
|
|
|
// Limit history size
|
|
if (this.historyStack.length > this.MAX_HISTORY) {
|
|
this.historyStack.shift();
|
|
this.historyStep--;
|
|
}
|
|
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
|
|
clearHistory() {
|
|
this.historyStack = [];
|
|
this.historyStep = -1;
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
|
|
undo() {
|
|
if (this.historyStep > 0) {
|
|
this.historyStep--;
|
|
this.restoreFromHistory();
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this.historyStep < this.historyStack.length - 1) {
|
|
this.historyStep++;
|
|
this.restoreFromHistory();
|
|
}
|
|
}
|
|
|
|
restoreFromHistory() {
|
|
if (this.historyStep >= 0 && this.historyStep < this.historyStack.length) {
|
|
const state = this.historyStack[this.historyStep];
|
|
|
|
// Restore canvas image
|
|
this.ctx.putImageData(state.imageData, 0, 0);
|
|
|
|
// Restore text and line data
|
|
this.textElements = JSON.parse(JSON.stringify(state.textElements));
|
|
this.lineSegments = JSON.parse(JSON.stringify(state.lineSegments));
|
|
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
}
|
|
|
|
updateUndoRedoButtons() {
|
|
const undoBtn = document.getElementById('undo-btn');
|
|
const redoBtn = document.getElementById('redo-btn');
|
|
|
|
if (undoBtn) {
|
|
undoBtn.disabled = this.historyStep <= 0;
|
|
}
|
|
|
|
if (redoBtn) {
|
|
redoBtn.disabled = this.historyStep >= this.historyStack.length - 1;
|
|
}
|
|
}
|
|
|
|
initPaintTools() {
|
|
document.getElementById('brush-mode').addEventListener('click', () => {
|
|
if (this.currentTool === 'brush') {
|
|
this.setActiveTool(null, '');
|
|
} else {
|
|
this.setActiveTool('brush', '画笔模式');
|
|
this.brushColor = document.getElementById('brush-color').value;
|
|
}
|
|
});
|
|
|
|
document.getElementById('eraser-mode').addEventListener('click', () => {
|
|
if (this.currentTool === 'eraser') {
|
|
this.setActiveTool(null, '');
|
|
} else {
|
|
this.setActiveTool('eraser', '橡皮擦');
|
|
this.brushColor = "#FFFFFF";
|
|
}
|
|
});
|
|
|
|
document.getElementById('text-mode').addEventListener('click', () => {
|
|
if (this.currentTool === 'text') {
|
|
this.setActiveTool(null, '');
|
|
} else {
|
|
this.setActiveTool('text', '插入文字');
|
|
this.brushColor = document.getElementById('brush-color').value;
|
|
}
|
|
});
|
|
|
|
document.getElementById('brush-color').addEventListener('change', (e) => {
|
|
this.brushColor = e.target.value;
|
|
});
|
|
|
|
document.getElementById('brush-size').addEventListener('input', (e) => {
|
|
this.brushSize = parseInt(e.target.value);
|
|
this.updateBrushCursorSize();
|
|
});
|
|
|
|
document.getElementById('add-text-btn').addEventListener('click', () => this.startTextPlacement());
|
|
|
|
// Add event listeners for bold and italic buttons
|
|
document.getElementById('text-bold').addEventListener('click', () => {
|
|
this.textBold = !this.textBold;
|
|
document.getElementById('text-bold').classList.toggle('primary', this.textBold);
|
|
});
|
|
|
|
document.getElementById('text-italic').addEventListener('click', () => {
|
|
this.textItalic = !this.textItalic;
|
|
document.getElementById('text-italic').classList.toggle('primary', this.textItalic);
|
|
});
|
|
|
|
// Add undo/redo button listeners
|
|
document.getElementById('undo-btn').addEventListener('click', () => this.undo());
|
|
document.getElementById('redo-btn').addEventListener('click', () => this.redo());
|
|
|
|
this.canvas.addEventListener('mousedown', this.startPaint);
|
|
this.canvas.addEventListener('mousemove', this.paint);
|
|
this.canvas.addEventListener('mouseup', this.endPaint);
|
|
this.canvas.addEventListener('mouseleave', this.endPaint);
|
|
this.canvas.addEventListener('click', this.handleCanvasClick);
|
|
|
|
// Touch support
|
|
this.canvas.addEventListener('touchstart', this.onTouchStart);
|
|
this.canvas.addEventListener('touchmove', this.onTouchMove);
|
|
this.canvas.addEventListener('touchend', this.onTouchEnd);
|
|
|
|
// Keyboard shortcuts for undo/redo
|
|
document.addEventListener('keydown', this.handleKeyboard);
|
|
|
|
// Mouse move for brush cursor
|
|
this.canvas.addEventListener('mouseenter', this.updateBrushCursor);
|
|
this.canvas.addEventListener('mousemove', this.updateBrushCursor);
|
|
|
|
// Create brush cursor element
|
|
this.createBrushCursor();
|
|
|
|
// Initialize history with blank canvas state
|
|
this.saveToHistory();
|
|
}
|
|
|
|
handleKeyboard(e) {
|
|
// Ctrl+Z or Cmd+Z for undo
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.undo();
|
|
}
|
|
// Ctrl+Y or Ctrl+Shift+Z or Cmd+Shift+Z for redo
|
|
else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) {
|
|
e.preventDefault();
|
|
this.redo();
|
|
}
|
|
}
|
|
|
|
setActiveTool(tool, title) {
|
|
setCanvasTitle(title);
|
|
this.currentTool = tool;
|
|
|
|
this.canvas.parentNode.classList.toggle('brush-mode', this.currentTool === 'brush');
|
|
this.canvas.parentNode.classList.toggle('eraser-mode', this.currentTool === 'eraser');
|
|
this.canvas.parentNode.classList.toggle('text-mode', this.currentTool === 'text');
|
|
|
|
document.getElementById('brush-mode').classList.toggle('active', this.currentTool === 'brush');
|
|
document.getElementById('eraser-mode').classList.toggle('active', this.currentTool === 'eraser');
|
|
document.getElementById('text-mode').classList.toggle('active', this.currentTool === 'text');
|
|
|
|
document.getElementById('brush-color').disabled = this.currentTool === 'eraser';
|
|
document.getElementById('brush-size').disabled = this.currentTool === 'text';
|
|
|
|
document.getElementById('undo-btn').classList.toggle('hide', this.currentTool === null);
|
|
document.getElementById('redo-btn').classList.toggle('hide', this.currentTool === null);
|
|
|
|
// Cancel any pending text placement
|
|
this.cancelTextPlacement();
|
|
}
|
|
|
|
createBrushCursor() {
|
|
// Create a div element to show as brush cursor
|
|
this.brushCursor = document.createElement('div');
|
|
this.brushCursor.id = 'brush-cursor';
|
|
this.brushCursor.style.position = 'fixed';
|
|
this.brushCursor.style.border = '2px solid rgba(0, 0, 0, 0.5)';
|
|
this.brushCursor.style.borderRadius = '50%';
|
|
this.brushCursor.style.pointerEvents = 'none';
|
|
this.brushCursor.style.display = 'none';
|
|
this.brushCursor.style.zIndex = '10000';
|
|
this.brushCursor.style.transform = 'translate(-50%, -50%)';
|
|
this.brushCursor.style.willChange = 'transform';
|
|
this.brushCursor.style.left = '0';
|
|
this.brushCursor.style.top = '0';
|
|
document.body.appendChild(this.brushCursor);
|
|
this.updateBrushCursorSize();
|
|
|
|
// For requestAnimationFrame throttling
|
|
this.cursorUpdateScheduled = false;
|
|
this.pendingCursorX = 0;
|
|
this.pendingCursorY = 0;
|
|
}
|
|
|
|
updateBrushCursorSize() {
|
|
if (!this.brushCursor) return;
|
|
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = rect.width / this.canvas.width;
|
|
const scaleY = rect.height / this.canvas.height;
|
|
const scale = Math.min(scaleX, scaleY);
|
|
|
|
const size = this.brushSize * scale;
|
|
this.brushCursor.style.width = size + 'px';
|
|
this.brushCursor.style.height = size + 'px';
|
|
}
|
|
|
|
updateBrushCursor(e) {
|
|
if (!this.brushCursor) return;
|
|
|
|
if (this.currentTool === 'brush' || this.currentTool === 'eraser') {
|
|
// Check if mouse is within canvas bounds
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const isInCanvas = e.clientX >= rect.left &&
|
|
e.clientX <= rect.right &&
|
|
e.clientY >= rect.top &&
|
|
e.clientY <= rect.bottom;
|
|
|
|
if (isInCanvas) {
|
|
this.brushCursor.style.display = 'block';
|
|
this.canvas.style.cursor = 'none';
|
|
|
|
// Store the pending position
|
|
this.pendingCursorX = e.clientX;
|
|
this.pendingCursorY = e.clientY;
|
|
|
|
// Schedule update using requestAnimationFrame for smooth movement
|
|
if (!this.cursorUpdateScheduled) {
|
|
this.cursorUpdateScheduled = true;
|
|
requestAnimationFrame(() => {
|
|
this.brushCursor.style.transform = `translate(${this.pendingCursorX}px, ${this.pendingCursorY}px) translate(-50%, -50%)`;
|
|
this.cursorUpdateScheduled = false;
|
|
});
|
|
}
|
|
|
|
// Update color to match brush or show white for eraser (only needs to happen once or when tool changes)
|
|
if (this.currentTool === 'eraser') {
|
|
if (this.brushCursor.getAttribute('data-tool') !== 'eraser') {
|
|
this.brushCursor.style.border = '2px solid rgba(255, 0, 0, 0.7)';
|
|
this.brushCursor.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
|
|
this.brushCursor.style.boxShadow = 'none';
|
|
this.brushCursor.setAttribute('data-tool', 'eraser');
|
|
}
|
|
} else {
|
|
if (this.brushCursor.getAttribute('data-tool') !== 'brush') {
|
|
// Use a contrasting border - white with black outline for visibility
|
|
this.brushCursor.style.border = '1px solid white';
|
|
this.brushCursor.style.boxShadow = '0 0 0 1px black, inset 0 0 0 1px black';
|
|
this.brushCursor.style.backgroundColor = 'transparent';
|
|
this.brushCursor.setAttribute('data-tool', 'brush');
|
|
}
|
|
}
|
|
} else {
|
|
// Hide cursor when outside canvas
|
|
this.brushCursor.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
hideBrushCursor() {
|
|
if (this.brushCursor) {
|
|
this.brushCursor.style.display = 'none';
|
|
}
|
|
this.canvas.style.cursor = 'default';
|
|
}
|
|
|
|
startPaint(e) {
|
|
if (!this.currentTool) return;
|
|
|
|
if (this.currentTool === 'text') {
|
|
// Check if we're clicking on a text element to drag
|
|
const textElement = this.findTextElementAt(e);
|
|
if (textElement && textElement === this.selectedTextElement) {
|
|
this.isDraggingText = true;
|
|
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX;
|
|
const y = (e.clientY - rect.top) * scaleY;
|
|
|
|
// Calculate offset for smooth dragging
|
|
this.dragOffsetX = textElement.x - x;
|
|
this.dragOffsetY = textElement.y - y;
|
|
|
|
return; // Don't start drawing
|
|
}
|
|
} else {
|
|
this.painting = true;
|
|
this.draw(e);
|
|
}
|
|
}
|
|
|
|
endPaint() {
|
|
if (this.painting || this.isDraggingText) {
|
|
this.saveToHistory(); // Save state after drawing or dragging text
|
|
}
|
|
this.painting = false;
|
|
this.isDraggingText = false;
|
|
this.lastX = 0;
|
|
this.lastY = 0;
|
|
|
|
this.hideBrushCursor();
|
|
}
|
|
|
|
paint(e) {
|
|
if (!this.currentTool) return;
|
|
|
|
if (this.currentTool === 'text') {
|
|
if (this.isDraggingText && this.selectedTextElement) {
|
|
this.dragText(e);
|
|
}
|
|
} else {
|
|
if (this.painting) {
|
|
this.draw(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
draw(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX;
|
|
const y = (e.clientY - rect.top) * scaleY;
|
|
|
|
this.ctx.lineJoin = 'round';
|
|
this.ctx.lineCap = 'round';
|
|
this.ctx.strokeStyle = this.brushColor;
|
|
this.ctx.lineWidth = this.brushSize;
|
|
|
|
this.ctx.beginPath();
|
|
|
|
if (this.lastX === 0 && this.lastY === 0) {
|
|
// For the first point, just do a dot
|
|
this.ctx.moveTo(x, y);
|
|
this.ctx.lineTo(x + 0.1, y + 0.1);
|
|
|
|
// Store the dot for redrawing
|
|
this.lineSegments.push({
|
|
type: 'dot',
|
|
x: x,
|
|
y: y,
|
|
color: this.brushColor,
|
|
size: this.brushSize
|
|
});
|
|
} else {
|
|
// Connect to the previous point
|
|
this.ctx.moveTo(this.lastX, this.lastY);
|
|
this.ctx.lineTo(x, y);
|
|
|
|
// Store the line segment for redrawing
|
|
this.lineSegments.push({
|
|
type: 'line',
|
|
x1: this.lastX,
|
|
y1: this.lastY,
|
|
x2: x,
|
|
y2: y,
|
|
color: this.brushColor,
|
|
size: this.brushSize
|
|
});
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
|
|
this.lastX = x;
|
|
this.lastY = y;
|
|
}
|
|
|
|
handleCanvasClick(e) {
|
|
if (this.currentTool === 'text' && this.isTextPlacementMode) {
|
|
this.placeText(e);
|
|
}
|
|
}
|
|
|
|
onTouchStart(e) {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
|
|
// If in text placement mode, handle as a click
|
|
if (this.currentTool === 'text' && this.isTextPlacementMode) {
|
|
const mouseEvent = new MouseEvent('click', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
});
|
|
this.canvas.dispatchEvent(mouseEvent);
|
|
return;
|
|
}
|
|
|
|
// Otherwise handle as normal drawing
|
|
const mouseEvent = new MouseEvent('mousedown', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
});
|
|
this.canvas.dispatchEvent(mouseEvent);
|
|
}
|
|
|
|
onTouchMove(e) {
|
|
e.preventDefault();
|
|
const touch = e.touches[0];
|
|
const mouseEvent = new MouseEvent('mousemove', {
|
|
clientX: touch.clientX,
|
|
clientY: touch.clientY
|
|
});
|
|
this.canvas.dispatchEvent(mouseEvent);
|
|
}
|
|
|
|
onTouchEnd(e) {
|
|
e.preventDefault();
|
|
this.endPaint();
|
|
}
|
|
|
|
dragText(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX;
|
|
const y = (e.clientY - rect.top) * scaleY;
|
|
|
|
// Update text position with offset
|
|
this.selectedTextElement.x = x + this.dragOffsetX;
|
|
this.selectedTextElement.y = y + this.dragOffsetY;
|
|
|
|
// Redraw selected text element
|
|
if (this.draggingCanvasContext) {
|
|
this.ctx.putImageData(this.draggingCanvasContext, 0, 0);
|
|
} else {
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
}
|
|
this.renderTextElement(this.selectedTextElement);
|
|
}
|
|
|
|
findTextElementAt(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX;
|
|
const y = (e.clientY - rect.top) * scaleY;
|
|
|
|
// Search through text elements in reverse order (top-most first)
|
|
for (let i = this.textElements.length - 1; i >= 0; i--) {
|
|
const text = this.textElements[i];
|
|
|
|
const measurement = this.measureTextElement(text);
|
|
const textWidth = measurement.width;
|
|
const textHeight = measurement.height;
|
|
const top = measurement.top;
|
|
const left = measurement.left;
|
|
|
|
// Check if click is within text bounds (allowing for some margin)
|
|
const margin = 5;
|
|
if (x >= left - margin &&
|
|
x <= left + textWidth + margin &&
|
|
y >= top - margin &&
|
|
y <= top + textHeight + margin) {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
startTextPlacement() {
|
|
const text = document.getElementById('text-input').value.trim();
|
|
if (!text) {
|
|
alert('请输入文字内容');
|
|
return;
|
|
}
|
|
|
|
this.isTextPlacementMode = true;
|
|
|
|
// Add visual feedback
|
|
setCanvasTitle('点击画布放置文字');
|
|
this.canvas.classList.add('text-placement-mode');
|
|
}
|
|
|
|
cancelTextPlacement() {
|
|
this.isTextPlacementMode = false;
|
|
this.canvas.classList.remove('text-placement-mode');
|
|
|
|
// reset dragging state
|
|
this.isDraggingText = false;
|
|
this.dragOffsetX = 0;
|
|
this.dragOffsetY = 0;
|
|
this.selectedTextElement = null;
|
|
this.draggingCanvasContext = null;
|
|
}
|
|
|
|
placeText(e) {
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
const scaleX = this.canvas.width / rect.width;
|
|
const scaleY = this.canvas.height / rect.height;
|
|
const x = (e.clientX - rect.left) * scaleX;
|
|
const y = (e.clientY - rect.top) * scaleY;
|
|
|
|
const text = document.getElementById('text-input').value;
|
|
const fontFamily = document.getElementById('font-family').value;
|
|
const fontSize = parseInt(document.getElementById('font-size').value, 10);
|
|
const blocks = this.parseMarkdownText(text, fontFamily, fontSize, this.brushColor, this.textBold, this.textItalic);
|
|
|
|
// Create a new text element
|
|
const newText = {
|
|
type: 'markdown',
|
|
text: text,
|
|
x: x,
|
|
y: y,
|
|
font: this.buildFontString(fontFamily, fontSize, this.textBold, this.textItalic),
|
|
color: this.brushColor,
|
|
fontFamily: fontFamily,
|
|
baseFontSize: fontSize,
|
|
blocks: blocks
|
|
};
|
|
|
|
// Add to our list of text elements
|
|
this.textElements.push(newText);
|
|
|
|
// Select this text element for immediate dragging
|
|
this.selectedTextElement = newText;
|
|
this.draggingCanvasContext = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
// Draw text on canvas
|
|
this.renderTextElement(newText);
|
|
|
|
// Save to history after placing text
|
|
this.saveToHistory();
|
|
|
|
// Reset
|
|
document.getElementById('text-input').value = '';
|
|
this.isTextPlacementMode = false;
|
|
this.canvas.classList.remove('text-placement-mode');
|
|
setCanvasTitle('拖动新添加文字可调整位置');
|
|
}
|
|
|
|
redrawTextElements() {
|
|
// Redraw all text elements after dithering
|
|
this.textElements.forEach(item => {
|
|
this.renderTextElement(item);
|
|
});
|
|
}
|
|
|
|
redrawLineSegments() {
|
|
// Redraw all line segments after dithering
|
|
this.lineSegments.forEach(segment => {
|
|
this.ctx.lineJoin = 'round';
|
|
this.ctx.lineCap = 'round';
|
|
this.ctx.strokeStyle = segment.color;
|
|
this.ctx.lineWidth = segment.size;
|
|
this.ctx.beginPath();
|
|
|
|
if (segment.type === 'dot') {
|
|
this.ctx.moveTo(segment.x, segment.y);
|
|
this.ctx.lineTo(segment.x + 0.1, segment.y + 0.1);
|
|
} else {
|
|
this.ctx.moveTo(segment.x1, segment.y1);
|
|
this.ctx.lineTo(segment.x2, segment.y2);
|
|
}
|
|
|
|
this.ctx.stroke();
|
|
});
|
|
}
|
|
|
|
clearElements() {
|
|
this.textElements = [];
|
|
this.lineSegments = [];
|
|
}
|
|
}
|