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 = []; } }