Spaces:
Running
Running
Download script.js from jaxxchen/flowsketcher-pro: direct link, hf CLI and curl.
- Browser
- Download file 23 kB
-
https://huggingface.co/spaces/jaxxchen/flowsketcher-pro/resolve/main/script.js
- Command line
-
hf download hf://spaces/jaxxchen/flowsketcher-pro/script.js
-
curl -L -o script.js https://huggingface.co/spaces/jaxxchen/flowsketcher-pro/resolve/main/script.js
23 kB
| // FlowSketcher Pro - Main Application Logic | |
| class FlowSketcherApp { | |
| constructor() { | |
| this.canvas = null; | |
| this.ctx = null; | |
| this.shapes = []; | |
| this.selectedShapes = []; | |
| this.currentTool = 'select'; | |
| this.isDrawing = false; | |
| this.isDragging = false; | |
| this.isResizing = false; | |
| this.startPoint = { x: 0, y: 0 }; | |
| this.currentShape = null; | |
| this.resizeHandle = null; | |
| this.gridSize = 20; | |
| this.history = []; | |
| this.historyIndex = -1; | |
| this.colors = { | |
| stroke: '#000000', | |
| fill: '#ffffff' | |
| }; | |
| this.strokeWidth = 2; | |
| this.tools = { | |
| SELECT: 'select', | |
| RECTANGLE: 'rectangle', | |
| CIRCLE: 'circle', | |
| DIAMOND: 'diamond', | |
| ARROW: 'arrow', | |
| TEXT: 'text', | |
| FREEHAND: 'freehand' | |
| }; | |
| this.init(); | |
| } | |
| init() { | |
| this.setupEventListeners(); | |
| this.setupCanvas(); | |
| this.setupKeyboardShortcuts(); | |
| this.loadFromLocalStorage(); | |
| } | |
| setupCanvas() { | |
| // Wait for canvas-area to be fully rendered | |
| const canvasArea = document.querySelector('canvas-area'); | |
| if (canvasArea) { | |
| // Use setTimeout to ensure shadow DOM is rendered | |
| setTimeout(() => { | |
| this.canvas = canvasArea.shadowRoot.querySelector('#main-canvas'); | |
| if (this.canvas) { | |
| this.ctx = this.canvas.getContext('2d'); | |
| this.resizeCanvas(); | |
| window.addEventListener('resize', () => this.resizeCanvas()); | |
| console.log('Canvas initialized successfully'); | |
| } else { | |
| console.error('Canvas element not found in shadow DOM'); | |
| } | |
| }, 100); | |
| } else { | |
| console.error('Canvas-area component not found'); | |
| } | |
| } | |
| resizeCanvas() { | |
| const canvasArea = document.querySelector('canvas-area'); | |
| const rect = canvasArea.getBoundingClientRect(); | |
| this.canvas.width = rect.width; | |
| this.canvas.height = rect.height; | |
| this.redrawCanvas(); | |
| } | |
| setupEventListeners() { | |
| document.addEventListener('tool-changed', (e) => { | |
| this.currentTool = e.detail.tool; | |
| this.canvas.style.cursor = this.getCursorForTool(this.currentTool); | |
| }); | |
| document.addEventListener('color-changed', (e) => { | |
| this.colors[e.detail.type] = e.detail.color; | |
| if (this.selectedShapes.length > 0) { | |
| this.updateSelectedShapes({ [e.detail.type]: e.detail.color }); | |
| } | |
| }); | |
| document.addEventListener('stroke-width-changed', (e) => { | |
| this.strokeWidth = e.detail.width; | |
| if (this.selectedShapes.length > 0) { | |
| this.updateSelectedShapes({ strokeWidth: e.detail.width }); | |
| } | |
| }); | |
| document.addEventListener('clear-canvas', () => this.clearCanvas()); | |
| document.addEventListener('export-png', () => this.exportAsPNG()); | |
| document.addEventListener('undo', () => this.undo()); | |
| document.addEventListener('redo', () => this.redo()); | |
| document.addEventListener('delete-selected', () => this.deleteSelected()); | |
| // Canvas events | |
| document.addEventListener('canvas-mousedown', (e) => this.handleMouseDown(e.detail)); | |
| document.addEventListener('canvas-mousemove', (e) => this.handleMouseMove(e.detail)); | |
| document.addEventListener('canvas-mouseup', (e) => this.handleMouseUp(e.detail)); | |
| document.addEventListener('canvas-dblclick', (e) => this.handleDoubleClick(e.detail)); | |
| // Quick shape insertion | |
| document.addEventListener('insert-quick-shape', (e) => { | |
| this.insertQuickShape(e.detail); | |
| }); | |
| } | |
| setupKeyboardShortcuts() { | |
| document.addEventListener('keydown', (e) => { | |
| if (e.ctrlKey || e.metaKey) { | |
| switch(e.key) { | |
| case 'z': | |
| e.preventDefault(); | |
| if (e.shiftKey) { | |
| this.redo(); | |
| } else { | |
| this.undo(); | |
| } | |
| break; | |
| case 'y': | |
| e.preventDefault(); | |
| this.redo(); | |
| break; | |
| case 's': | |
| e.preventDefault(); | |
| this.exportAsPNG(); | |
| break; | |
| case 'a': | |
| e.preventDefault(); | |
| this.selectAll(); | |
| break; | |
| } | |
| } else if (e.key === 'Delete') { | |
| this.deleteSelected(); | |
| } else if (e.key === 'Escape') { | |
| this.deselectAll(); | |
| } | |
| }); | |
| } | |
| handleMouseDown(event) { | |
| if (!this.canvas || !this.ctx) { | |
| console.warn('Canvas not ready'); | |
| return; | |
| } | |
| const { offsetX, offsetY } = event; | |
| this.startPoint = { x: offsetX, y: offsetY }; | |
| if (this.currentTool === this.tools.SELECT) { | |
| const clickedShape = this.getShapeAtPoint(offsetX, offsetY); | |
| if (clickedShape) { | |
| if (!event.shiftKey && !this.selectedShapes.includes(clickedShape)) { | |
| this.deselectAll(); | |
| } | |
| this.selectedShapes.push(clickedShape); | |
| this.isDragging = true; | |
| this.updatePropertiesPanel(); | |
| } else { | |
| this.deselectAll(); | |
| this.isDrawing = true; | |
| } | |
| } else { | |
| this.isDrawing = true; | |
| this.startNewShape(offsetX, offsetY); | |
| } | |
| this.redrawCanvas(); | |
| } | |
| handleMouseMove(event) { | |
| const { offsetX, offsetY } = event; | |
| if (this.isDragging && this.selectedShapes.length > 0) { | |
| this.dragSelectedShapes(offsetX - this.startPoint.x, offsetY - this.startPoint.y); | |
| this.startPoint = { x: offsetX, y: offsetY }; | |
| } else if (this.isDrawing && this.currentShape) { | |
| this.updateCurrentShape(offsetX, offsetY); | |
| } else if (this.isResizing && this.resizeHandle) { | |
| this.resizeSelectedShape(offsetX, offsetY); | |
| } | |
| this.redrawCanvas(); | |
| } | |
| handleMouseUp(event) { | |
| if (this.isDrawing && this.currentShape) { | |
| if (this.currentShape.width > 5 || this.currentShape.height > 5 || | |
| (this.currentShape.points && this.currentShape.points.length > 2)) { | |
| this.shapes.push(this.currentShape); | |
| this.saveToHistory(); | |
| } | |
| this.currentShape = null; | |
| } | |
| this.isDrawing = false; | |
| this.isDragging = false; | |
| this.isResizing = false; | |
| this.resizeHandle = null; | |
| if (this.currentTool !== this.tools.SELECT) { | |
| this.currentTool = this.tools.SELECT; | |
| document.dispatchEvent(new CustomEvent('tool-changed', { detail: { tool: this.tools.SELECT } })); | |
| } | |
| } | |
| handleDoubleClick(event) { | |
| const { offsetX, offsetY } = event; | |
| const clickedShape = this.getShapeAtPoint(offsetX, offsetY); | |
| if (clickedShape && clickedShape.type === this.tools.TEXT) { | |
| this.editTextShape(clickedShape, event); | |
| } | |
| } | |
| startNewShape(x, y) { | |
| const shape = { | |
| id: 'shape_' + Date.now(), | |
| type: this.currentTool, | |
| x: x, | |
| y: y, | |
| width: 0, | |
| height: 0, | |
| strokeColor: this.colors.stroke, | |
| fillColor: this.colors.fill, | |
| strokeWidth: this.strokeWidth, | |
| roughness: 1.5, | |
| text: '', | |
| points: this.currentTool === this.tools.FREEHAND ? [{x, y}] : null | |
| }; | |
| this.currentShape = shape; | |
| } | |
| updateCurrentShape(x, y) { | |
| if (!this.currentShape) return; | |
| if (this.currentShape.type === this.tools.FREEHAND) { | |
| this.currentShape.points.push({ x, y }); | |
| } else if (this.currentShape.type === this.tools.ARROW) { | |
| this.currentShape.x2 = x; | |
| this.currentShape.y2 = y; | |
| } else { | |
| this.currentShape.width = x - this.startPoint.x; | |
| this.currentShape.height = y - this.startPoint.y; | |
| } | |
| } | |
| dragSelectedShapes(dx, dy) { | |
| this.selectedShapes.forEach(shape => { | |
| shape.x += dx; | |
| shape.y += dy; | |
| if (shape.points) { | |
| shape.points.forEach(p => { | |
| p.x += dx; | |
| p.y += dy; | |
| }); | |
| } | |
| }); | |
| } | |
| resizeSelectedShape(x, y) { | |
| if (this.selectedShapes.length !== 1) return; | |
| const shape = this.selectedShapes[0]; | |
| const handle = this.resizeHandle; | |
| if (handle.includes('e')) { | |
| shape.width = x - shape.x; | |
| } | |
| if (handle.includes('s')) { | |
| shape.height = y - shape.y; | |
| } | |
| if (handle.includes('w')) { | |
| const newWidth = shape.x + shape.width - x; | |
| if (newWidth > 20) { | |
| shape.width = newWidth; | |
| shape.x = x; | |
| } | |
| } | |
| if (handle.includes('n')) { | |
| const newHeight = shape.y + shape.height - y; | |
| if (newHeight > 20) { | |
| shape.height = newHeight; | |
| shape.y = y; | |
| } | |
| } | |
| } | |
| redrawCanvas() { | |
| if (!this.ctx) return; | |
| const { width, height } = this.canvas; | |
| this.ctx.clearRect(0, 0, width, height); | |
| // Draw shapes | |
| this.shapes.concat(this.currentShape ? [this.currentShape] : []).forEach(shape => { | |
| this.drawShape(shape); | |
| }); | |
| // Draw selection | |
| this.drawSelection(); | |
| } | |
| drawShape(shape) { | |
| if (!shape) return; | |
| this.ctx.save(); | |
| this.ctx.strokeStyle = shape.strokeColor; | |
| this.ctx.fillStyle = shape.fillColor; | |
| this.ctx.lineWidth = shape.strokeWidth; | |
| this.ctx.lineCap = 'round'; | |
| this.ctx.lineJoin = 'round'; | |
| switch(shape.type) { | |
| case this.tools.RECTANGLE: | |
| this.drawRoughRectangle(shape); | |
| break; | |
| case this.tools.CIRCLE: | |
| this.drawRoughCircle(shape); | |
| break; | |
| case this.tools.DIAMOND: | |
| this.drawRoughDiamond(shape); | |
| break; | |
| case this.tools.ARROW: | |
| this.drawArrow(shape); | |
| break; | |
| case this.tools.TEXT: | |
| this.drawText(shape); | |
| break; | |
| case this.tools.FREEHAND: | |
| this.drawFreehand(shape); | |
| break; | |
| } | |
| this.ctx.restore(); | |
| } | |
| drawRoughRectangle(shape) { | |
| const { x, y, width, height } = shape; | |
| if (width === 0 || height === 0) return; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.roughX(x), this.roughY(y)); | |
| this.ctx.lineTo(this.roughX(x + width), this.roughY(y)); | |
| this.ctx.lineTo(this.roughX(x + width), this.roughY(y + height)); | |
| this.ctx.lineTo(this.roughX(x), this.roughY(y + height)); | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| this.ctx.stroke(); | |
| } | |
| drawRoughCircle(shape) { | |
| const { x, y, width, height } = shape; | |
| const centerX = x + width / 2; | |
| const centerY = y + height / 2; | |
| const radius = Math.min(Math.abs(width), Math.abs(height)) / 2; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(this.roughX(centerX), this.roughY(centerY), radius, 0, 2 * Math.PI); | |
| this.ctx.fill(); | |
| this.ctx.stroke(); | |
| } | |
| drawRoughDiamond(shape) { | |
| const { x, y, width, height } = shape; | |
| const centerX = x + width / 2; | |
| const centerY = y + height / 2; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.roughX(centerX), this.roughY(y)); | |
| this.ctx.lineTo(this.roughX(x + width), this.roughY(centerY)); | |
| this.ctx.lineTo(this.roughX(centerX), this.roughY(y + height)); | |
| this.ctx.lineTo(this.roughX(x), this.roughY(centerY)); | |
| this.ctx.closePath(); | |
| this.ctx.fill(); | |
| this.ctx.stroke(); | |
| } | |
| drawArrow(shape) { | |
| if (!shape.x2 || !shape.y2) return; | |
| const { x, y, x2, y2 } = shape; | |
| const angle = Math.atan2(y2 - y, x2 - x); | |
| const arrowLength = 15; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.roughX(x), this.roughY(y)); | |
| this.ctx.lineTo(this.roughX(x2), this.roughY(y2)); | |
| this.ctx.stroke(); | |
| // Arrow head | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.roughX(x2), this.roughY(y2)); | |
| this.ctx.lineTo( | |
| this.roughX(x2 - arrowLength * Math.cos(angle - Math.PI / 6)), | |
| this.roughY(y2 - arrowLength * Math.sin(angle - Math.PI / 6)) | |
| ); | |
| this.ctx.moveTo(this.roughX(x2), this.roughY(y2)); | |
| this.ctx.lineTo( | |
| this.roughX(x2 - arrowLength * Math.cos(angle + Math.PI / 6)), | |
| this.roughY(y2 - arrowLength * Math.sin(angle + Math.PI / 6)) | |
| ); | |
| this.ctx.stroke(); | |
| } | |
| drawText(shape) { | |
| const { x, y, width, height, text } = shape; | |
| this.ctx.font = `${Math.abs(height) || 20}px Inter, sans-serif`; | |
| this.ctx.textAlign = 'center'; | |
| this.ctx.textBaseline = 'middle'; | |
| this.ctx.fillStyle = shape.strokeColor; | |
| this.ctx.fillText(text || '双击编辑', x + width / 2, y + (height || 20) / 2); | |
| } | |
| drawFreehand(shape) { | |
| if (!shape.points || shape.points.length < 2) return; | |
| this.ctx.beginPath(); | |
| this.ctx.moveTo(this.roughX(shape.points[0].x), this.roughY(shape.points[0].y)); | |
| for (let i = 1; i < shape.points.length; i++) { | |
| const point = shape.points[i]; | |
| this.ctx.lineTo(this.roughX(point.x), this.roughY(point.y)); | |
| } | |
| this.ctx.stroke(); | |
| } | |
| drawSelection() { | |
| this.selectedShapes.forEach(shape => { | |
| this.drawResizeHandles(shape); | |
| }); | |
| } | |
| drawResizeHandles(shape) { | |
| const handles = this.getResizeHandles(shape); | |
| handles.forEach(handle => { | |
| this.ctx.fillStyle = '#6366f1'; | |
| this.ctx.strokeStyle = 'white'; | |
| this.ctx.lineWidth = 2; | |
| this.ctx.beginPath(); | |
| this.ctx.arc(handle.x, handle.y, 5, 0, 2 * Math.PI); | |
| this.ctx.fill(); | |
| this.ctx.stroke(); | |
| }); | |
| } | |
| getResizeHandles(shape) { | |
| const handles = []; | |
| const { x, y, width, height } = shape; | |
| if (shape.type !== this.tools.FREEHAND) { | |
| handles.push({ x: x, y: y, position: 'nw' }); | |
| handles.push({ x: x + width, y: y, position: 'ne' }); | |
| handles.push({ x: x, y: y + height, position: 'sw' }); | |
| handles.push({ x: x + width, y: y + height, position: 'se' }); | |
| } | |
| return handles; | |
| } | |
| roughX(x) { | |
| return x + (Math.random() - 0.5) * this.getRoughness(); | |
| } | |
| roughY(y) { | |
| return y + (Math.random() - 0.5) * this.getRoughness(); | |
| } | |
| getRoughness() { | |
| return 1.5; | |
| } | |
| getShapeAtPoint(x, y) { | |
| for (let i = this.shapes.length - 1; i >= 0; i--) { | |
| const shape = this.shapes[i]; | |
| if (this.isPointInShape(x, y, shape)) { | |
| return shape; | |
| } | |
| } | |
| return null; | |
| } | |
| isPointInShape(x, y, shape) { | |
| if (shape.type === this.tools.FREEHAND) { | |
| return this.isPointNearFreehand(x, y, shape); | |
| } | |
| return x >= shape.x && x <= shape.x + shape.width && | |
| y >= shape.y && y <= shape.y + shape.height; | |
| } | |
| isPointNearFreehand(x, y, shape) { | |
| if (!shape.points) return false; | |
| for (const point of shape.points) { | |
| const distance = Math.sqrt((x - point.x) ** 2 + (y - point.y) ** 2); | |
| if (distance < 10) return true; | |
| } | |
| return false; | |
| } | |
| updateSelectedShapes(updates) { | |
| this.selectedShapes.forEach(shape => { | |
| Object.assign(shape, updates); | |
| }); | |
| this.redrawCanvas(); | |
| } | |
| deselectAll() { | |
| this.selectedShapes = []; | |
| this.updatePropertiesPanel(); | |
| this.redrawCanvas(); | |
| } | |
| selectAll() { | |
| this.selectedShapes = [...this.shapes]; | |
| this.redrawCanvas(); | |
| } | |
| insertQuickShape(config) { | |
| const canvasRect = this.canvas.getBoundingClientRect(); | |
| const shape = { | |
| id: 'shape_' + Date.now(), | |
| type: config.type, | |
| x: canvasRect.width / 2 - config.width / 2, | |
| y: canvasRect.height / 2 - config.height / 2, | |
| width: config.width, | |
| height: config.height, | |
| strokeColor: this.colors.stroke, | |
| fillColor: this.colors.fill, | |
| strokeWidth: this.strokeWidth, | |
| roughness: 1.5, | |
| text: '', | |
| points: null | |
| }; | |
| this.shapes.push(shape); | |
| this.saveToHistory(); | |
| this.redrawCanvas(); | |
| } | |
| deleteSelected() { | |
| this.shapes = this.shapes.filter(shape => !this.selectedShapes.includes(shape)); | |
| this.selectedShapes = []; | |
| this.saveToHistory(); | |
| this.redrawCanvas(); | |
| } | |
| editTextShape(shape, event) { | |
| const input = document.createElement('textarea'); | |
| input.className = 'shape-text-input'; | |
| input.value = shape.text; | |
| input.style.left = `${shape.x}px`; | |
| input.style.top = `${shape.y}px`; | |
| input.style.width = `${Math.max(shape.width, 100)}px`; | |
| input.style.height = `${Math.max(shape.height, 30)}px`; | |
| input.style.color = shape.strokeColor; | |
| const canvasArea = document.querySelector('canvas-area'); | |
| canvasArea.shadowRoot.appendChild(input); | |
| input.focus(); | |
| input.select(); | |
| const finishEdit = () => { | |
| shape.text = input.value; | |
| canvasArea.shadowRoot.removeChild(input); | |
| this.saveToHistory(); | |
| this.redrawCanvas(); | |
| }; | |
| input.addEventListener('blur', finishEdit); | |
| input.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| finishEdit(); | |
| } | |
| }); | |
| } | |
| updatePropertiesPanel() { | |
| const panel = document.querySelector('properties-panel'); | |
| if (panel) { | |
| panel.updateSelection(this.selectedShapes); | |
| } | |
| } | |
| saveToHistory() { | |
| this.history = this.history.slice(0, this.historyIndex + 1); | |
| this.history.push(JSON.stringify(this.shapes)); | |
| this.historyIndex++; | |
| if (this.history.length > 50) { | |
| this.history.shift(); | |
| this.historyIndex--; | |
| } | |
| this.saveToLocalStorage(); | |
| } | |
| undo() { | |
| if (this.historyIndex > 0) { | |
| this.historyIndex--; | |
| this.shapes = JSON.parse(this.history[this.historyIndex]); | |
| this.selectedShapes = []; | |
| this.redrawCanvas(); | |
| } | |
| } | |
| redo() { | |
| if (this.historyIndex < this.history.length - 1) { | |
| this.historyIndex++; | |
| this.shapes = JSON.parse(this.history[this.historyIndex]); | |
| this.selectedShapes = []; | |
| this.redrawCanvas(); | |
| } | |
| } | |
| clearCanvas() { | |
| if (confirm('Are you sure you want to clear the canvas? This action cannot be undone.')) { | |
| this.shapes = []; | |
| this.selectedShapes = []; | |
| this.history = []; | |
| this.historyIndex = -1; | |
| this.saveToHistory(); | |
| this.redrawCanvas(); | |
| } | |
| } | |
| exportAsPNG() { | |
| const tempCanvas = document.createElement('canvas'); | |
| tempCanvas.width = this.canvas.width; | |
| tempCanvas.height = this.canvas.height; | |
| const tempCtx = tempCanvas.getContext('2d'); | |
| // White background | |
| tempCtx.fillStyle = 'white'; | |
| tempCtx.fillRect(0, 0, tempCanvas.width, tempCanvas.height); | |
| // Draw all shapes | |
| this.shapes.forEach(shape => { | |
| this.drawShapeOnContext(shape, tempCtx); | |
| }); | |
| // Download | |
| const link = document.createElement('a'); | |
| link.download = `flowchart-${Date.now()}.png`; | |
| link.href = tempCanvas.toDataURL(); | |
| link.click(); | |
| // Show success notification | |
| document.dispatchEvent(new CustomEvent('show-notification', { | |
| detail: { message: 'Flowchart exported successfully!' } | |
| })); | |
| } | |
| drawShapeOnContext(shape, ctx) { | |
| const originalCtx = this.ctx; | |
| this.ctx = ctx; | |
| this.drawShape(shape); | |
| this.ctx = originalCtx; | |
| } | |
| saveToLocalStorage() { | |
| try { | |
| localStorage.setItem('flowsketcher-autosave', JSON.stringify({ | |
| shapes: this.shapes, | |
| timestamp: Date.now() | |
| })); | |
| } catch (e) { | |
| console.warn('Failed to save to localStorage:', e); | |
| } | |
| } | |
| loadFromLocalStorage() { | |
| try { | |
| const saved = localStorage.getItem('flowsketcher-autosave'); | |
| if (saved) { | |
| const data = JSON.parse(saved); | |
| this.shapes = data.shapes || []; | |
| this.saveToHistory(); | |
| this.redrawCanvas(); | |
| } | |
| } catch (e) { | |
| console.warn('Failed to load from localStorage:', e); | |
| } | |
| } | |
| getCursorForTool(tool) { | |
| const cursors = { | |
| [this.tools.SELECT]: 'default', | |
| [this.tools.RECTANGLE]: 'crosshair', | |
| [this.tools.CIRCLE]: 'crosshair', | |
| [this.tools.DIAMOND]: 'crosshair', | |
| [this.tools.ARROW]: 'crosshair', | |
| [this.tools.TEXT]: 'text', | |
| [this.tools.FREEHAND]: 'crosshair' | |
| }; | |
| return cursors[tool] || 'default'; | |
| } | |
| } | |
| // Initialize app when DOM is ready | |
| document.addEventListener('DOMContentLoaded', () => { | |
| window.FlowSketcher = new FlowSketcherApp(); | |
| }); |