diff --git a/.github/workflows/build-flatpak.yml b/.github/workflows/build-flatpak.yml index 104f9b0..d60eb97 100644 --- a/.github/workflows/build-flatpak.yml +++ b/.github/workflows/build-flatpak.yml @@ -1,11 +1,10 @@ name: Build Flatpak on: - push: - branches: [ deploy ] - pull_request: - branches: [ deploy ] workflow_dispatch: + inputs: + disabled: + description: 'This workflow is disabled' permissions: actions: write @@ -53,8 +52,11 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install briefcase + python -m pip install "briefcase>=0.4.1" + - name: Copy PNG icon for Flatpak + run: cp app/static/logo.png standalone/src/castle/resources/logo.png + - name: Create Flatpak package working-directory: ./standalone run: | diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 7c4ac8f..d40b2e3 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -1,11 +1,10 @@ name: Build Linux App on: - push: - branches: [ deploy ] - pull_request: - branches: [ deploy ] workflow_dispatch: + inputs: + disabled: + description: 'This workflow is disabled' permissions: contents: write @@ -37,7 +36,10 @@ jobs: desktop-file-utils - name: Install Briefcase - run: python3 -m pip install --break-system-packages briefcase + run: python3 -m pip install --break-system-packages "briefcase>=0.4.1" + + - name: Copy PNG icon for Flatpak + run: cp app/static/logo.png standalone/src/castle/resources/logo.png - name: Build DEB package working-directory: ./standalone diff --git a/app/app.py b/app/app.py index 92b5116..3a257b0 100644 --- a/app/app.py +++ b/app/app.py @@ -1,18 +1,18 @@ -import os import logging +import os import traceback from time import strftime from dotenv import load_dotenv -from flask import (Flask, make_response, render_template, - send_from_directory, request, flash, redirect, url_for) +from flask import (Flask, make_response, redirect, render_template, request, + send_from_directory, url_for) +from flask_cors import CORS from flask_login import LoginManager, current_user from flask_pymongo import PyMongo from flask_wtf.csrf import CSRFProtect -from flask_cors import CORS from app.auth.auth_utils import UserManager -from app.utils import limiter, get_mongodb_instance, close_mongodb_connection +from app.utils import close_mongodb_connection, get_mongodb_instance, limiter csrf = CSRFProtect() mongo = PyMongo() @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) def create_app(): - app = Flask(__name__, static_folder="static", template_folder="templates") + app: Flask = Flask(__name__, static_folder="static", template_folder="templates") # Load config load_dotenv() @@ -104,9 +104,9 @@ def load_user(user_id): # Import blueprints inside create_app to avoid circular imports from app.auth.routes import auth_bp + from app.notifications.routes import notifications_bp from app.scout.routes import scouting_bp from app.team.routes import team_bp - from app.notifications.routes import notifications_bp app.register_blueprint(auth_bp, url_prefix="/auth") app.register_blueprint(scouting_bp, url_prefix="/") diff --git a/app/auth/auth_utils.py b/app/auth/auth_utils.py index 19030c3..318c4b6 100644 --- a/app/auth/auth_utils.py +++ b/app/auth/auth_utils.py @@ -2,12 +2,15 @@ import logging from datetime import datetime, timezone +from typing import Optional +from bson import ObjectId from flask_login import current_user from werkzeug.security import generate_password_hash from app.models import User -from app.utils import DatabaseManager, allowed_file, with_mongodb_retry, get_database_connection, get_gridfs +from app.utils import (DatabaseManager, allowed_file, get_gridfs, + with_mongodb_retry) logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -27,7 +30,7 @@ def __init__(self, mongo_uri=None): super().__init__(mongo_uri) self._ensure_collections() - def _ensure_collections(self): + def _ensure_collections(self) -> None: """Ensure required collections exist""" if "users" not in self.db.list_collection_names(): self.db.create_collection("users") @@ -40,7 +43,7 @@ async def create_user( username, password, team_number=None - ): + ) -> tuple[bool, str]: """Create a new user with retry mechanism""" try: @@ -78,7 +81,7 @@ async def create_user( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def authenticate_user(self, login, password): + async def authenticate_user(self, login: str, password: str) -> tuple[bool, User | None]: """Authenticate user with retry mechanism""" try: @@ -101,7 +104,7 @@ async def authenticate_user(self, login, password): return False, None @with_mongodb_retry(retries=3, delay=2) - def get_user_by_id(self, user_id): + def get_user_by_id(self, user_id: str) -> User | None: """Retrieve user by ID with retry mechanism""" try: @@ -114,7 +117,7 @@ def get_user_by_id(self, user_id): return None @with_mongodb_retry(retries=3, delay=2) - async def update_user_profile(self, user_id, updates): + async def update_user_profile(self, user_id: str, updates: dict) -> tuple[bool, str]: """Update user profile information""" try: @@ -146,7 +149,7 @@ async def update_user_profile(self, user_id, updates): logger.error(f"Error updating profile: {str(e)}") return False, "An internal error has occurred." - def get_user_profile(self, username): + def get_user_profile(self, username: str) -> User | None: """Get user profile by username""" try: @@ -157,7 +160,7 @@ def get_user_profile(self, username): return None @with_mongodb_retry(retries=3, delay=2) - async def update_profile_picture(self, user_id, file_id): + async def update_profile_picture(self, user_id: str, file_id: str) -> tuple[bool, str]: """Update user's profile picture and clean up old one""" try: @@ -188,7 +191,7 @@ async def update_profile_picture(self, user_id, file_id): logger.error(f"Error updating profile picture: {str(e)}") return False, "An internal error has occurred." - def get_profile_picture(self, user_id): + def get_profile_picture(self, user_id: str) -> str | None: """Get user's profile picture ID""" try: @@ -200,7 +203,7 @@ def get_profile_picture(self, user_id): return None @with_mongodb_retry(retries=3, delay=2) - async def delete_user(self, user_id): + async def delete_user(self, user_id: str) -> tuple[bool, str]: """Delete a user account and all associated data""" try: @@ -230,7 +233,68 @@ async def delete_user(self, user_id): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def update_user_settings(self, user_id, form_data, profile_picture=None): + async def change_password(self, user_id: str, current_password: str, new_password: str) -> tuple[bool, str]: + """Change user password after verifying current password""" + + try: + # Get user data + user_data = self.db.users.find_one({"_id": ObjectId(user_id)}) + if not user_data: + return False, "User not found" + + # Check for account lockout due to too many failed attempts + failed_attempts = user_data.get('failed_password_change_attempts', 0) + last_failed = user_data.get('last_failed_password_change') + + if failed_attempts >= 5 and last_failed: + # Lock for 15 minutes after 5 failed attempts + time_since = (datetime.now(timezone.utc) - last_failed).total_seconds() + if time_since < 900: # 15 minutes + mins_left = int((900 - time_since) / 60) + 1 + return False, f"Too many failed attempts. Try again in {mins_left} minutes" + + # Create user object to verify current password + user = User.create_from_db(user_data) + if not user.check_password(current_password): + # Track failed attempt + self.db.users.update_one( + {"_id": ObjectId(user_id)}, + { + "$set": {"last_failed_password_change": datetime.now(timezone.utc)}, + "$inc": {"failed_password_change_attempts": 1} + } + ) + return False, "Current password is incorrect" + + # Check new password strength + password_valid, message = await check_password_strength(new_password) + if not password_valid: + return False, message + + # Update password and reset failed attempts + result = self.db.users.update_one( + {"_id": ObjectId(user_id)}, + { + "$set": { + "password_hash": generate_password_hash(new_password), + "password_changed_at": datetime.now(timezone.utc), + "failed_password_change_attempts": 0 + }, + "$unset": {"last_failed_password_change": ""} + } + ) + + if result.modified_count > 0: + logger.info(f"Password changed for user: {user.username}") + return True, "Password changed successfully" + return False, "Failed to update password" + + except Exception as e: + logger.error(f"Error changing password: {str(e)}") + return False, "An internal error has occurred." + + @with_mongodb_retry(retries=3, delay=2) + async def update_user_settings(self, user_id: str, form_data: dict, profile_picture=None) -> tuple[bool, Optional[str]]: """Update user settings including profile picture""" try: @@ -241,7 +305,7 @@ async def update_user_settings(self, user_id, form_data, profile_picture=None): if new_username != current_user.username: # Check if username is taken if self.db.users.find_one({"username": new_username}): - return False + return False, "Username is already taken" updates['username'] = new_username # Handle description update @@ -262,7 +326,7 @@ async def update_user_settings(self, user_id, form_data, profile_picture=None): if updates: success, message = await self.update_user_profile(user_id, updates) - return success + return success, message return True, "Profile updated successfully" except Exception as e: diff --git a/app/auth/routes.py b/app/auth/routes.py index 21be903..b1888da 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -13,8 +13,8 @@ from werkzeug.utils import secure_filename from app.auth.auth_utils import UserManager -from app.utils import (async_route, handle_route_errors, is_safe_url, limiter, - send_gridfs_file, get_gridfs) +from app.utils import (async_route, get_gridfs, handle_route_errors, + is_safe_url, limiter, send_gridfs_file) ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} @@ -249,7 +249,6 @@ def profile_picture(user_id): return send_gridfs_file( user.profile_picture_id, - user_manager.db, "static/images/default_profile.png" ) @@ -282,6 +281,54 @@ async def check_username(): }), 500 +@auth_bp.route("/change_password", methods=["POST"]) +@limiter.limit("5 per minute") +@login_required +@async_route +async def change_password(): + """Change user password""" + try: + if request.headers.get('X-Requested-With') != 'XMLHttpRequest': + return jsonify({"success": False, "message": "Invalid request"}), 403 + + data = request.get_json() + current_password = data.get('current_password', '').strip() + new_password = data.get('new_password', '').strip() + confirm_password = data.get('confirm_password', '').strip() + + # Validate input + if not all([current_password, new_password, confirm_password]): + return jsonify({"success": False, "message": "All fields are required"}), 400 + + if new_password != confirm_password: + return jsonify({"success": False, "message": "New passwords do not match"}), 400 + + # Additional security: Prevent reusing current password + if current_password == new_password: + return jsonify({"success": False, "message": "New password must be different from current password"}), 400 + + # Change password + success, message = await user_manager.change_password( + current_user.get_id(), + current_password, + new_password + ) + + if success: + current_app.logger.info(f"Password changed for user {current_user.username}") + # Log out user to invalidate session + logout_user() + return jsonify({"success": True, "message": "Password changed successfully. Please log in again.", "redirect": url_for("auth.login")}) + else: + # Don't reveal whether username exists or password was wrong (timing attack mitigation) + current_app.logger.warning(f"Failed password change attempt for user {current_user.username}") + return jsonify({"success": False, "message": message}), 400 + + except Exception as e: + current_app.logger.error(f"Error changing password for user {current_user.username}: {str(e)}", exc_info=True) + return jsonify({"success": False, "message": "An internal error has occurred."}), 500 + + @auth_bp.route("/delete_account", methods=["POST"]) @login_required @async_route diff --git a/app/models.py b/app/models.py index 2ab7486..db2ed4d 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Dict +from typing import Dict, List, Optional from bson import ObjectId from flask_login import UserMixin @@ -8,33 +8,33 @@ class User(UserMixin): def __init__(self, data): - self._id = data.get('_id') - self.username = data.get('username') - self.email = data.get("email") - self.teamNumber = data.get("teamNumber") - self.password_hash = data.get("password_hash") - self.last_login = data.get("last_login") - self.created_at = data.get("created_at") - self.description = data.get("description", "") - self.profile_picture_id = data.get("profile_picture_id") + self._id: ObjectId = data.get('_id') + self.username: str = data.get('username') + self.email: str = data.get("email") + self.teamNumber: int = data.get("teamNumber") + self.password_hash: str = data.get("password_hash") + self.last_login: str = data.get("last_login") + self.created_at: str = data.get("created_at") + self.description: str = data.get("description", "") + self.profile_picture_id: str = data.get("profile_picture_id") @property def id(self): return str(self._id) - def get_id(self): + def get_id(self) -> str: return str(self._id) - def is_authenticated(self): + def is_authenticated(self) -> bool: return True - def is_active(self): + def is_active(self) -> bool: return True - def is_anonymous(self): + def is_anonymous(self) -> bool: return False - def check_password(self, password): + def check_password(self, password: str) -> bool: return check_password_hash(self.password_hash, password) @staticmethod @@ -47,7 +47,7 @@ def create_from_db(user_data): user_data["_id"] = ObjectId(user_data["_id"]) return User(user_data) - def to_dict(self): + def to_dict(self) -> Dict: return { "_id": self._id, "email": self.email, @@ -60,7 +60,7 @@ def to_dict(self): "profile_picture_id": str(self.profile_picture_id) if self.profile_picture_id else None, } - def update_team_number(self, team_number): + def update_team_number(self, team_number: int) -> "User": """Update the user's team number""" self.teamNumber = team_number return self @@ -68,87 +68,54 @@ def update_team_number(self, team_number): class TeamData: def __init__(self, data): - self.id = str(data.get('_id')) - self.team_number = data.get('team_number') - self.match_number = data.get('match_number') - self.event_code = data.get('event_code') - self.alliance = data.get('alliance', '') + self.id: str = str(data.get('_id')) + self.team_number: int = data.get('team_number') + self.match_number: int = data.get('match_number') + self.event_code: str = data.get('event_code') + self.alliance: str = data.get('alliance', '') # 2026 Game Scoring - Fuel - self.auto_fuel = data.get('auto_fuel', 0) - self.transition_fuel = data.get('transition_fuel', 0) - self.teleop_shift_1_fuel = data.get('teleop_shift_1_fuel', 0) - self.teleop_shift_2_fuel = data.get('teleop_shift_2_fuel', 0) - self.teleop_shift_3_fuel = data.get('teleop_shift_3_fuel', 0) - self.teleop_shift_4_fuel = data.get('teleop_shift_4_fuel', 0) - self.endgame_fuel = data.get('endgame_fuel', 0) - self.ferried_fuel = data.get('ferried_fuel', 0) + self.auto_fuel: int = data.get('auto_fuel', 0) + self.transition_fuel: int = data.get('transition_fuel', 0) + self.teleop_shift_1_fuel: int = data.get('teleop_shift_1_fuel', 0) + self.teleop_shift_2_fuel: int = data.get('teleop_shift_2_fuel', 0) + self.teleop_shift_3_fuel: int = data.get('teleop_shift_3_fuel', 0) + self.teleop_shift_4_fuel: int = data.get('teleop_shift_4_fuel', 0) + self.endgame_fuel: int = data.get('endgame_fuel', 0) + self.ferried_fuel: int = data.get('ferried_fuel', 0) # Climb - self.auto_climb = data.get('auto_climb', False) - self.climb_level = data.get('climb_level', 0) # 0=None, 1-3 - self.climb_success = data.get('climb_success', False) + self.auto_climb: bool = data.get('auto_climb', False) + self.climb_level: int = data.get('climb_level', 0) # 0=None, 1-3 + self.climb_success: bool = data.get('climb_success', False) # Defense - self.defense_rating = data.get('defense_rating', 1) # 1-5 scale - self.defense_notes = data.get('defense_notes', '') + self.defense_rating: int = data.get('defense_rating', 1) # 1-5 scale + self.defense_notes: str = data.get('defense_notes', '') # Auto - self.auto_path = data.get('auto_path', '') # Store coordinates of drawn path - self.auto_notes = data.get('auto_notes', '') + self.auto_path: str = data.get('auto_path', '') # Store coordinates of drawn path + self.auto_notes: str = data.get('auto_notes', '') # Notes - self.notes = data.get('notes', '') + self.notes: str = data.get('notes', '') # Scouter information - self.scouter_id = data.get('scouter_id') - self.scouter_name = data.get('scouter_name') - self.scouter_team = data.get('scouter_team') - self.is_owner = data.get('is_owner', True) + self.scouter_id: ObjectId = data.get('scouter_id') + self.scouter_name: str = data.get('scouter_name') + self.scouter_team: str = data.get('scouter_team') + self.is_owner: bool = data.get('is_owner', True) # Robot Disabled Status - self.robot_disabled = data.get('robot_disabled', 'None') # 'None', 'Partially', 'Full' + self.robot_disabled: str = data.get('robot_disabled', 'None') # 'None', 'Partially', 'Full' @classmethod - def create_from_db(cls, data): + def create_from_db(cls, data) -> "TeamData": return cls(data) - def to_dict(self): - return { - 'id': self.id, - 'team_number': self.team_number, - 'match_number': self.match_number, - 'event_code': self.event_code, - 'alliance': self.alliance, - - # 2026 Game Scoring - 'auto_fuel': self.auto_fuel, - 'transition_fuel': self.transition_fuel, - 'teleop_shift_1_fuel': self.teleop_shift_1_fuel, - 'teleop_shift_2_fuel': self.teleop_shift_2_fuel, - 'teleop_shift_3_fuel': self.teleop_shift_3_fuel, - 'teleop_shift_4_fuel': self.teleop_shift_4_fuel, - 'endgame_fuel': self.endgame_fuel, - 'ferried_fuel': self.ferried_fuel, - 'auto_climb': self.auto_climb, - 'climb_level': self.climb_level, - - 'climb_success': self.climb_success, - 'defense_rating': self.defense_rating, - 'defense_notes': self.defense_notes, - 'auto_path': self.auto_path, - 'auto_notes': self.auto_notes, - 'robot_disabled': self.robot_disabled, - 'notes': self.notes, - 'scouter_id': self.scouter_id, - 'scouter_name': self.scouter_name, - 'scouter_team': self.scouter_team, - 'is_owner': self.is_owner, - } - @property - def formatted_date(self): + def formatted_date(self) -> str: """Returns formatted creation date""" if self.created_at: return self.created_at.strftime("%Y-%m-%d %H:%M:%S") @@ -157,39 +124,39 @@ def formatted_date(self): class PitScouting: - def __init__(self, data: Dict): + def __init__(self, data: Dict) -> None: self._id = data.get("_id") self.team_number = data.get("team_number") self.scouter_id = data.get("scouter_id") # Drive base information - self.drive_type = data.get("drive_type", { + self.drive_type: Dict[str, bool or str] = data.get("drive_type", { "swerve": False, "tank": False, "other": "" }) - self.swerve_modules = data.get("swerve_modules", "") + self.swerve_modules: str = data.get("swerve_modules", "") # Motor details - self.motor_details = data.get("motor_details", { + self.motor_details: Dict[str, bool or str] = data.get("motor_details", { "falcons": False, "neos": False, "krakens": False, "vortex": False, "other": "" }) - self.motor_count = data.get("motor_count", 0) + self.motor_count: int = data.get("motor_count", 0) # Dimensions (in) - self.dimensions = data.get("dimensions", { + self.dimensions: Dict[str, int] = data.get("dimensions", { "length": 0, "width": 0, "height": 0, }) # Programming and Autonomous - self.programming_language = data.get("programming_language", "") - self.autonomous_capabilities = data.get("autonomous_capabilities", { + self.programming_language: str = data.get("programming_language", "") + self.autonomous_capabilities: Dict = data.get("autonomous_capabilities", { "has_auto": False, "num_routes": 0, "preferred_start": "", @@ -197,17 +164,17 @@ def __init__(self, data: Dict): }) # Driver Experience - self.driver_experience = data.get("driver_experience", { + self.driver_experience: Dict = data.get("driver_experience", { "years": 0, "notes": "" }) # Analysis - self.notes = data.get("notes", "") + self.notes: str = data.get("notes", "") # Metadata - self.created_at = data.get("created_at") - self.updated_at = data.get("updated_at") + self.created_at: Optional[datetime] = data.get("created_at") + self.updated_at: Optional[datetime] = data.get("updated_at") @staticmethod def create_from_db(data: Dict): @@ -218,56 +185,18 @@ def create_from_db(data: Dict): data["_id"] = ObjectId(data["_id"]) return PitScouting(data) - def to_dict(self): - """Convert the object to a dictionary for database storage""" - return { - "id": self.id, - "team_number": self.team_number, - "scouter_id": self.scouter_id, - "scouter_name": self.scouter_name, - "drive_type": self.drive_type, - "swerve_modules": self.swerve_modules, - "drive_motors": self.drive_motors, - "motor_details": self.motor_details, - "dimensions": self.dimensions, - "mechanisms": self.mechanisms, - "programming_language": self.programming_language, - "autonomous_capabilities": self.autonomous_capabilities, - "driver_experience": self.driver_experience, - "pictures": self.pictures, - "notes": self.notes, - "strengths": self.strengths, - "weaknesses": self.weaknesses, - "created_at": self.created_at, - "updated_at": self.updated_at - } - class Team: - def __init__(self, data: Dict): - self._id = data.get("_id") - self.team_number = data.get("team_number") - self.team_join_code = data.get("team_join_code") - self.users = data.get("users", []) # List of User IDs - self.admins = data.get("admins", []) # List of admin User IDs - self.owner_id = data.get("owner_id") # Single owner ID - self.created_at = data.get("created_at") - self.team_name = data.get("team_name") - self.description = data.get("description", "") - self.logo_id = data.get("logo_id") # This should be kept as ObjectId - - def to_dict(self): - return { - "id": self.id, - "team_number": self.team_number, - "team_join_code": self.team_join_code, - "users": self.users, - "admins": self.admins, - "owner_id": str(self.owner_id) if self.owner_id else None, - "created_at": self.created_at, - "team_name": self.team_name, - "description": self.description, - "logo_id": str(self.logo_id) if self.logo_id else None, - } + def __init__(self, data: Dict) -> None: + self._id: Optional[ObjectId] = data.get("_id") + self.team_number: Optional[int] = data.get("team_number") + self.team_join_code: Optional[str] = data.get("team_join_code") + self.users: List[str] = data.get("users", []) # List of User IDs + self.admins: List[str] = data.get("admins", []) # List of admin User IDs + self.owner_id: Optional[str] = data.get("owner_id") # Single owner ID + self.created_at: Optional[datetime] = data.get("created_at") + self.team_name: Optional[str] = data.get("team_name") + self.description: str = data.get("description", "") + self.logo_id: Optional[ObjectId] = data.get("logo_id") # This should be kept as ObjectId def is_admin(self, user_id: str) -> bool: """Check if a user is an admin or owner of the team""" @@ -278,8 +207,8 @@ def is_owner(self, user_id: str) -> bool: return str(self.owner_id) == user_id @property - def id(self): - return str(self._id) + def id(self) -> Optional[str]: + return str(self._id) if self._id else None @staticmethod def create_from_db(data: Dict): @@ -292,28 +221,31 @@ def create_from_db(data: Dict): data["logo_id"] = ObjectId(data["logo_id"]) return Team(data) - def add_user(self, user: UserMixin): + def add_user(self, user: UserMixin) -> None: # Assuming user is an instance of User (or any UserMixin subclass) if isinstance(user, UserMixin): self.users.append(user.get_id()) # Store the User ID else: raise ValueError("Expected a UserMixin instance") - def remove_user(self, user: UserMixin): + def remove_user(self, user: UserMixin) -> None: if isinstance(user, UserMixin): self.users = [uid for uid in self.users if uid != user.get_id()] else: raise ValueError("Expected a UserMixin instance") class Assignment: - def __init__(self, id, title, description, team_number, creator_id, assigned_to, due_date=None, status='pending', created_at=None): - self.id = str(id) - self.title = title - self.description = description - self.team_number = team_number - self.creator_id = creator_id - self.assigned_to = assigned_to - self.status = status + def __init__(self, id: str, title: str, description: str, team_number: int, + creator_id: str, assigned_to: List[str], due_date: Optional[datetime] = None, + status: str = 'pending', created_at: Optional[datetime] = None + ) -> None: + self.id: str = str(id) + self.title: str = title + self.description: str = description + self.team_number: int = team_number + self.creator_id: str = creator_id + self.assigned_to: List[str] = assigned_to + self.status: str = status # Convert string to datetime if needed if isinstance(due_date, str): try: @@ -346,22 +278,9 @@ def create_from_db(cls, data): created_at=data.get('created_at') ) - def to_dict(self): - return { - "id": self.id, - "team_number": self.team_number, - "title": self.title, - "description": self.description, - "assigned_to": self.assigned_to, - "status": self.status, - "due_date": self.due_date, - "created_by": str(self.created_by) if self.created_by else None, - "created_at": self.created_at, - "completed_at": self.completed_at, - } class AssignmentSubscription: - def __init__(self, data: Dict): + def __init__(self, data: Dict) -> None: self._id = data.get("_id") self.user_id = data.get("user_id") self.team_number = data.get("team_number") @@ -403,28 +322,7 @@ def create_from_db(data: Dict): data["_id"] = ObjectId(data["_id"]) return AssignmentSubscription(data) - def to_dict(self): - """Convert the object to a dictionary for database storage""" - return { - "user_id": self.user_id, - "team_number": self.team_number, - "subscription_json": self.subscription_json, - "assignment_id": self.assignment_id, - "reminder_time": self.reminder_time, - "scheduled_time": self.scheduled_time, - "sent": self.sent, - "sent_at": self.sent_at, - "status": self.status, - "error": self.error, - "title": self.title, - "body": self.body, - "url": self.url, - "data": self.data, - "created_at": self.created_at, - "updated_at": datetime.now() - } - - def mark_as_sent(self): + def mark_as_sent(self) -> None: """Mark the notification as sent""" self.sent = True self.sent_at = datetime.now() diff --git a/app/notifications/notification_manager.py b/app/notifications/notification_manager.py index 7429bfe..723ae8e 100644 --- a/app/notifications/notification_manager.py +++ b/app/notifications/notification_manager.py @@ -1,13 +1,14 @@ +import json import logging import threading from datetime import datetime, timedelta -import json -from bson import ObjectId from typing import Dict, Optional, Tuple +from bson import ObjectId +from pywebpush import WebPushException, webpush + from app.models import AssignmentSubscription -from app.utils import DatabaseManager, with_mongodb_retry, get_database_connection -from pywebpush import webpush, WebPushException +from app.utils import DatabaseManager, with_mongodb_retry logger = logging.getLogger(__name__) @@ -37,7 +38,7 @@ def _ensure_collections(self) -> None: self.db.create_collection("assignment_subscriptions") logger.info("Created assignment_subscriptions collection") - def start_notification_service(self): + def start_notification_service(self) -> None: """Start the background thread that processes notifications""" if self._notification_thread is None or not self._notification_thread.is_alive(): self._shutdown_event.clear() @@ -48,14 +49,14 @@ def start_notification_service(self): self._notification_thread.start() logger.info("Notification service started") - def stop_notification_service(self): + def stop_notification_service(self) -> None: """Stop the notification background thread""" if self._notification_thread and self._notification_thread.is_alive(): self._shutdown_event.set() self._notification_thread.join(timeout=5) logger.info("Notification service stopped") - def _notification_worker(self): + def _notification_worker(self) -> None: """Background worker that checks for pending notifications""" logger.info("Notification worker started") @@ -75,7 +76,7 @@ def _notification_worker(self): self._shutdown_event.wait(5) @with_mongodb_retry() - def _process_pending_notifications(self): + def _process_pending_notifications(self) -> None: """Process all pending notifications that are due to be sent""" @@ -129,7 +130,7 @@ def _process_pending_notifications(self): logger.info(f"Sent {count} notifications") @with_mongodb_retry() - def _schedule_assignment_notifications(self): + def _schedule_assignment_notifications(self) -> None: """Schedule notifications for assignments with due dates""" @@ -142,7 +143,7 @@ def _schedule_assignment_notifications(self): # Get the subscriptions for this assignment self._schedule_assignment_reminder(assignment) - def _schedule_assignment_reminder(self, assignment: Dict): + def _schedule_assignment_reminder(self, assignment: Dict) -> None: """Schedule reminders for a specific assignment Args: @@ -547,4 +548,4 @@ async def send_instant_assignment_notification(self, assignment_data: Dict, team logger.warning(f"No notifications were sent for assignment {assignment_data.get('title')}") except Exception as e: - logger.error(f"Error sending instant assignment notification: {str(e)}") \ No newline at end of file + logger.error(f"Error sending instant assignment notification: {str(e)}") diff --git a/app/notifications/routes.py b/app/notifications/routes.py index 533465d..73d3451 100644 --- a/app/notifications/routes.py +++ b/app/notifications/routes.py @@ -2,13 +2,14 @@ from flask_login import current_user, login_required from app.utils import async_route, limiter + from .notification_manager import NotificationManager notifications_bp = Blueprint("notifications", __name__) notification_manager = None @notifications_bp.record -def on_blueprint_init(state): +def on_blueprint_init(state) -> None: """Initialize the notification manager""" global notification_manager app = state.app @@ -151,4 +152,4 @@ async def delete_subscription(): # return jsonify({ # "success": False, # "message": "An internal error has occurred." -# }), 500 \ No newline at end of file +# }), 500 diff --git a/app/scout/TBA.py b/app/scout/TBA.py index 9a94388..305c53d 100644 --- a/app/scout/TBA.py +++ b/app/scout/TBA.py @@ -2,8 +2,12 @@ import os from datetime import datetime from functools import lru_cache + import requests +from .artificial_data import (_generate_test_matches, _generate_test_rankings, + _generate_test_teams) + logger = logging.getLogger(__name__) class TBAInterface: @@ -22,8 +26,27 @@ def __init__(self, api_key=None): self.timeout = 5 # Reduced timeout @lru_cache(maxsize=100) - def get_team(self, team_key): + def get_team(self, team_key: str) -> dict | None: """Get team information from TBA""" + # Check for test teams 1-15 + if team_key and team_key.startswith('frc'): + try: + team_num = int(team_key.replace('frc', '')) + if 1 <= team_num <= 15: + return { + 'key': team_key, + 'team_number': team_num, + 'nickname': f'Test Team {team_num}', + 'name': f'Test Team {team_num}', + 'city': 'Test City', + 'state_prov': 'Test State', + 'country': 'Test Country', + 'website': 'https://www.example.com', + 'rookie_year': 2024 + } + except ValueError: + pass + try: response = requests.get( f"{self.base_url}/team/{team_key}", @@ -36,8 +59,11 @@ def get_team(self, team_key): return None @lru_cache(maxsize=5) - def get_event_matches(self, event_key): + def get_event_matches(self, event_key: str) -> dict[str, dict] | None: """Get matches for an event and format them by match number""" + if str(event_key).endswith("test1") or str(event_key).endswith("test2") or str(event_key).endswith("test3"): + return _generate_test_matches(event_key) + try: response = requests.get( f"{self.base_url}/event/{event_key}/matches", @@ -77,7 +103,7 @@ def get_event_matches(self, event_key): return None @lru_cache(maxsize=20) - def get_current_events(self, year): + def get_current_events(self, year: int, include_test_data: bool = False) -> dict | None: """Get all events for the specified year""" try: response = requests.get( @@ -93,6 +119,22 @@ def get_current_events(self, year): # No date filtering - include all events # Convert to dictionary, maintaining alphabetical order current_events = {} + + if include_test_data: + current_events.update({ + "Testing Regional #1": { + 'key': f"{year}test1", + 'start_date': f"{year}-01-01" + }, + "Testing Regional #2": { + 'key': f"{year}test2", + 'start_date': f"{year}-01-02" + }, + "Testing Regional #3": { + 'key': f"{year}test3", + 'start_date': f"{year}-01-03" + } + }) # Sort events alphabetically by name events.sort(key=lambda x: x['name']) @@ -111,8 +153,33 @@ def get_current_events(self, year): return None @lru_cache(maxsize=20) - def get_team_status_at_event(self, team_key, event_key): + def get_team_status_at_event(self, team_key: str, event_key: str) -> dict | None: """Get team status and ranking at a specific event""" + if str(event_key).endswith("test1") or str(event_key).endswith("test2") or str(event_key).endswith("test3"): + try: + # Get rank from generated rankings + rankings = _generate_test_rankings(event_key) + team_rank = next((r for r in rankings if r['team_key'] == team_key), None) + + if team_rank: + return { + 'qual': { + 'rank': team_rank['rank'], + 'ranking_points': team_rank['ranking_points'], + 'record': team_rank['record'], + 'matches_played': team_rank['matches_played'] + }, + 'alliance': { + 'name': 'Alliance 1', + 'number': 1, + 'pick': 1, + 'backup': None + }, + 'playoff': None + } + except Exception as e: + logger.error(f"Error generating test rankings: {e}") + try: response = requests.get( f"{self.base_url}/team/{team_key}/event/{event_key}/status", @@ -125,8 +192,24 @@ def get_team_status_at_event(self, team_key, event_key): return None @lru_cache(maxsize=20) - def get_team_matches_at_event(self, team_key, event_key): + def get_team_matches_at_event(self, team_key: str, event_key: str) -> dict[str, list] | None: """Get a team's matches at a specific event with previous and upcoming separation""" + if str(event_key).endswith("test1") or str(event_key).endswith("test2") or str(event_key).endswith("test3"): + matches_dict = _generate_test_matches(event_key) + team_matches = [] + for key, m in matches_dict.items(): + if team_key in m['red'] or team_key in m['blue']: + alliance = 'red' if team_key in m['red'] else 'blue' + match_info = { + 'match_name': key, + 'time': 0, + 'alliance': alliance, + 'score': {'red': 0, 'blue': 0} + } + team_matches.append(match_info) + + return {'previous': team_matches, 'upcoming': []} + try: response = requests.get( f"{self.base_url}/team/{team_key}/event/{event_key}/matches", @@ -198,11 +281,24 @@ def get_team_matches_at_event(self, team_key, event_key): return None @lru_cache(maxsize=20) - def get_team_events(self, team_key, year=None): + def get_team_events(self, team_key: str, year: int | None = None) -> list[dict] | None: """Get all events a team is participating in for the given year""" if year is None: year = datetime.now().year + # Mock events for test teams + if team_key and team_key.startswith('frc'): + try: + team_num = int(team_key.replace('frc', '')) + if 1 <= team_num <= 5: + return [{'key': f'{year}test1', 'name': 'Testing Regional #1', 'start_date': f'{year}-01-01', 'end_date': f'{year}-01-04'}] + elif 6 <= team_num <= 10: + return [{'key': f'{year}test2', 'name': 'Testing Regional #2', 'start_date': f'{year}-01-02', 'end_date': f'{year}-01-05'}] + elif 11 <= team_num <= 15: + return [{'key': f'{year}test3', 'name': 'Testing Regional #3', 'start_date': f'{year}-01-03', 'end_date': f'{year}-01-06'}] + except ValueError: + pass + try: response = requests.get( f"{self.base_url}/team/{team_key}/events/{year}/simple", @@ -244,7 +340,7 @@ def get_most_recent_active_event(self, team_key): return events[0] @lru_cache(maxsize=5) - def get_event_teams(self, event_key): + def get_event_teams(self, event_key: str) -> list[dict] | None: """ Retrieve a list of teams attending a specific event. @@ -254,6 +350,9 @@ def get_event_teams(self, event_key): Returns: list[dict] or None: A list of team objects, or None if the request fails. """ + if str(event_key).endswith("test1") or str(event_key).endswith("test2") or str(event_key).endswith("test3"): + return _generate_test_teams(event_key) + try: response = requests.get( f"{self.base_url}/event/{event_key}/teams", @@ -270,7 +369,7 @@ def get_event_teams(self, event_key): return None @lru_cache(maxsize=5) - def get_event_rankings(self, event_key): + def get_event_rankings(self, event_key: str) -> list[dict] | None: """ Retrieve the team rankings for a given event, including ranking points and match records. @@ -290,6 +389,9 @@ def get_event_rankings(self, event_key): Exceptions: Logs and returns None if an exception occurs during the request or response parsing. """ + if str(event_key).endswith("test1") or str(event_key).endswith("test2") or str(event_key).endswith("test3"): + return _generate_test_rankings(event_key) + try: response = requests.get( f"{self.base_url}/event/{event_key}/rankings", @@ -325,4 +427,4 @@ def get_event_rankings(self, event_key): except Exception as e: logger.error(f"Error fetching event rankings from TBA: {e}") - return None \ No newline at end of file + return None diff --git a/app/scout/__init__.py b/app/scout/__init__.py index 9d3d5ed..42eb56d 100644 --- a/app/scout/__init__.py +++ b/app/scout/__init__.py @@ -1,5 +1,7 @@ +from .artificial_data import (_generate_test_matches, _generate_test_rankings, + _generate_test_teams) from .routes import * from .scouting_utils import * from .TBA import TBAInterface -__all__ = ['TBAInterface'] +__all__ = ['TBAInterface', '_generate_test_teams', '_generate_test_matches', '_generate_test_rankings'] diff --git a/app/scout/artificial_data.py b/app/scout/artificial_data.py new file mode 100644 index 0000000..4bbc8d4 --- /dev/null +++ b/app/scout/artificial_data.py @@ -0,0 +1,121 @@ +from __future__ import annotations + + +def _generate_test_teams(event_key: str) -> list[dict]: + """ + Generate test teams based on event key + + Arrgs: + event_key (str): The event key to determine which test teams to generate. + + Returns: + list[dict]: A list of team objects. + """ + if str(event_key).endswith("test1"): + start, end = 1, 6 + elif str(event_key).endswith("test2"): + start, end = 6, 11 + elif str(event_key).endswith("test3"): + start, end = 11, 16 + else: + return [] + + teams = [] + for i in range(start, end): + teams.append({ + 'key': f'frc{i}', + 'team_number': i, + 'nickname': f'Test Team {i}', + 'name': f'Test Team {i}', + 'city': 'Test City', + 'state_prov': 'Test State', + 'country': 'Test Country' + }) + return teams + +def _generate_test_matches(event_key: str) -> dict[str, dict]: + """ + Generate test matches for 5 teams + + Args: + event_key (str): The event key to determine which test matches to generate. + + Returns: + dict: A dictionary of match objects formatted by match key. + """ + teams = [t['key'] for t in _generate_test_teams(event_key)] + if not teams: + return {} + + formatted_matches = {} + + def get_team_number(index): + return teams[index % len(teams)] + + for i in range(1, 6): + # Shift starting position for variety + start_idx = (i - 1) * 2 + + red_teams = [ + get_team_number(start_idx), + get_team_number(start_idx + 1), + get_team_number(start_idx + 2) + ] + blue_teams = [ + get_team_number(start_idx + 3), + get_team_number(start_idx + 4), + get_team_number(start_idx + 5) + ] + + match_key = f"Qual {i}" + formatted_matches[match_key] = { + 'red': red_teams, + 'blue': blue_teams, + 'comp_level': 'qm', + 'match_number': i, + 'set_number': 1 + } + + # Playoffs + formatted_matches["Semifinal 1"] = { + 'red': [teams[0], teams[1], teams[2]], + 'blue': [teams[3], teams[4], teams[0]], # Reuse team 0 to fill + 'comp_level': 'sf', + 'match_number': 1, + 'set_number': 1 + } + + formatted_matches["Final 1"] = { + 'red': [teams[0], teams[1], teams[2]], + 'blue': [teams[3], teams[4], teams[0]], # Reuse team 0 to fill + 'comp_level': 'f', + 'match_number': 1, + 'set_number': 1 + } + + return formatted_matches + +def _generate_test_rankings(event_key: str) -> list[dict]: + """ + Generate test rankings for teams at an event. + + Args: + event_key (str): The event key to determine which test rankings to generate. + + Returns: + list[dict]: A list of ranking objects. + """ + teams = _generate_test_teams(event_key) + rankings = [] + + for i, team in enumerate(teams, 1): + rankings.append({ + 'rank': i, + 'team_key': team['key'], + 'team_number': team['team_number'], + 'ranking_points': 2.0 + (1.0 / i), # Fake RP + 'record': {'wins': 5-i, 'losses': i, 'ties': 0}, + 'matches_played': 5 + }) + + return rankings \ No newline at end of file diff --git a/app/scout/routes.py b/app/scout/routes.py index 0dab57c..84070f3 100644 --- a/app/scout/routes.py +++ b/app/scout/routes.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from datetime import datetime, timezone import aiohttp @@ -9,7 +10,6 @@ render_template, request, url_for) from flask_login import current_user, login_required -import logging from app.scout.scouting_utils import ScoutingManager from app.utils import async_route, handle_route_errors, limiter @@ -49,9 +49,9 @@ def add(): # Get current events only tba = TBAInterface() year = datetime.now().year - events = tba.get_current_events(year) or {} + events = tba.get_current_events(year, include_test_data=True) or {} - return render_template("scouting/add.html", + return render_template("scouting/add.html", events=events, event_matches={}) # Empty dict @@ -1030,11 +1030,11 @@ def pit_scouting_add(): return render_template("scouting/pit-scouting-add.html") -@scouting_bp.route("/scouting/pit/edit/", methods=["GET", "POST"]) +@scouting_bp.route("/scouting/pit/edit/", methods=["GET", "POST"]) @login_required # @limiter.limit("10 per minute") -def pit_scouting_edit(team_number): - pit_data = scouting_manager.get_pit_scouting(team_number) +def pit_scouting_edit(entry_id): + pit_data = scouting_manager.get_pit_scouting_by_id(entry_id) if not pit_data: flash("Pit scouting data not found", "error") return redirect(url_for("scouting.pit_scouting")) @@ -1083,7 +1083,7 @@ def pit_scouting_edit(team_number): "updated_at": datetime.now(timezone.utc) } - if scouting_manager.update_pit_scouting(team_number, data, current_user.get_id()): + if scouting_manager.update_pit_scouting(entry_id, data, current_user.get_id()): current_app.logger.info(f"Successfully updated pit scouting data {data} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") flash("Pit scouting data updated successfully", "success") return redirect(url_for("scouting.pit_scouting")) @@ -1096,14 +1096,14 @@ def pit_scouting_edit(team_number): return render_template("scouting/pit-scouting-edit.html", pit_data=pit_data) -@scouting_bp.route("/scouting/pit/delete/") +@scouting_bp.route("/scouting/pit/delete/") @login_required -def pit_scouting_delete(team_number): - if scouting_manager.delete_pit_scouting(team_number, current_user.get_id()): - current_app.logger.info(f"Successfully deleted pit scouting data {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") +def pit_scouting_delete(entry_id): + if scouting_manager.delete_pit_scouting(entry_id, current_user.get_id()): + current_app.logger.info(f"Successfully deleted pit scouting entry {entry_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") flash("Pit scouting data deleted successfully", "success") else: - current_app.logger.info(f"Failed to delete pit scouting data {team_number} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") + current_app.logger.info(f"Failed to delete pit scouting entry {entry_id} for user {current_user.username if current_user.is_authenticated else 'Anonymous'}") flash("Error deleting pit scouting data", "error") return redirect(url_for("scouting.pit_scouting")) diff --git a/app/scout/scouting_utils.py b/app/scout/scouting_utils.py index e963a65..08f2a9e 100644 --- a/app/scout/scouting_utils.py +++ b/app/scout/scouting_utils.py @@ -17,7 +17,7 @@ def __init__(self, mongo_uri=None): super().__init__(mongo_uri) self._ensure_collections() - def _ensure_collections(self): + def _ensure_collections(self) -> None: """Ensure required collections exist""" collections = self.db.list_collection_names() if "team_data" not in collections: @@ -28,18 +28,15 @@ def _ensure_collections(self): self.db.pit_scouting.create_index([("scouter_id", 1)]) logger.info("Created pit_scouting collection and indexes") - def _create_team_data_collection(self): + def _create_team_data_collection(self) -> None: self.db.create_collection("team_data") self.db.team_data.create_index([("team_number", 1)]) self.db.team_data.create_index([("scouter_id", 1)]) logger.info("Created team_data collection and indexes") @with_mongodb_retry(retries=3, delay=2) - def add_scouting_data(self, data, scouter_id): - """Add new scouting data with retry mechanism""" - # Ensure we have a valid connection - - + def add_scouting_data(self, data: dict, scouter_id: str) -> tuple[bool, str]: + """Add new scouting data with retry mechanism""" try: # Validate team number team_number = int(data["team_number"]) @@ -47,38 +44,38 @@ def add_scouting_data(self, data, scouter_id): return False, "Invalid team number" # Lookup to check if this team is already scouted in this match by someone from the same team - pipeline = [ - { - "$match": { - "event_code": data["event_code"], - "match_number": data["match_number"], - "team_number": team_number - } - }, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"}, - { - "$lookup": { - "from": "users", - "localField": "scouter.teamNumber", - "foreignField": "teamNumber", - "as": "team_scouters" - } - } - ] - - if existing_entries := list(self.db.team_data.aggregate(pipeline)): - current_user = self.db.users.find_one({"_id": ObjectId(scouter_id)}) - for entry in existing_entries: - if entry.get("scouter", {}).get("teamNumber") == current_user.get("teamNumber"): - return False, f"Team {team_number} has already been scouted by your team in match {data['match_number']}" + # pipeline = [ + # { + # "$match": { + # "event_code": data["event_code"], + # "match_number": data["match_number"], + # "team_number": team_number + # } + # }, + # { + # "$lookup": { + # "from": "users", + # "localField": "scouter_id", + # "foreignField": "_id", + # "as": "scouter" + # } + # }, + # {"$unwind": "$scouter"}, + # { + # "$lookup": { + # "from": "users", + # "localField": "scouter.teamNumber", + # "foreignField": "teamNumber", + # "as": "team_scouters" + # } + # } + # ] + + # if existing_entries := list(self.db.team_data.aggregate(pipeline)): + # current_user = self.db.users.find_one({"_id": ObjectId(scouter_id)}) + # for entry in existing_entries: + # if entry.get("scouter", {}).get("teamNumber") == current_user.get("teamNumber"): + # return False, f"Team {team_number} has already been scouted by your team in match {data['match_number']}" # Get existing match data to validate alliance sizes and calculate scores # match_data = list(self.db.team_data.find({ @@ -144,7 +141,7 @@ def add_scouting_data(self, data, scouter_id): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - def get_all_scouting_data(self, user_team_number=None, user_id=None): + def get_all_scouting_data(self, user_team_number=None, user_id=None) -> list[dict]: """Get all scouting data with user information, filtered by team access""" try: # Base pipeline for user lookup @@ -223,7 +220,7 @@ def get_all_scouting_data(self, user_team_number=None, user_id=None): return [] @with_mongodb_retry(retries=3, delay=2) - def get_team_data(self, team_id, scouter_id=None): + def get_team_data(self, team_id, scouter_id=None) -> TeamData | None: """Get specific team data with optional scouter verification""" try: # Just get the data by ID first @@ -250,7 +247,7 @@ def get_team_data(self, team_id, scouter_id=None): return None @with_mongodb_retry(retries=3, delay=2) - def update_team_data(self, team_id, data, scouter_id): + def update_team_data(self, team_id, data, scouter_id) -> bool: """Update existing team data if user is the owner""" try: # First verify ownership and get current data @@ -355,7 +352,7 @@ def update_team_data(self, team_id, data, scouter_id): return False @with_mongodb_retry(retries=3, delay=2) - def delete_team_data(self, team_id, user_id, admin_override=False): + def delete_team_data(self, team_id: str, user_id: str, admin_override=False) -> bool: """Delete team data if scouter has permission (original scouter or team admin)""" try: # First get the team data to check permissions @@ -406,7 +403,7 @@ def delete_team_data(self, team_id, user_id, admin_override=False): return False @with_mongodb_retry(retries=3, delay=2) - def has_team_data(self, team_number): + def has_team_data(self, team_number: str) -> bool: """Check if there is any scouting data for a given team number""" try: count = self.db.team_data.count_documents({"team_number": int(team_number)}) @@ -416,7 +413,7 @@ def has_team_data(self, team_number): return False @with_mongodb_retry(retries=3, delay=2) - def get_team_stats(self, team_number): + def get_team_stats(self, team_number: str) -> dict | None: """Get comprehensive stats for a team""" try: pipeline = [ @@ -468,7 +465,7 @@ def get_team_stats(self, team_number): return None @with_mongodb_retry(retries=3, delay=2) - def get_team_matches(self, team_number): + def get_team_matches(self, team_number: str) -> list[dict]: """Get all match data for a specific team""" try: pipeline = [ @@ -491,7 +488,7 @@ def get_team_matches(self, team_number): return [] @with_mongodb_retry(retries=3, delay=2) - def get_auto_paths(self, team_number): + def get_auto_paths(self, team_number: str) -> list[dict]: """Get all auto paths for a specific team""" try: paths = list(self.db.team_data.find( @@ -520,19 +517,28 @@ def get_auto_paths(self, team_number): return [] @with_mongodb_retry(retries=3, delay=2) - def add_pit_scouting(self, data): + def add_pit_scouting(self, data: dict) -> bool: """Add new pit scouting data with team validation""" try: team_number = int(data["team_number"]) scouter_id = ObjectId(data["scouter_id"]) # Convert to ObjectId - # Check if this team is already scouted by someone from the same team + # Ensure scouter_id is ObjectId in the data + data["scouter_id"] = scouter_id + + result = self.db.pit_scouting.insert_one(data) + return bool(result.inserted_id) + + except Exception as e: + logger.error(f"Error adding pit scouting data: {str(e)}") + return False + + @with_mongodb_retry(retries=3, delay=2) + def get_pit_scouting_by_id(self, entry_id: str) -> dict | None: + """Get a single pit scouting record by its MongoDB _id""" + try: pipeline = [ - { - "$match": { - "team_number": team_number - } - }, + {"$match": {"_id": ObjectId(entry_id)}}, { "$lookup": { "from": "users", @@ -541,29 +547,39 @@ def add_pit_scouting(self, data): "as": "scouter" } }, - {"$unwind": "$scouter"} + { + "$unwind": { + "path": "$scouter", + "preserveNullAndEmptyArrays": True + } + }, + { + "$project": { + "_id": 1, + "team_number": 1, + "drive_type": 1, + "swerve_modules": 1, + "motor_details": 1, + "motor_count": 1, + "dimensions": 1, + "programming_language": 1, + "autonomous_capabilities": 1, + "driver_experience": 1, + "notes": 1, + "scouter_id": "$scouter._id", + "scouter_name": "$scouter.username", + "scouter_team": "$scouter.teamNumber" + } + } ] - - existing_entries = list(self.db.pit_scouting.aggregate(pipeline)) - current_user = self.db.users.find_one({"_id": scouter_id}) - - for entry in existing_entries: - if entry.get("scouter", {}).get("teamNumber") == current_user.get("teamNumber"): - logger.warning(f"Team {team_number} has already been pit scouted by team {current_user.get('teamNumber')}") - return False - - # Ensure scouter_id is ObjectId in the data - data["scouter_id"] = scouter_id - - result = self.db.pit_scouting.insert_one(data) - return bool(result.inserted_id) - + result = list(self.db.pit_scouting.aggregate(pipeline)) + return result[0] if result else None except Exception as e: - logger.error(f"Error adding pit scouting data: {str(e)}") - return False + logger.error(f"Error fetching pit scouting data by id: {str(e)}") + return None @with_mongodb_retry(retries=3, delay=2) - def get_pit_scouting(self, team_number): + def get_pit_scouting(self, team_number: str) -> dict | None: """Get pit scouting data with scouter information""" try: pipeline = [ @@ -613,7 +629,7 @@ def get_pit_scouting(self, team_number): return None @with_mongodb_retry(retries=3, delay=2) - def get_all_pit_scouting(self, user_team_number=None, user_id=None): + def get_all_pit_scouting(self, user_team_number=None, user_id=None) -> list[dict]: """Get all pit scouting data with team-based access control""" try: logger.info(f"Fetching pit scouting data for user_id: {user_id}, team_number: {user_team_number}") @@ -710,51 +726,16 @@ def get_all_pit_scouting(self, user_team_number=None, user_id=None): return [] @with_mongodb_retry(retries=3, delay=2) - def update_pit_scouting(self, team_number, data, scouter_id): - """Update pit scouting data with team validation""" + def update_pit_scouting(self, entry_id: str, data: dict, scouter_id: str) -> bool: + """Update pit scouting data with ownership validation""" try: - # First verify ownership and get current data - existing_data = self.db.pit_scouting.find_one( - {"team_number": team_number} - ) - - if not existing_data: - logger.warning(f"Pit data not found for team: {team_number}") - return False - - # Check if this team is already scouted by someone else from the same team - pipeline = [ - { - "$match": { - "team_number": team_number, - "_id": {"$ne": existing_data["_id"]} # Exclude current entry - } - }, - { - "$lookup": { - "from": "users", - "localField": "scouter_id", - "foreignField": "_id", - "as": "scouter" - } - }, - {"$unwind": "$scouter"} - ] - - existing_entries = list(self.db.pit_scouting.aggregate(pipeline)) - current_user = self.db.users.find_one({"_id": ObjectId(scouter_id)}) - - for entry in existing_entries: - if entry.get("scouter", {}).get("teamNumber") == current_user.get("teamNumber"): - logger.warning( - f"Update attempted for team {team_number} which is already pit scouted by team {current_user.get('teamNumber')}" - ) - return False - result = self.db.pit_scouting.update_one( - {"team_number": team_number}, + {"_id": ObjectId(entry_id), "scouter_id": ObjectId(scouter_id)}, {"$set": data} ) + if result.matched_count == 0: + logger.warning(f"Pit data not found or permission denied for entry: {entry_id}") + return False return result.modified_count > 0 except Exception as e: @@ -762,11 +743,11 @@ def update_pit_scouting(self, team_number, data, scouter_id): return False @with_mongodb_retry(retries=3, delay=2) - def delete_pit_scouting(self, team_number, scouter_id): + def delete_pit_scouting(self, entry_id: str, scouter_id: str) -> bool: """Delete pit scouting data""" try: result = self.db.pit_scouting.delete_one({ - "team_number": int(team_number), + "_id": ObjectId(entry_id), "scouter_id": ObjectId(scouter_id) }) return result.deleted_count > 0 diff --git a/app/static/css/auton.css b/app/static/css/auton.css index e789a53..a468464 100644 --- a/app/static/css/auton.css +++ b/app/static/css/auton.css @@ -1,90 +1,114 @@ .path-card { - border: 1px solid #e5e7eb; - border-radius: 0.375rem; - padding: 0.75rem; - margin-bottom: 0.5rem; - background-color: white; - cursor: pointer; - transition: all 0.2s; + border: 1px solid #e5e7eb; + border-radius: 0.375rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + background-color: white; + cursor: pointer; + transition: all 0.2s; } .path-card:hover { - background-color: #f3f4f6; - border-color: #d1d5db; + background-color: #f3f4f6; + border-color: #d1d5db; } .path-card.selected { - border-color: #3b82f6; - background-color: #ebf5ff; + border-color: #3b82f6; + background-color: #ebf5ff; } .draggable-container { - border: 2px dashed #d1d5db; - border-radius: 0.375rem; - padding: 1rem; - min-height: 200px; + border: 2px dashed #d1d5db; + border-radius: 0.375rem; + padding: 1rem; + min-height: 200px; } .drag-handle { - cursor: grab; + cursor: grab; } .selected-path-card { - position: relative; - padding-right: 2rem; + position: relative; + padding-right: 2rem; } .remove-path { - position: absolute; - right: 0.5rem; - top: 0.5rem; - cursor: pointer; - color: #ef4444; + position: absolute; + right: 0.5rem; + top: 0.5rem; + cursor: pointer; + color: #ef4444; } .color-indicator { - display: inline-block; - width: 16px; - height: 16px; - border-radius: 50%; - margin-right: 8px; - vertical-align: middle; + display: inline-block; + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 8px; + vertical-align: middle; } .tool-btn { - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background-color: #f3f4f6; - color: #374151; - border: 1px solid #d1d5db; - border-radius: 0.75rem; - transition: all 0.2s ease; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; + border-radius: 0.75rem; + transition: all 0.2s ease; } .tool-btn svg { - width: 24px; - height: 24px; + width: 24px; + height: 24px; } .tool-btn:active { - transform: scale(0.95); + transform: scale(0.95); } .tool-btn.bg-blue-50 { - background-color: #eff6ff; - border-color: #93c5fd; - color: #1d4ed8; + background-color: #eff6ff; + border-color: #93c5fd; + color: #1d4ed8; } .tool-btn.bg-blue-50:hover { - background-color: #dbeafe; + background-color: #dbeafe; } .tool-btn.bg-red-50 { - background-color: #fef2f2; - border-color: #fca5a5; - color: #b91c1c; + background-color: #fef2f2; + border-color: #fca5a5; + color: #b91c1c; } .tool-btn.bg-red-50:hover { - background-color: #fee2e2; + background-color: #fee2e2; +} +.tool-btn.bg-purple-50 { + background-color: #faf5ff; + border-color: #ddd6fe; + color: #7c3aed; +} +.tool-btn.bg-purple-50:hover { + background-color: #f3e8ff; +} +.tool-btn.bg-green-50 { + background-color: #f0fdf4; + border-color: #86efac; + color: #15803d; +} +.tool-btn.bg-green-50:hover { + background-color: #dcfce7; } @media (max-width: 640px) { - .tool-btn { - width: 42px; - height: 42px; - } - .tool-btn svg { - width: 20px; - height: 20px; - } -} \ No newline at end of file + .tool-btn { + width: 38px; + height: 38px; + padding: 0.4rem; + } + .tool-btn svg { + width: 18px; + height: 18px; + } + + /* Make button container scrollable on mobile */ + .flex.gap-4 { + gap: 0.5rem; + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/app/static/css/coloris.css b/app/static/css/coloris.css new file mode 100644 index 0000000..41ddad2 --- /dev/null +++ b/app/static/css/coloris.css @@ -0,0 +1,577 @@ +.clr-picker { + display: none; + flex-wrap: wrap; + position: absolute; + width: 200px; + z-index: 1000; + border-radius: 10px; + background-color: #fff; + justify-content: flex-end; + direction: ltr; + box-shadow: 0 0 5px rgba(0,0,0,.05), 0 5px 20px rgba(0,0,0,.1); + -moz-user-select: none; + -webkit-user-select: none; + user-select: none; +} + +.clr-picker.clr-open, +.clr-picker[data-inline="true"] { + display: flex; +} + +.clr-picker[data-inline="true"] { + position: relative; +} + +.clr-gradient { + position: relative; + width: 100%; + height: 100px; + margin-bottom: 15px; + border-radius: 3px 3px 0 0; + background-image: linear-gradient(rgba(0,0,0,0), #000), linear-gradient(90deg, #fff, currentColor); + cursor: pointer; +} + +.clr-marker { + position: absolute; + width: 12px; + height: 12px; + margin: -6px 0 0 -6px; + border: 1px solid #fff; + border-radius: 50%; + background-color: currentColor; + cursor: pointer; +} + +.clr-picker input[type="range"]::-webkit-slider-runnable-track { + width: 100%; + height: 16px; +} + +.clr-picker input[type="range"]::-webkit-slider-thumb { + width: 16px; + height: 16px; + -webkit-appearance: none; +} + +.clr-picker input[type="range"]::-moz-range-track { + width: 100%; + height: 16px; + border: 0; +} + +.clr-picker input[type="range"]::-moz-range-thumb { + width: 16px; + height: 16px; + border: 0; +} + +.clr-hue { + background-image: linear-gradient(to right, #f00 0%, #ff0 16.66%, #0f0 33.33%, #0ff 50%, #00f 66.66%, #f0f 83.33%, #f00 100%); +} + +.clr-hue, +.clr-alpha { + position: relative; + width: calc(100% - 40px); + height: 8px; + margin: 5px 20px; + border-radius: 4px; +} + +.clr-alpha span { + display: block; + height: 100%; + width: 100%; + border-radius: inherit; + background-image: linear-gradient(90deg, rgba(0,0,0,0), currentColor); +} + +.clr-hue input[type="range"], +.clr-alpha input[type="range"] { + position: absolute; + width: calc(100% + 32px); + height: 16px; + left: -16px; + top: -4px; + margin: 0; + background-color: transparent; + opacity: 0; + cursor: pointer; + appearance: none; + -webkit-appearance: none; +} + +.clr-hue div, +.clr-alpha div { + position: absolute; + width: 16px; + height: 16px; + left: 0; + top: 50%; + margin-left: -8px; + transform: translateY(-50%); + border: 2px solid #fff; + border-radius: 50%; + background-color: currentColor; + box-shadow: 0 0 1px #888; + pointer-events: none; +} + +.clr-alpha div:before { + content: ''; + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; + border-radius: 50%; + background-color: currentColor; +} + +.clr-format { + display: none; + order: 1; + width: calc(100% - 40px); + margin: 0 20px 20px; +} + +.clr-segmented { + display: flex; + position: relative; + width: 100%; + margin: 0; + padding: 0; + border: 1px solid #ddd; + border-radius: 15px; + box-sizing: border-box; + color: #999; + font-size: 12px; +} + +.clr-segmented input, +.clr-segmented legend { + position: absolute; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + left: 0; + top: 0; + opacity: 0; + pointer-events: none; +} + +.clr-segmented label { + flex-grow: 1; + margin: 0; + padding: 4px 0; + font-size: inherit; + font-weight: normal; + line-height: initial; + text-align: center; + cursor: pointer; +} + +.clr-segmented label:first-of-type { + border-radius: 10px 0 0 10px; +} + +.clr-segmented label:last-of-type { + border-radius: 0 10px 10px 0; +} + +.clr-segmented input:checked + label { + color: #fff; + background-color: #666; +} + +.clr-swatches { + order: 2; + width: calc(100% - 32px); + margin: 0 16px; +} + +.clr-swatches div { + display: flex; + flex-wrap: wrap; + padding-bottom: 12px; + justify-content: center; +} + +.clr-swatches button { + position: relative; + width: 20px; + height: 20px; + margin: 0 4px 6px 4px; + padding: 0; + border: 0; + border-radius: 50%; + color: inherit; + text-indent: -1000px; + white-space: nowrap; + overflow: hidden; + cursor: pointer; +} + +.clr-swatches button:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + background-color: currentColor; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.1); +} + +input.clr-color { + order: 1; + width: calc(100% - 80px); + height: 32px; + margin: 15px 20px 20px auto; + padding: 0 10px; + border: 1px solid #ddd; + border-radius: 16px; + color: #444; + background-color: #fff; + font-family: sans-serif; + font-size: 14px; + text-align: center; + box-shadow: none; +} + +input.clr-color:focus { + outline: none; + border: 1px solid #1e90ff; +} + +.clr-close, +.clr-clear { + display: none; + order: 2; + height: 24px; + margin: 0 20px 20px; + padding: 0 20px; + border: 0; + border-radius: 12px; + color: #fff; + background-color: #666; + font-family: inherit; + font-size: 12px; + font-weight: 400; + cursor: pointer; +} + +.clr-close { + display: block; + margin: 0 20px 20px auto; +} + +.clr-preview { + position: relative; + width: 32px; + height: 32px; + margin: 15px 0 20px 20px; + border-radius: 50%; + overflow: hidden; +} + +.clr-preview:before, +.clr-preview:after { + content: ''; + position: absolute; + height: 100%; + width: 100%; + left: 0; + top: 0; + border: 1px solid #fff; + border-radius: 50%; +} + +.clr-preview:after { + border: 0; + background-color: currentColor; + box-shadow: inset 0 0 0 1px rgba(0,0,0,.1); +} + +.clr-preview button { + position: absolute; + width: 100%; + height: 100%; + z-index: 1; + margin: 0; + padding: 0; + border: 0; + border-radius: 50%; + outline-offset: -2px; + background-color: transparent; + text-indent: -9999px; + cursor: pointer; + overflow: hidden; +} + +.clr-marker, +.clr-hue div, +.clr-alpha div, +.clr-color { + box-sizing: border-box; +} + +.clr-field { + display: inline-block; + position: relative; + color: transparent; +} + +.clr-field input { + margin: 0; + direction: ltr; +} + +.clr-field.clr-rtl input { + text-align: right; +} + +.clr-field button { + position: absolute; + width: 30px; + height: 100%; + right: 0; + top: 50%; + transform: translateY(-50%); + margin: 0; + padding: 0; + border: 0; + color: inherit; + text-indent: -1000px; + white-space: nowrap; + overflow: hidden; + pointer-events: none; +} + +.clr-field.clr-rtl button { + right: auto; + left: 0; +} + +.clr-field button:after { + content: ''; + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + border-radius: inherit; + background-color: currentColor; + box-shadow: inset 0 0 1px rgba(0,0,0,.5); +} + +.clr-alpha, +.clr-alpha div, +.clr-swatches button, +.clr-preview:before, +.clr-field button { + background-image: repeating-linear-gradient(45deg, #aaa 25%, transparent 25%, transparent 75%, #aaa 75%, #aaa), repeating-linear-gradient(45deg, #aaa 25%, #fff 25%, #fff 75%, #aaa 75%, #aaa); + background-position: 0 0, 4px 4px; + background-size: 8px 8px; +} + +.clr-marker:focus { + outline: none; +} + +.clr-keyboard-nav .clr-marker:focus, +.clr-keyboard-nav .clr-hue input:focus + div, +.clr-keyboard-nav .clr-alpha input:focus + div, +.clr-keyboard-nav .clr-segmented input:focus + label { + outline: none; + box-shadow: 0 0 0 2px #1e90ff, 0 0 2px 2px #fff; +} + +.clr-picker[data-alpha="false"] .clr-alpha { + display: none; +} + +.clr-picker[data-minimal="true"] { + padding-top: 16px; +} + +.clr-picker[data-minimal="true"] .clr-gradient, +.clr-picker[data-minimal="true"] .clr-hue, +.clr-picker[data-minimal="true"] .clr-alpha, +.clr-picker[data-minimal="true"] .clr-color, +.clr-picker[data-minimal="true"] .clr-preview { + display: none; +} + +/** Dark theme **/ + +.clr-dark { + background-color: #444; +} + +.clr-dark .clr-segmented { + border-color: #777; +} + +.clr-dark .clr-swatches button:after { + box-shadow: inset 0 0 0 1px rgba(255,255,255,.3); +} + +.clr-dark input.clr-color { + color: #fff; + border-color: #777; + background-color: #555; +} + +.clr-dark input.clr-color:focus { + border-color: #1e90ff; +} + +.clr-dark .clr-preview:after { + box-shadow: inset 0 0 0 1px rgba(255,255,255,.5); +} + +.clr-dark .clr-alpha, +.clr-dark .clr-alpha div, +.clr-dark .clr-swatches button, +.clr-dark .clr-preview:before { + background-image: repeating-linear-gradient(45deg, #666 25%, transparent 25%, transparent 75%, #888 75%, #888), repeating-linear-gradient(45deg, #888 25%, #444 25%, #444 75%, #888 75%, #888); +} + +/** Polaroid theme **/ + +.clr-picker.clr-polaroid { + border-radius: 6px; + box-shadow: 0 0 5px rgba(0,0,0,.1), 0 5px 30px rgba(0,0,0,.2); +} + +.clr-picker.clr-polaroid:before { + content: ''; + display: block; + position: absolute; + width: 16px; + height: 10px; + left: 20px; + top: -10px; + border: solid transparent; + border-width: 0 8px 10px 8px; + border-bottom-color: currentColor; + box-sizing: border-box; + color: #fff; + filter: drop-shadow(0 -4px 3px rgba(0,0,0,.1)); + pointer-events: none; +} + +.clr-picker.clr-polaroid.clr-dark:before { + color: #444; +} + +.clr-picker.clr-polaroid.clr-left:before { + left: auto; + right: 20px; +} + +.clr-picker.clr-polaroid.clr-top:before { + top: auto; + bottom: -10px; + transform: rotateZ(180deg); +} + +.clr-polaroid .clr-gradient { + width: calc(100% - 20px); + height: 120px; + margin: 10px; + border-radius: 3px; +} + +.clr-polaroid .clr-hue, +.clr-polaroid .clr-alpha { + width: calc(100% - 30px); + height: 10px; + margin: 6px 15px; + border-radius: 5px; +} + +.clr-polaroid .clr-hue div, +.clr-polaroid .clr-alpha div { + box-shadow: 0 0 5px rgba(0,0,0,.2); +} + +.clr-polaroid .clr-format { + width: calc(100% - 20px); + margin: 0 10px 15px; +} + +.clr-polaroid .clr-swatches { + width: calc(100% - 12px); + margin: 0 6px; +} +.clr-polaroid .clr-swatches div { + padding-bottom: 10px; +} + +.clr-polaroid .clr-swatches button { + width: 22px; + height: 22px; +} + +.clr-polaroid input.clr-color { + width: calc(100% - 60px); + margin: 10px 10px 15px auto; +} + +.clr-polaroid .clr-clear { + margin: 0 10px 15px 10px; +} + +.clr-polaroid .clr-close { + margin: 0 10px 15px auto; +} + +.clr-polaroid .clr-preview { + margin: 10px 0 15px 10px; +} + +/** Large theme **/ + +.clr-picker.clr-large { + width: 275px; +} + +.clr-large .clr-gradient { + height: 150px; +} + +.clr-large .clr-swatches button { + width: 22px; + height: 22px; +} + +/** Pill (horizontal) theme **/ + +.clr-picker.clr-pill { + width: 380px; + padding-left: 180px; + box-sizing: border-box; +} + +.clr-pill .clr-gradient { + position: absolute; + width: 180px; + height: 100%; + left: 0; + top: 0; + margin-bottom: 0; + border-radius: 3px 0 0 3px; +} + +.clr-pill .clr-hue { + margin-top: 20px; +} \ No newline at end of file diff --git a/app/static/css/global.css b/app/static/css/global.css index 055f82b..f96135a 100644 --- a/app/static/css/global.css +++ b/app/static/css/global.css @@ -49,6 +49,84 @@ input[type="number"] { appearance: textfield; } +/* Counter button group */ +.counter-group { + display: flex; + align-items: stretch; + gap: 0.5rem; + justify-content: center; +} + +.counter-group .counter-decrements, +.counter-group .counter-increments { + display: flex; + gap: 0.5rem; +} + +/* All buttons inside a counter-group */ +.counter-group .counter-decrements > button, +.counter-group .counter-increments > button { + margin-left: 0 !important; + margin-right: 0 !important; + padding: 0.75rem 1.25rem; + font-size: 1rem; + font-weight: 700; + min-width: 56px; + min-height: 48px; + border-radius: 0.375rem; + cursor: pointer; + flex-shrink: 0; +} + +.counter-group input[type="number"] { + min-width: 80px; + max-width: 120px; + flex: 1 1 auto; + width: auto !important; + text-align: center; + font-size: 1rem; + font-weight: 600; +} + +@media (max-width: 639px) { + /* On mobile: stack into column — decrements top, input middle, increments bottom */ + .counter-group { + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + /* Row of decrement buttons */ + .counter-group .counter-decrements { + order: 1; + } + + /* Input row */ + .counter-group input[type="number"] { + order: 2; + width: 100% !important; + max-width: 100%; + min-width: unset; + padding: 0.75rem; + font-size: 1.125rem; + font-weight: 700; + } + + /* Row of increment buttons */ + .counter-group .counter-increments { + order: 3; + } + + .counter-group .counter-decrements > button, + .counter-group .counter-increments > button { + flex: 1; + padding: 0.875rem 0.5rem; + font-size: 1rem; + font-weight: 700; + min-height: 52px; + } +} + .tippy-box[data-theme~='success'] { background-color: #10B981; color: white; diff --git a/app/static/css/scouting.css b/app/static/css/scouting.css index 603aa2a..df9f09e 100644 --- a/app/static/css/scouting.css +++ b/app/static/css/scouting.css @@ -1,68 +1,110 @@ .clr-field button { - width: 36px; - height: 36px; - border-radius: 9999px; - transition: transform 0.2s; + width: 36px; + height: 36px; + border-radius: 9999px; + transition: transform 0.2s; } .clr-field button:hover { - transform: scale(1.1); + transform: scale(1.1); } .clr-field.active button { - --tw-ring-offset-width: 2px; - --tw-ring-width: 2px; - --tw-ring-color: rgb(59 130 246); - box-shadow: 0 0 0 var(--tw-ring-offset-width) #fff, - 0 0 0 calc(var(--tw-ring-offset-width) + var(--tw-ring-width)) var(--tw-ring-color); + --tw-ring-offset-width: 2px; + --tw-ring-width: 2px; + --tw-ring-color: rgb(59 130 246); + box-shadow: + 0 0 0 var(--tw-ring-offset-width) #fff, + 0 0 0 calc(var(--tw-ring-offset-width) + var(--tw-ring-width)) + var(--tw-ring-color); } .tool-btn { - @apply p-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors border border-gray-300; - width: 48px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; + @apply p-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors border border-gray-300; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; } @media (min-width: 640px) { - .tool-btn { - width: 40px; - height: 40px; - } + .tool-btn { + width: 40px; + height: 40px; + } } .tool-btn svg { - width: 24px; - height: 24px; + width: 24px; + height: 24px; } .tool-btn.active { - @apply bg-blue-100 border-blue-500 text-blue-700; + @apply bg-blue-100 border-blue-500 text-blue-700; } .tool-btn:active { - @apply transform scale-95; + @apply transform scale-95; } .tool-btn.bg-red-50 { - @apply bg-red-50 border-red-300 text-red-700 hover:bg-red-100; + @apply bg-red-50 border-red-300 text-red-700 hover:bg-red-100; } .tool-btn.bg-green-50 { - @apply bg-green-50 border-green-300 text-green-700 hover:bg-green-100; + @apply bg-green-50 border-green-300 text-green-700 hover:bg-green-100; } .tool-btn.bg-blue-50 { - @apply bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100; + @apply bg-blue-50 border-blue-300 text-blue-700 hover:bg-blue-100; +} +.tool-btn.bg-purple-50 { + @apply bg-purple-50 border-purple-300 text-purple-700 hover:bg-purple-100; +} +/* Toolbox layout */ +.tools-group, +.actions-group { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.5rem; + width: 100%; +} + +@media (min-width: 640px) { + .tools-group { + grid-template-columns: repeat(7, 1fr); + } + .actions-group { + grid-template-columns: repeat(4, 1fr); + } } + +@media (min-width: 1024px) { + .tools-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + width: auto; + } + .actions-group { + display: flex; + flex-wrap: wrap; + justify-content: center; + width: auto; + } +} + /* Mobile-specific styles */ @media (max-width: 639px) { - .controls { - @apply sticky top-0 bg-white z-10 border-b border-gray-200; - } - - .color-picker-container { - @apply w-full; - } - - #pathColorPicker { - @apply w-full; - } - - .clr-field button { - width: 48px; - height: 48px; - } -} \ No newline at end of file + .controls { + @apply sticky top-0 bg-white z-10 border-b border-gray-200; + } + + .color-picker-container { + @apply w-full; + } + + #pathColorPicker { + @apply w-full; + } + + .clr-field button { + width: 48px; + height: 48px; + } + + .tool-btn { + width: 100%; + } +} diff --git a/app/static/js/Canvas.js b/app/static/js/Canvas.js index b633410..306b2d5 100644 --- a/app/static/js/Canvas.js +++ b/app/static/js/Canvas.js @@ -1,1237 +1,1663 @@ class Canvas { - constructor(options = {}) { - // Canvas elements - this.canvas = options.canvas || document.getElementById('whiteboard'); - if (!this.canvas) { - throw new Error('Canvas element is required'); - } - - this.ctx = this.canvas.getContext('2d', { - desynchronized: true, // Reduce latency - alpha: false // Optimize performance - }); + constructor(options = {}) { + // Canvas elements + this.canvas = options.canvas || document.getElementById("whiteboard"); + if (!this.canvas) { + throw new Error("Canvas element is required"); + } - if (!this.ctx) { - throw new Error('Could not get canvas context'); - } - - this.container = options.container || document.getElementById('container'); - - // Status display handling - if (typeof options.showStatus === 'function') { - // If a function is provided, use it - this.statusDisplayFn = options.showStatus; - } else { - // Otherwise, look for a DOM element - this.statusDisplay = options.statusDisplay || null; - } - - // Readonly mode - this.readonly = options.readonly || false; - - // External UI update function - this.externalUpdateUIControls = options.updateUIControls || null; - - // Field dimensions (fixed size we want to display) - this.FIELD_WIDTH = 800; - this.FIELD_HEIGHT = 400; - - // Background image - this.backgroundImage = new Image(); - this.backgroundImage.src = options.backgroundImage || ''; - this.backgroundLoaded = false; - this.backgroundImage.onload = () => { - this.backgroundLoaded = true; - this.resetView(); // Center and scale the view when image loads - this.redrawCanvas(); - }; - - // Safety limits - this.MAX_STROKES = 10000; // Prevent memory issues - this.MIN_SCALE = 0.1; - this.MAX_SCALE = 10; - this.lastDPR = window.devicePixelRatio || 1; - - // Drawing state - this.isDrawing = false; - this.lastX = 0; - this.lastY = 0; - this.previewShape = null; // Add preview shape state - - // Pan and zoom state - this.isPanning = false; - this.startPanX = 0; - this.startPanY = 0; - this.offsetX = 0; - this.offsetY = 0; - this.scale = 1; - this.lastDistance = null; - - // Pan limits - how far from center user can pan - this.MAX_PAN_DISTANCE = options.maxPanDistance || 500; - - // Grid settings - this.GRID_SPACING = options.gridSpacing || 50; - this.GRID_COLOR = options.gridColor || '#e0e0e0'; - this.AXIS_COLOR = options.axisColor || '#a0a0a0'; - - // Line style - this.currentColor = options.initialColor || '#000000'; - this.currentThickness = options.initialThickness || 3; - this.isFilled = false; // Add fill state - - // Drawing history for undo and save functionality - this.drawingHistory = []; - this.redoHistory = []; // Add redo history - this.currentStroke = []; - - // Current tool state - this.currentTool = 'pen'; // pen, rectangle, circle, line, hexagon, star, arrow, select - this.startX = null; - this.startY = null; - - // Selection state - this.selectedStrokes = []; - this.selectionRect = null; - this.isSelecting = false; - this.selectedStrokesCopy = null; // For copy/cut operations - this.moveSelection = { - active: false, - startX: 0, - startY: 0, - offsetX: 0, - offsetY: 0 - }; - - // Initialize LocalForage - this.storage = localforage.createInstance({ - name: 'CanvasField' - }); - - // Perfect freehand settings - this.pressure = 0.5; - this.thinning = 0.5; - this.smoothing = 0.5; - this.streamline = 0.5; - this.points = []; - - // Stroke settings - this.minWidth = 0.5; - this.maxWidth = 4; - this.lastVelocity = 0; - this.lastWidth = 0; - this.velocityFilterWeight = 0.7; - - // Auto-save interval (every 30 seconds) - this.autoSaveInterval = setInterval(() => this.autoSave(), 30000); - - // Initialize - this.resizeCanvas(); - this.bindEvents(); - - // Handle DPR changes (e.g., moving between monitors) - this.dprMediaQuery = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); - this.dprMediaQuery.addEventListener('change', () => { - if (this.lastDPR !== (window.devicePixelRatio || 1)) { - this.lastDPR = window.devicePixelRatio || 1; - this.resizeCanvas(); - } - }); - - // Handle context loss - this.canvas.addEventListener('webglcontextlost', (e) => { - e.preventDefault(); - this.showStatus('Canvas context lost - Attempting to restore...'); - }); - - this.canvas.addEventListener('webglcontextrestored', () => { - this.showStatus('Canvas restored'); - this.resizeCanvas(); - }); - - // Add resize handle state - this.resizeHandles = { - active: false, - activeHandle: null, - size: 8, - positions: [] - }; + this.ctx = this.canvas.getContext("2d", { + desynchronized: true, // Reduce latency + alpha: false, // Optimize performance + }); + + if (!this.ctx) { + throw new Error("Could not get canvas context"); } - - // Add new method to reset view - resetView() { - const rect = this.container.getBoundingClientRect(); - const containerWidth = rect.width; - const containerHeight = rect.height; - const dpr = window.devicePixelRatio || 1; - - // Calculate scale to fit the field in the container, accounting for DPR - const scaleX = (containerWidth * dpr) / this.FIELD_WIDTH; - const scaleY = (containerHeight * dpr) / this.FIELD_HEIGHT; - this.scale = Math.min(scaleX, scaleY) * 0.95; // 95% to add a small margin - - // Center the field, accounting for DPR - this.offsetX = (containerWidth * dpr) / 2; - this.offsetY = (containerHeight * dpr) / 2; - } - - resizeCanvas() { - // Get the container's CSS dimensions - const rect = this.container.getBoundingClientRect(); - const dpr = window.devicePixelRatio || 1; - - // Set canvas dimensions to match container size, accounting for device pixel ratio - this.canvas.width = rect.width * dpr; - this.canvas.height = rect.height * dpr; - - // Set CSS size explicitly - this.canvas.style.width = `${rect.width}px`; - this.canvas.style.height = `${rect.height}px`; - - // Always reset view to ensure proper centering - this.resetView(); - - // Apply pan limits after resize - this.applyPanLimits(); - - // Redraw with updated dimensions + + this.container = options.container || document.getElementById("container"); + + // Status display handling + if (typeof options.showStatus === "function") { + // If a function is provided, use it + this.statusDisplayFn = options.showStatus; + } else { + // Otherwise, look for a DOM element + this.statusDisplay = options.statusDisplay || null; + } + + // Readonly mode + this.readonly = options.readonly || false; + + // External UI update function + this.externalUpdateUIControls = options.updateUIControls || null; + + // Field dimensions (fixed size we want to display) + this.FIELD_WIDTH = 800; + this.FIELD_HEIGHT = 400; + + // Background image + this.backgroundImage = new Image(); + this.backgroundImage.src = options.backgroundImage || ""; + this.backgroundLoaded = false; + this.backgroundImage.onload = () => { + this.backgroundLoaded = true; + this.resetView(); // Center and scale the view when image loads this.redrawCanvas(); - - return { - width: rect.width, - height: rect.height - }; + }; + + // Safety limits + this.MAX_STROKES = 10000; // Prevent memory issues + this.MIN_SCALE = 0.1; + this.MAX_SCALE = 10; + this.lastDPR = window.devicePixelRatio || 1; + + // Drawing state + this.isDrawing = false; + this.lastX = 0; + this.lastY = 0; + this.previewShape = null; // Add preview shape state + + // Pan and zoom state + this.isPanning = false; + this.startPanX = 0; + this.startPanY = 0; + this.offsetX = 0; + this.offsetY = 0; + this.scale = 1; + this.lastDistance = null; + + // Pan limits - how far from center user can pan + this.MAX_PAN_DISTANCE = options.maxPanDistance || 500; + + // Flip and Rotate state + this.flipX = false; + this.flipY = false; + this.rotation = 0; // Rotation angle in degrees (0, 90, 180, 270) + + // Grid settings + this.GRID_SPACING = options.gridSpacing || 50; + this.GRID_COLOR = options.gridColor || "#e0e0e0"; + this.AXIS_COLOR = options.axisColor || "#a0a0a0"; + + // Line style + this.currentColor = options.initialColor || "#000000"; + this.currentThickness = options.initialThickness || 3; + this.isFilled = false; // Add fill state + + // Drawing history for undo and save functionality + this.drawingHistory = []; + this.redoHistory = []; // Add redo history + this.currentStroke = []; + + // Current tool state + this.currentTool = "pen"; // pen, rectangle, circle, line, hexagon, star, arrow, select + this.startX = null; + this.startY = null; + + // Selection state + this.selectedStrokes = []; + this.selectionRect = null; + this.isSelecting = false; + this.selectedStrokesCopy = null; // For copy/cut operations + this.moveSelection = { + active: false, + startX: 0, + startY: 0, + offsetX: 0, + offsetY: 0, + }; + + // Initialize LocalForage + this.storage = localforage.createInstance({ + name: "CanvasField", + }); + + // Perfect freehand settings + this.pressure = 0.5; + this.thinning = 0.5; + this.smoothing = 0.5; + this.streamline = 0.5; + this.points = []; + + // Stroke settings + this.minWidth = 0.5; + this.maxWidth = 4; + this.lastVelocity = 0; + this.lastWidth = 0; + this.velocityFilterWeight = 0.7; + + // Auto-save interval (every 30 seconds) + this.autoSaveInterval = setInterval(() => this.autoSave(), 30000); + + // Initialize + this.resizeCanvas(); + this.bindEvents(); + + // Handle DPR changes (e.g., moving between monitors) + this.dprMediaQuery = matchMedia( + `(resolution: ${window.devicePixelRatio}dppx)`, + ); + this.dprMediaQuery.addEventListener("change", () => { + if (this.lastDPR !== (window.devicePixelRatio || 1)) { + this.lastDPR = window.devicePixelRatio || 1; + this.resizeCanvas(); + } + }); + + // Handle context loss + this.canvas.addEventListener("webglcontextlost", (e) => { + e.preventDefault(); + this.showStatus("Canvas context lost - Attempting to restore..."); + }); + + this.canvas.addEventListener("webglcontextrestored", () => { + this.showStatus("Canvas restored"); + this.resizeCanvas(); + }); + + // Add resize handle state + this.resizeHandles = { + active: false, + activeHandle: null, + size: 8, + positions: [], + }; + } + + // Add new method to reset view + resetView() { + const rect = this.container.getBoundingClientRect(); + const containerWidth = rect.width; + const containerHeight = rect.height; + const dpr = window.devicePixelRatio || 1; + + // Calculate scale to fit the field in the container, accounting for DPR + const scaleX = (containerWidth * dpr) / this.FIELD_WIDTH; + const scaleY = (containerHeight * dpr) / this.FIELD_HEIGHT; + this.scale = Math.min(scaleX, scaleY) * 0.95; // 95% to add a small margin + + // Center the field, accounting for DPR + this.offsetX = (containerWidth * dpr) / 2; + this.offsetY = (containerHeight * dpr) / 2; + } + + resizeCanvas() { + // Get the container's CSS dimensions + const rect = this.container.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + + // Set canvas dimensions to match container size, accounting for device pixel ratio + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + + // Set CSS size explicitly + this.canvas.style.width = `${rect.width}px`; + this.canvas.style.height = `${rect.height}px`; + + // Always reset view to ensure proper centering + this.resetView(); + + // Apply pan limits after resize + this.applyPanLimits(); + + // Redraw with updated dimensions + this.redrawCanvas(); + + return { + width: rect.width, + height: rect.height, + }; + } + + // Drawing functions + drawStroke(stroke, isSelected = false) { + if (!Array.isArray(stroke) || stroke.length < 2) { + return; } - - // Drawing functions - drawStroke(stroke, isSelected = false) { - if (!Array.isArray(stroke) || stroke.length < 2) { + + try { + const strokePoints = this.getStrokePoints(stroke); + if (!strokePoints || strokePoints.length < 2) { return; } - - try { - const strokePoints = this.getStrokePoints(stroke); - if (!strokePoints || strokePoints.length < 2) { - return; - } - - this.ctx.beginPath(); - this.ctx.moveTo(strokePoints[0].x, strokePoints[0].y); - - // Draw using cubic Bézier curves for smoother lines - for (let i = 0; i < strokePoints.length - 1; i++) { - const current = strokePoints[i]; - const next = strokePoints[i + 1]; - - if (!current || !next) { - continue; - } // Skip if points are invalid - - if (current.ctrl1x !== undefined) { - // Use cubic Bézier if we have control points - this.ctx.bezierCurveTo( - current.ctrl1x, current.ctrl1y, - current.ctrl2x, current.ctrl2y, - next.x, next.y - ); - } else { - // Fallback to quadratic curve - const xc = (current.x + next.x) / 2; - const yc = (current.y + next.y) / 2; - this.ctx.quadraticCurveTo(current.x, current.y, xc, yc); - } - - this.ctx.lineWidth = current.thickness || this.currentThickness; - } - - // Draw selection highlight if selected (draw it first as a background) - if (isSelected) { - this.ctx.save(); - this.ctx.strokeStyle = '#0066ff'; - // Cap the highlight thickness to a maximum of 4px more than the stroke - const highlightThickness = Math.min(stroke[0].thickness + 4, stroke[0].thickness * 1.2); - this.ctx.lineWidth = highlightThickness; - this.ctx.setLineDash([]); - this.ctx.globalAlpha = 0.3; - this.ctx.stroke(); - this.ctx.restore(); - } - - // Draw the actual stroke - this.ctx.strokeStyle = stroke[0].color || this.currentColor; - this.ctx.lineCap = 'round'; - this.ctx.lineJoin = 'round'; - this.ctx.stroke(); - - // Draw a second highlight for better visibility - if (isSelected) { - this.ctx.save(); - this.ctx.strokeStyle = '#ffffff'; - // Cap the inner highlight thickness to a maximum of 2px more than the stroke - const innerHighlightThickness = Math.min(stroke[0].thickness + 2, stroke[0].thickness * 1.1); - this.ctx.lineWidth = innerHighlightThickness; - this.ctx.setLineDash([]); - this.ctx.globalAlpha = 0.5; - this.ctx.stroke(); - this.ctx.restore(); + + this.ctx.beginPath(); + this.ctx.moveTo(strokePoints[0].x, strokePoints[0].y); + + // Draw using cubic Bézier curves for smoother lines + for (let i = 0; i < strokePoints.length - 1; i++) { + const current = strokePoints[i]; + const next = strokePoints[i + 1]; + + if (!current || !next) { + continue; + } // Skip if points are invalid + + if (current.ctrl1x !== undefined) { + // Use cubic Bézier if we have control points + this.ctx.bezierCurveTo( + current.ctrl1x, + current.ctrl1y, + current.ctrl2x, + current.ctrl2y, + next.x, + next.y, + ); + } else { + // Fallback to quadratic curve + const xc = (current.x + next.x) / 2; + const yc = (current.y + next.y) / 2; + this.ctx.quadraticCurveTo(current.x, current.y, xc, yc); } - } catch (error) { - console.warn('Error drawing stroke:', error); + + this.ctx.lineWidth = current.thickness || this.currentThickness; } - } - - // Draw coordinate grid and axes - drawGridAndAxes() { - const canvasWidth = this.canvas.width; - const canvasHeight = this.canvas.height; - - // Calculate grid boundaries based on view size and pan limit - const boundsLeft = -this.MAX_PAN_DISTANCE * 2; - const boundsRight = canvasWidth + this.MAX_PAN_DISTANCE * 2; - const boundsTop = -this.MAX_PAN_DISTANCE * 2; - const boundsBottom = canvasHeight + this.MAX_PAN_DISTANCE * 2; - - // Calculate grid start coordinates - const startX = Math.floor(boundsLeft / this.GRID_SPACING) * this.GRID_SPACING; - const startY = Math.floor(boundsTop / this.GRID_SPACING) * this.GRID_SPACING; - - // Calculate number of lines needed - const numHorizontalLines = Math.ceil((boundsBottom - boundsTop) / this.GRID_SPACING) + 1; - const numVerticalLines = Math.ceil((boundsRight - boundsLeft) / this.GRID_SPACING) + 1; - - // Save current drawing state - this.ctx.save(); - - // Set line style for grid - this.ctx.lineWidth = 1; - this.ctx.strokeStyle = this.GRID_COLOR; - - // Draw horizontal grid lines - for (let i = 0; i < numHorizontalLines; i++) { - const y = startY + i * this.GRID_SPACING; - this.ctx.beginPath(); - this.ctx.moveTo(boundsLeft, y); - this.ctx.lineTo(boundsRight, y); + + // Draw selection highlight if selected (draw it first as a background) + if (isSelected) { + this.ctx.save(); + this.ctx.strokeStyle = "#0066ff"; + // Cap the highlight thickness to a maximum of 4px more than the stroke + const highlightThickness = Math.min( + stroke[0].thickness + 4, + stroke[0].thickness * 1.2, + ); + this.ctx.lineWidth = highlightThickness; + this.ctx.setLineDash([]); + this.ctx.globalAlpha = 0.3; this.ctx.stroke(); + this.ctx.restore(); } - - // Draw vertical grid lines - for (let i = 0; i < numVerticalLines; i++) { - const x = startX + i * this.GRID_SPACING; - this.ctx.beginPath(); - this.ctx.moveTo(x, boundsTop); - this.ctx.lineTo(x, boundsBottom); + + // Draw the actual stroke + this.ctx.strokeStyle = stroke[0].color || this.currentColor; + this.ctx.lineCap = "round"; + this.ctx.lineJoin = "round"; + this.ctx.stroke(); + + // Draw a second highlight for better visibility + if (isSelected) { + this.ctx.save(); + this.ctx.strokeStyle = "#ffffff"; + // Cap the inner highlight thickness to a maximum of 2px more than the stroke + const innerHighlightThickness = Math.min( + stroke[0].thickness + 2, + stroke[0].thickness * 1.1, + ); + this.ctx.lineWidth = innerHighlightThickness; + this.ctx.setLineDash([]); + this.ctx.globalAlpha = 0.5; this.ctx.stroke(); + this.ctx.restore(); } - - // Draw coordinate axes - this.ctx.strokeStyle = this.AXIS_COLOR; - this.ctx.lineWidth = 2; - - // X-axis + } catch (error) { + console.warn("Error drawing stroke:", error); + } + } + + // Draw coordinate grid and axes + drawGridAndAxes() { + const canvasWidth = this.canvas.width; + const canvasHeight = this.canvas.height; + + // Calculate grid boundaries based on view size and pan limit + const boundsLeft = -this.MAX_PAN_DISTANCE * 2; + const boundsRight = canvasWidth + this.MAX_PAN_DISTANCE * 2; + const boundsTop = -this.MAX_PAN_DISTANCE * 2; + const boundsBottom = canvasHeight + this.MAX_PAN_DISTANCE * 2; + + // Calculate grid start coordinates + const startX = + Math.floor(boundsLeft / this.GRID_SPACING) * this.GRID_SPACING; + const startY = + Math.floor(boundsTop / this.GRID_SPACING) * this.GRID_SPACING; + + // Calculate number of lines needed + const numHorizontalLines = + Math.ceil((boundsBottom - boundsTop) / this.GRID_SPACING) + 1; + const numVerticalLines = + Math.ceil((boundsRight - boundsLeft) / this.GRID_SPACING) + 1; + + // Save current drawing state + this.ctx.save(); + + // Set line style for grid + this.ctx.lineWidth = 1; + this.ctx.strokeStyle = this.GRID_COLOR; + + // Draw horizontal grid lines + for (let i = 0; i < numHorizontalLines; i++) { + const y = startY + i * this.GRID_SPACING; this.ctx.beginPath(); - this.ctx.moveTo(boundsLeft, 0); - this.ctx.lineTo(boundsRight, 0); + this.ctx.moveTo(boundsLeft, y); + this.ctx.lineTo(boundsRight, y); this.ctx.stroke(); - - // Y-axis + } + + // Draw vertical grid lines + for (let i = 0; i < numVerticalLines; i++) { + const x = startX + i * this.GRID_SPACING; this.ctx.beginPath(); - this.ctx.moveTo(0, boundsTop); - this.ctx.lineTo(0, boundsBottom); + this.ctx.moveTo(x, boundsTop); + this.ctx.lineTo(x, boundsBottom); this.ctx.stroke(); - - // Draw axis labels - this.ctx.fillStyle = this.AXIS_COLOR; - this.ctx.font = '12px Arial'; - this.ctx.textAlign = 'center'; - this.ctx.textBaseline = 'top'; - - // X-axis labels - for (let x = this.GRID_SPACING; x <= boundsRight; x += this.GRID_SPACING) { - this.ctx.fillText(x.toString(), x, 5); - if (x !== 0) { - this.ctx.fillText((-x).toString(), -x, 5); - } - } - - // Y-axis labels - this.ctx.textAlign = 'right'; - this.ctx.textBaseline = 'middle'; - for (let y = this.GRID_SPACING; y <= boundsBottom; y += this.GRID_SPACING) { - this.ctx.fillText(y.toString(), -5, y); - if (y !== 0) { - this.ctx.fillText((-y).toString(), -5, -y); - } + } + + // Draw coordinate axes + this.ctx.strokeStyle = this.AXIS_COLOR; + this.ctx.lineWidth = 2; + + // X-axis + this.ctx.beginPath(); + this.ctx.moveTo(boundsLeft, 0); + this.ctx.lineTo(boundsRight, 0); + this.ctx.stroke(); + + // Y-axis + this.ctx.beginPath(); + this.ctx.moveTo(0, boundsTop); + this.ctx.lineTo(0, boundsBottom); + this.ctx.stroke(); + + // Draw axis labels + this.ctx.fillStyle = this.AXIS_COLOR; + this.ctx.font = "12px Arial"; + this.ctx.textAlign = "center"; + this.ctx.textBaseline = "top"; + + // X-axis labels + for (let x = this.GRID_SPACING; x <= boundsRight; x += this.GRID_SPACING) { + this.ctx.fillText(x.toString(), x, 5); + if (x !== 0) { + this.ctx.fillText((-x).toString(), -x, 5); } - - // Draw origin label - this.ctx.textAlign = 'right'; - this.ctx.textBaseline = 'top'; - this.ctx.fillText('0', -5, 5); - - // Restore drawing state - this.ctx.restore(); } - - // Apply pan limits to prevent going too far from center - applyPanLimits() { - const centerX = this.canvas.width / 2; - const centerY = this.canvas.height / 2; - - // Calculate distance from center - const dx = this.offsetX - centerX; - const dy = this.offsetY - centerY; - const distance = Math.sqrt(dx * dx + dy * dy); - - // If beyond limit, scale back - if (distance > this.MAX_PAN_DISTANCE) { - const ratio = this.MAX_PAN_DISTANCE / distance; - this.offsetX = centerX + dx * ratio; - this.offsetY = centerY + dy * ratio; + + // Y-axis labels + this.ctx.textAlign = "right"; + this.ctx.textBaseline = "middle"; + for (let y = this.GRID_SPACING; y <= boundsBottom; y += this.GRID_SPACING) { + this.ctx.fillText(y.toString(), -5, y); + if (y !== 0) { + this.ctx.fillText((-y).toString(), -5, -y); } } - - redrawCanvas() { - // Ensure canvas context is valid + + // Draw origin label + this.ctx.textAlign = "right"; + this.ctx.textBaseline = "top"; + this.ctx.fillText("0", -5, 5); + + // Restore drawing state + this.ctx.restore(); + } + + // Apply pan limits to prevent going too far from center + applyPanLimits() { + const centerX = this.canvas.width / 2; + const centerY = this.canvas.height / 2; + + // Calculate distance from center + const dx = this.offsetX - centerX; + const dy = this.offsetY - centerY; + const distance = Math.sqrt(dx * dx + dy * dy); + + // If beyond limit, scale back + if (distance > this.MAX_PAN_DISTANCE) { + const ratio = this.MAX_PAN_DISTANCE / distance; + this.offsetX = centerX + dx * ratio; + this.offsetY = centerY + dy * ratio; + } + } + + redrawCanvas() { + // Ensure canvas context is valid + if (!this.ctx) { + this.ctx = this.canvas.getContext("2d", { + desynchronized: true, + alpha: false, + }); if (!this.ctx) { - this.ctx = this.canvas.getContext('2d', { - desynchronized: true, - alpha: false - }); - if (!this.ctx) { - this.showStatus('Error: Could not restore canvas context'); - return; - } - } - - this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform - - // Set white background - this.ctx.fillStyle = '#ffffff'; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); - - // Apply transformations with safety checks - const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; - const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; - this.ctx.translate(safeOffsetX, safeOffsetY); - - // Apply scale for drawing with safety check - const safeScale = Math.min(Math.max(this.scale, this.MIN_SCALE), this.MAX_SCALE); - if (this.scale !== safeScale) { - this.scale = safeScale; - this.showStatus(`Scale limited to ${this.scale.toFixed(2)}`); - } - this.ctx.scale(this.scale, this.scale); - - // Draw the field image first if loaded - if (this.backgroundLoaded) { - const x = -this.FIELD_WIDTH / 2; - const y = -this.FIELD_HEIGHT / 2; - this.ctx.drawImage(this.backgroundImage, x, y, this.FIELD_WIDTH, this.FIELD_HEIGHT); - } - - // Draw all strokes from history with length limit - if (this.drawingHistory.length > this.MAX_STROKES) { - this.drawingHistory = this.drawingHistory.slice(-this.MAX_STROKES); - this.showStatus(`Drawing history limited to ${this.MAX_STROKES} strokes`); + this.showStatus("Error: Could not restore canvas context"); + return; } - - // Draw non-selected strokes normally - this.drawingHistory.forEach((stroke, index) => { - if (!this.selectedStrokes.includes(index)) { - try { - if (Array.isArray(stroke) && stroke[0].type) { - // It's a shape - this.drawShape(stroke[0]); - } else { - // It's a freehand stroke - this.drawStroke(stroke); - } - } catch (error) { - console.error('Error drawing stroke:', error); - } - } - }); - - // Draw selected strokes with highlight - this.ctx.save(); - this.ctx.strokeStyle = '#0066ff'; - this.ctx.lineWidth = 2; - this.selectedStrokes.forEach(index => { - const stroke = this.drawingHistory[index]; + } + + this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Reset transform + + // Set white background + this.ctx.fillStyle = "#ffffff"; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + // Apply transformations with safety checks + const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; + const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; + this.ctx.translate(safeOffsetX, safeOffsetY); + + // Apply scale for drawing with safety check + const safeScale = Math.min( + Math.max(this.scale, this.MIN_SCALE), + this.MAX_SCALE, + ); + if (this.scale !== safeScale) { + this.scale = safeScale; + this.showStatus(`Scale limited to ${this.scale.toFixed(2)}`); + } + this.ctx.scale(this.scale, this.scale); + + // Apply flip transformations + if (this.flipX) { + this.ctx.scale(-1, 1); + } + if (this.flipY) { + this.ctx.scale(1, -1); + } + + // Apply rotation + if (this.rotation !== 0) { + const rotationRadians = (this.rotation * Math.PI) / 180; + this.ctx.rotate(rotationRadians); + } + + // Draw the field image first if loaded + if (this.backgroundLoaded) { + const x = -this.FIELD_WIDTH / 2; + const y = -this.FIELD_HEIGHT / 2; + this.ctx.drawImage( + this.backgroundImage, + x, + y, + this.FIELD_WIDTH, + this.FIELD_HEIGHT, + ); + } + + // Draw all strokes from history with length limit + if (this.drawingHistory.length > this.MAX_STROKES) { + this.drawingHistory = this.drawingHistory.slice(-this.MAX_STROKES); + this.showStatus(`Drawing history limited to ${this.MAX_STROKES} strokes`); + } + + // Draw non-selected strokes normally + this.drawingHistory.forEach((stroke, index) => { + if (!this.selectedStrokes.includes(index)) { try { if (Array.isArray(stroke) && stroke[0].type) { // It's a shape - this.drawShape(stroke[0], false, true); + this.drawShape(stroke[0]); } else { // It's a freehand stroke - this.drawStroke(stroke, true); + this.drawStroke(stroke); } } catch (error) { - console.error('Error drawing selected stroke:', error); - } - }); - this.ctx.restore(); - - // Draw current stroke if active - if (this.currentStroke.length > 0) { - try { - this.drawStroke(this.currentStroke); - } catch (error) { - console.error('Error drawing current stroke:', error); + console.error("Error drawing stroke:", error); } } - - // Draw preview shape if exists - if (this.previewShape) { - try { - this.drawShape(this.previewShape, true); - } catch (error) { - console.error('Error drawing preview shape:', error); + }); + + // Draw selected strokes with highlight + this.ctx.save(); + this.ctx.strokeStyle = "#0066ff"; + this.ctx.lineWidth = 2; + this.selectedStrokes.forEach((index) => { + const stroke = this.drawingHistory[index]; + try { + if (Array.isArray(stroke) && stroke[0].type) { + // It's a shape + this.drawShape(stroke[0], false, true); + } else { + // It's a freehand stroke + this.drawStroke(stroke, true); } + } catch (error) { + console.error("Error drawing selected stroke:", error); } - - // Draw selection rectangle if selecting - if (this.selectionRect && this.currentTool === 'select') { - this.ctx.save(); - this.ctx.strokeStyle = '#0066ff'; - this.ctx.lineWidth = 1; - this.ctx.setLineDash([5, 5]); - this.ctx.strokeRect( - this.selectionRect.x, - this.selectionRect.y, - this.selectionRect.width, - this.selectionRect.height - ); - this.ctx.restore(); - } - - // After drawing selection rectangle, add resize handles - if (this.selectionRect && this.currentTool === 'select' && !this.isSelecting) { - this.drawResizeHandles(); + }); + this.ctx.restore(); + + // Draw current stroke if active + if (this.currentStroke.length > 0) { + try { + this.drawStroke(this.currentStroke); + } catch (error) { + console.error("Error drawing current stroke:", error); } } - - getTransformedPosition(clientX, clientY) { - // Safety check for input values - if (!isFinite(clientX) || !isFinite(clientY)) { - return { x: 0, y: 0 }; + + // Draw preview shape if exists + if (this.previewShape) { + try { + this.drawShape(this.previewShape, true); + } catch (error) { + console.error("Error drawing preview shape:", error); } - - // Get the canvas bounds - const rect = this.canvas.getBoundingClientRect(); - - // Calculate the scale between CSS pixels and canvas pixels - const cssScale = rect.width / this.canvas.width; - - // Convert screen coordinates to canvas coordinates, accounting for CSS scaling - const canvasX = (clientX - rect.left) / cssScale; - const canvasY = (clientY - rect.top) / cssScale; - - // Apply the transformation for pan and zoom with safety checks - const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; - const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; - const safeScale = Math.min(Math.max(this.scale, this.MIN_SCALE), this.MAX_SCALE); - - const x = (canvasX - safeOffsetX) / safeScale; - const y = (canvasY - safeOffsetY) / safeScale; - - // Ensure returned coordinates are finite - return { - x: isFinite(x) ? x : 0, - y: isFinite(y) ? y : 0 - }; } - - // Status message - showStatus(message) { - // If we have a status display function from options, use it - if (typeof this.statusDisplayFn === 'function') { - this.statusDisplayFn(message); - return; - } - - // If statusDisplay element exists, update it - if (this.statusDisplay && typeof this.statusDisplay.textContent !== 'undefined') { - this.statusDisplay.textContent = message; - // Clear the message after 3 seconds - setTimeout(() => { - this.statusDisplay.textContent = ''; - }, 3000); - } else { - // Just log to console if no status display is available - console.log(message); - } + + // Draw selection rectangle if selecting + if (this.selectionRect && this.currentTool === "select") { + this.ctx.save(); + this.ctx.strokeStyle = "#0066ff"; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([5, 5]); + this.ctx.strokeRect( + this.selectionRect.x, + this.selectionRect.y, + this.selectionRect.width, + this.selectionRect.height, + ); + this.ctx.restore(); } - - // Action methods - undo() { - if (this.drawingHistory.length === 0) { - this.showStatus('Nothing to undo'); - return false; - } - - const lastOp = this.drawingHistory.pop(); - - if (lastOp.type === 'colorChange') { - // Reverse the color change - lastOp.strokes.forEach(({index, oldColor}) => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - stroke.forEach(point => point.color = oldColor); - } else if (stroke[0]?.type) { - stroke[0].color = oldColor; - } - }); - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else if (lastOp.type === 'thicknessChange') { - // Reverse the thickness change - lastOp.strokes.forEach(({index, oldThickness}) => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - stroke.forEach(point => point.thickness = oldThickness); - } else if (stroke[0]?.type) { - stroke[0].thickness = oldThickness; - } - }); - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else if (lastOp.type === 'fillChange') { - // Reverse the fill change - lastOp.strokes.forEach(({index, oldFill}) => { - const stroke = this.drawingHistory[index]; - if (stroke[0]?.type) { - stroke[0].isFilled = oldFill; - } - }); - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else if (lastOp.type === 'move') { - // Reverse the move - for (const index of lastOp.strokes) { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - stroke.forEach(point => { - point.x -= lastOp.dx; - point.y -= lastOp.dy; - }); - } else if (stroke[0]?.type) { - stroke[0].x -= lastOp.dx; - stroke[0].y -= lastOp.dy; - } - } - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else if (lastOp.type === 'delete') { - // Restore deleted strokes - lastOp.strokes.reverse().forEach(({index, stroke}) => { - this.drawingHistory.splice(index, 0, stroke); - }); - this.selectedStrokes = lastOp.strokes.map(s => s.index); - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else if (lastOp.type === 'paste') { - // Remove pasted strokes - const indices = lastOp.newStrokes.map(s => s.index).sort((a, b) => b - a); - indices.forEach(index => { - this.drawingHistory.splice(index, 1); - }); - this.selectedStrokes = []; - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } else { - // Regular stroke or shape - this.redoHistory.push(lastOp); - this.redrawCanvas(); - } - - this.showStatus('Undo successful'); - return true; + + // After drawing selection rectangle, add resize handles + if ( + this.selectionRect && + this.currentTool === "select" && + !this.isSelecting + ) { + this.drawResizeHandles(); } - - redo() { - if (this.redoHistory.length === 0) { - this.showStatus('Nothing to redo'); - return false; - } - - const nextOp = this.redoHistory.pop(); - - if (nextOp.type === 'move') { - // Reapply the move - for (const index of nextOp.strokes) { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - stroke.forEach(point => { - point.x += nextOp.dx; - point.y += nextOp.dy; - }); - } else if (stroke[0]?.type) { - stroke[0].x += nextOp.dx; - stroke[0].y += nextOp.dy; - } - } - this.drawingHistory.push(nextOp); - this.redrawCanvas(); - } else if (nextOp.type === 'delete') { - // Reapply deletion - const sortedIndices = nextOp.strokes.map(s => s.index).sort((a, b) => b - a); - sortedIndices.forEach(index => { - this.drawingHistory.splice(index, 1); - }); - this.selectedStrokes = []; - this.drawingHistory.push(nextOp); - this.redrawCanvas(); - } else if (nextOp.type === 'paste') { - // Restore pasted strokes - nextOp.newStrokes.forEach(({index, stroke}) => { - this.drawingHistory.splice(index, 0, stroke); - }); - this.selectedStrokes = nextOp.newStrokes.map(s => s.index); - this.drawingHistory.push(nextOp); - this.redrawCanvas(); - } else { - // Regular stroke or shape - this.drawingHistory.push(nextOp); - this.redrawCanvas(); - } - - this.showStatus('Redo successful'); - return true; + } + + getTransformedPosition(clientX, clientY) { + // Safety check for input values + if (!isFinite(clientX) || !isFinite(clientY)) { + return { x: 0, y: 0 }; } - - setTool(tool) { - if (this.readonly) { - this.showStatus('Cannot change tool in read-only mode'); - return; - } - this.currentTool = tool; - this.selectedStrokes = []; - this.selectionRect = null; - this.canvas.style.cursor = 'default'; + + // Get the canvas bounds + const rect = this.canvas.getBoundingClientRect(); + + // Calculate the scale between CSS pixels and canvas pixels + const cssScale = rect.width / this.canvas.width; + + // Convert screen coordinates to canvas coordinates, accounting for CSS scaling + const canvasX = (clientX - rect.left) / cssScale; + const canvasY = (clientY - rect.top) / cssScale; + + // Apply the transformation for pan and zoom with safety checks + const safeOffsetX = isFinite(this.offsetX) ? this.offsetX : 0; + const safeOffsetY = isFinite(this.offsetY) ? this.offsetY : 0; + const safeScale = Math.min( + Math.max(this.scale, this.MIN_SCALE), + this.MAX_SCALE, + ); + + let x = (canvasX - safeOffsetX) / safeScale; + let y = (canvasY - safeOffsetY) / safeScale; + + // The forward transform in redrawCanvas is: + // translate(offset) -> scale(zoom) -> flip -> rotate + // Which means a drawing-space point p maps to screen as: + // screen = T(S(Flip(R(p)))) + // After undoing T and S above, the remaining value is Flip(R(p)). + // To recover point we need: p = R⁻¹(Flip⁻¹(value)) + // So: apply inverse-flip first, then inverse-rotation. + + // 1. Apply inverse flip (flip is its own inverse) + if (this.flipX) { + x = -x; } - - setFill(filled) { - if (this.readonly) { - this.showStatus('Cannot change fill in read-only mode'); - return; - } - this.isFilled = filled; - // Update fill of selected shapes - if (this.selectedStrokes.length > 0) { - // Create fill change operation - const fillOp = { - type: 'fillChange', - strokes: this.selectedStrokes.map(index => ({ - index, - oldFill: this.drawingHistory[index][0].isFilled, - newFill: filled - })) - }; - - // Update fills - this.selectedStrokes.forEach(index => { + if (this.flipY) { + y = -y; + } + + // 2. Apply inverse rotation + if (this.rotation !== 0) { + const rotationRadians = (-this.rotation * Math.PI) / 180; + const cos = Math.cos(rotationRadians); + const sin = Math.sin(rotationRadians); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + x = rx; + y = ry; + } + + // Ensure returned coordinates are finite + return { + x: isFinite(x) ? x : 0, + y: isFinite(y) ? y : 0, + }; + } + + // Status message + showStatus(message) { + // If we have a status display function from options, use it + if (typeof this.statusDisplayFn === "function") { + this.statusDisplayFn(message); + return; + } + + // If statusDisplay element exists, update it + if ( + this.statusDisplay && + typeof this.statusDisplay.textContent !== "undefined" + ) { + this.statusDisplay.textContent = message; + // Clear the message after 3 seconds + setTimeout(() => { + this.statusDisplay.textContent = ""; + }, 3000); + } else { + // Just log to console if no status display is available + console.log(message); + } + } + + // Action methods + undo() { + if (this.drawingHistory.length === 0) { + this.showStatus("Nothing to undo"); + return false; + } + + const lastOp = this.drawingHistory.pop(); + + if (lastOp.type === "colorChange") { + // Reverse the color change + lastOp.strokes.forEach(({ index, oldColor }) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + stroke.forEach((point) => (point.color = oldColor)); + } else if (stroke[0]?.type) { + stroke[0].color = oldColor; + } + }); + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else if (lastOp.type === "thicknessChange") { + // Reverse the thickness change + lastOp.strokes.forEach(({ index, oldThickness }) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + stroke.forEach((point) => (point.thickness = oldThickness)); + } else if (stroke[0]?.type) { + stroke[0].thickness = oldThickness; + } + }); + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else if (lastOp.type === "fillChange") { + // Reverse the fill change + lastOp.strokes.forEach(({ index, oldFill }) => { + const stroke = this.drawingHistory[index]; + if (stroke[0]?.type) { + stroke[0].isFilled = oldFill; + } + }); + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else if (lastOp.type === "move") { + // Reverse the move + for (const index of lastOp.strokes) { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + stroke.forEach((point) => { + point.x -= lastOp.dx; + point.y -= lastOp.dy; + }); + } else if (stroke[0]?.type) { + stroke[0].x -= lastOp.dx; + stroke[0].y -= lastOp.dy; + } + } + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else if (lastOp.type === "delete") { + // Restore deleted strokes + lastOp.strokes.reverse().forEach(({ index, stroke }) => { + this.drawingHistory.splice(index, 0, stroke); + }); + this.selectedStrokes = lastOp.strokes.map((s) => s.index); + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else if (lastOp.type === "paste") { + // Remove pasted strokes + const indices = lastOp.newStrokes + .map((s) => s.index) + .sort((a, b) => b - a); + indices.forEach((index) => { + this.drawingHistory.splice(index, 1); + }); + this.selectedStrokes = []; + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } else { + // Regular stroke or shape + this.redoHistory.push(lastOp); + this.redrawCanvas(); + } + + this.showStatus("Undo successful"); + return true; + } + + redo() { + if (this.redoHistory.length === 0) { + this.showStatus("Nothing to redo"); + return false; + } + + const nextOp = this.redoHistory.pop(); + + if (nextOp.type === "move") { + // Reapply the move + for (const index of nextOp.strokes) { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + stroke.forEach((point) => { + point.x += nextOp.dx; + point.y += nextOp.dy; + }); + } else if (stroke[0]?.type) { + stroke[0].x += nextOp.dx; + stroke[0].y += nextOp.dy; + } + } + this.drawingHistory.push(nextOp); + this.redrawCanvas(); + } else if (nextOp.type === "delete") { + // Reapply deletion + const sortedIndices = nextOp.strokes + .map((s) => s.index) + .sort((a, b) => b - a); + sortedIndices.forEach((index) => { + this.drawingHistory.splice(index, 1); + }); + this.selectedStrokes = []; + this.drawingHistory.push(nextOp); + this.redrawCanvas(); + } else if (nextOp.type === "paste") { + // Restore pasted strokes + nextOp.newStrokes.forEach(({ index, stroke }) => { + this.drawingHistory.splice(index, 0, stroke); + }); + this.selectedStrokes = nextOp.newStrokes.map((s) => s.index); + this.drawingHistory.push(nextOp); + this.redrawCanvas(); + } else { + // Regular stroke or shape + this.drawingHistory.push(nextOp); + this.redrawCanvas(); + } + + this.showStatus("Redo successful"); + return true; + } + + setTool(tool) { + if (this.readonly) { + this.showStatus("Cannot change tool in read-only mode"); + return; + } + this.currentTool = tool; + this.selectedStrokes = []; + this.selectionRect = null; + this.canvas.style.cursor = "default"; + } + + setFill(filled) { + if (this.readonly) { + this.showStatus("Cannot change fill in read-only mode"); + return; + } + this.isFilled = filled; + // Update fill of selected shapes + if (this.selectedStrokes.length > 0) { + // Create fill change operation + const fillOp = { + type: "fillChange", + strokes: this.selectedStrokes.map((index) => ({ + index, + oldFill: this.drawingHistory[index][0].isFilled, + newFill: filled, + })), + }; + + // Update fills + this.selectedStrokes.forEach((index) => { + const stroke = this.drawingHistory[index]; + if (stroke[0]?.type) { + // Only update shapes, not freehand strokes + stroke[0].isFilled = filled; + } + }); + + // Add operation to history + this.drawingHistory.push(fillOp); + this.redoHistory = []; + this.redrawCanvas(); + this.showStatus("Fill updated for selection"); + } + } + + setColor(color) { + if (this.readonly) { + this.showStatus("Cannot change color in read-only mode"); + return; + } + this.currentColor = color; + // Immediately update selected strokes when color changes + if (this.selectedStrokes.length > 0) { + // Create color change operation + const colorOp = { + type: "colorChange", + strokes: this.selectedStrokes.map((index) => { const stroke = this.drawingHistory[index]; - if (stroke[0]?.type) { // Only update shapes, not freehand strokes - stroke[0].isFilled = filled; + let oldColor; + if (Array.isArray(stroke) && !stroke[0]?.type) { + // For freehand strokes + oldColor = stroke[0].color; + stroke.forEach((point) => (point.color = color)); + } else if (stroke[0]?.type) { + // For shapes + oldColor = stroke[0].color; + stroke[0].color = color; } - }); - - // Add operation to history - this.drawingHistory.push(fillOp); - this.redoHistory = []; - this.redrawCanvas(); - this.showStatus('Fill updated for selection'); + return { index, oldColor }; + }), + }; + + // Add operation to history + this.drawingHistory.push(colorOp); + this.redoHistory = []; + this.redrawCanvas(); + this.showStatus("Color updated for selection"); + } + } + + setThickness(thickness) { + if (this.readonly) { + this.showStatus("Cannot change thickness in read-only mode"); + return; + } + this.currentThickness = parseInt(thickness); + // Update thickness of selected strokes/shapes + if (this.selectedStrokes.length > 0) { + // Create thickness change operation + const thicknessOp = { + type: "thicknessChange", + strokes: this.selectedStrokes.map((index) => ({ + index, + oldThickness: this.drawingHistory[index][0].thickness, + newThickness: thickness, + })), + }; + + // Update thicknesses + this.selectedStrokes.forEach((index) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + stroke.forEach((point) => (point.thickness = thickness)); + } else if (stroke[0]?.type) { + stroke[0].thickness = thickness; + } + }); + + // Add operation to history + this.drawingHistory.push(thicknessOp); + this.redoHistory = []; + this.redrawCanvas(); + this.showStatus("Thickness updated for selection"); + } + } + + drawShape(shape, isPreview = false, isSelected = false) { + const { ctx } = this; + + // Draw selection highlight background if selected + if (isSelected) { + ctx.save(); + ctx.strokeStyle = "#0066ff"; + // Cap the highlight thickness to a maximum of 4px more than the shape thickness + const highlightThickness = Math.min( + shape.thickness + 4, + shape.thickness * 1.2, + ); + ctx.lineWidth = highlightThickness; + ctx.setLineDash([]); + ctx.globalAlpha = 0.3; + this.drawShapePath(shape); + ctx.stroke(); + ctx.restore(); + } + + // Draw the main shape + ctx.beginPath(); + ctx.strokeStyle = shape.color; + ctx.fillStyle = shape.color; + ctx.lineWidth = shape.thickness; + + // Save the current context state + ctx.save(); + + // If it's a preview, use dashed line and lighter color + if (isPreview) { + ctx.setLineDash([5, 5]); + ctx.globalAlpha = shape.isFilled ? 0.3 : 0.6; + } + + this.drawShapePath(shape); + + if (shape.isFilled) { + ctx.fill(); + } + ctx.stroke(); + + // Draw white highlight for better visibility when selected + if (isSelected) { + ctx.strokeStyle = "#ffffff"; + // Cap the inner highlight thickness to a maximum of 2px more than the shape thickness + const innerHighlightThickness = Math.min( + shape.thickness + 2, + shape.thickness * 1.1, + ); + ctx.lineWidth = innerHighlightThickness; + ctx.setLineDash([]); + ctx.globalAlpha = 0.5; + ctx.stroke(); + } + + // Restore the context state + ctx.restore(); + } + + clear() { + this.drawingHistory = []; + this.redrawCanvas(); + console.log("Canvas cleared"); + } + + // Save current drawing state to JSON + saveToJSON() { + const saveData = { + version: 1, + timestamp: new Date().toISOString(), + scale: this.scale, + offsetX: this.offsetX, + offsetY: this.offsetY, + flipX: this.flipX, + flipY: this.flipY, + rotation: this.rotation, + canvasWidth: this.canvas.width, + canvasHeight: this.canvas.height, + strokes: this.drawingHistory, + }; + + return JSON.stringify(saveData); + } + + // Load drawing from JSON string + loadFromJSON(jsonString) { + try { + const jsonData = JSON.parse(jsonString); + + // Check version compatibility + if (jsonData.version !== 1) { + throw new Error("Unsupported file version"); } + + // Load the strokes + this.drawingHistory = jsonData.strokes || []; + + // Load flip and rotation state + this.flipX = jsonData.flipX || false; + this.flipY = jsonData.flipY || false; + this.rotation = jsonData.rotation || 0; + + // Check if we have canvas dimensions stored + if (jsonData.canvasWidth && jsonData.canvasHeight) { + // Calculate scale factors for width and height differences + const widthRatio = this.canvas.width / jsonData.canvasWidth; + const heightRatio = this.canvas.height / jsonData.canvasHeight; + + // Adjust offset to maintain relative position + this.offsetX = jsonData.offsetX * widthRatio; + this.offsetY = jsonData.offsetY * heightRatio; + + // Set the scale (using original scale or adjusted) + this.scale = jsonData.scale || 1; + + this.showStatus("Drawing loaded with size adjustment"); + } else { + // Use the saved values directly if no canvas dimensions were stored + this.scale = jsonData.scale || 1; + this.offsetX = jsonData.offsetX || 0; + this.offsetY = jsonData.offsetY || 0; + + this.showStatus("Drawing loaded successfully"); + } + + // Apply pan limits to loaded state + this.applyPanLimits(); + + // Redraw + this.redrawCanvas(); + + return true; + } catch (error) { + this.showStatus("Error loading file: " + error.message); + console.error("Error loading file:", error); + return false; } - - setColor(color) { - if (this.readonly) { - this.showStatus('Cannot change color in read-only mode'); + } + + // Event binding + bindEvents() { + // Resize handler + window.addEventListener("resize", () => this.resizeCanvas()); + + // Mouse wheel for zooming + this.container.addEventListener("wheel", (e) => { + e.preventDefault(); + + const mouseX = e.clientX; + const mouseY = e.clientY; + + // Calculate position before zoom + const pointBeforeZoomX = (mouseX - this.offsetX) / this.scale; + const pointBeforeZoomY = (mouseY - this.offsetY) / this.scale; + + // Adjust zoom level + const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; + this.scale *= zoomFactor; + + // Limit zoom + this.scale = Math.min(Math.max(0.1, this.scale), 10); + + // Calculate position after zoom + const pointAfterZoomX = (mouseX - this.offsetX) / this.scale; + const pointAfterZoomY = (mouseY - this.offsetY) / this.scale; + + // Adjust offset to keep mouse position fixed + this.offsetX += (pointAfterZoomX - pointBeforeZoomX) * this.scale; + this.offsetY += (pointAfterZoomY - pointBeforeZoomY) * this.scale; + + // Apply pan limits + this.applyPanLimits(); + + // Redraw + this.redrawCanvas(); + + // Show zoom level + // this.showStatus(`Zoom: ${Math.round(this.scale * 100)}%`); + }); + + // Mouse events for drawing and panning + this.canvas.addEventListener("mousedown", (e) => { + if (this.readonly && !e.shiftKey && e.button !== 1) { + return; + } // Only allow panning in readonly mode + + this.lastEvent = e; + if (e.shiftKey || e.button === 1) { + this.isPanning = true; + this.startPanX = e.clientX - this.offsetX; + this.startPanY = e.clientY - this.offsetY; + this.canvas.style.cursor = "grabbing"; + } else if (!this.readonly) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + this.startX = pos.x; + this.startY = pos.y; + + if (this.currentTool === "select") { + // First check if clicking on a resize handle + const handle = this.getResizeHandleAtPoint(pos); + if (handle) { + this.resizeHandles.active = true; + this.resizeHandles.activeHandle = handle; + this.canvas.style.cursor = handle.cursor; + return; + } + + // Then check if clicking inside selection area + if (this.selectedStrokes.length > 0 && this.selectionRect) { + const clickPoint = { x: pos.x, y: pos.y }; + if (this.isPointInRect(clickPoint, this.selectionRect)) { + // Start moving the selection + this.moveSelection.active = true; + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + this.canvas.style.cursor = "move"; + return; + } + } + + // Check if clicking on any stroke + const clickedPoint = { x: pos.x, y: pos.y }; + let clickedIndex = -1; + + // Check selected strokes first + for (const index of this.selectedStrokes) { + if ( + this.isPointInStroke(clickedPoint, this.drawingHistory[index]) + ) { + clickedIndex = index; + break; + } + } + + // If not clicking on selected stroke, check others + if (clickedIndex === -1) { + for (let i = this.drawingHistory.length - 1; i >= 0; i--) { + if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { + clickedIndex = i; + break; + } + } + } + + if (clickedIndex >= 0) { + // Clicked on a stroke + if ( + !e.ctrlKey && + !e.metaKey && + !this.selectedStrokes.includes(clickedIndex) + ) { + // New selection if not holding Ctrl/Cmd + this.selectedStrokes = [clickedIndex]; + } else if ( + (e.ctrlKey || e.metaKey) && + this.selectedStrokes.includes(clickedIndex) + ) { + // Deselect if holding Ctrl/Cmd and clicking on selected stroke + this.selectedStrokes = this.selectedStrokes.filter( + (i) => i !== clickedIndex, + ); + } else if (e.ctrlKey || e.metaKey) { + // Add to selection if holding Ctrl/Cmd + this.selectedStrokes.push(clickedIndex); + } + + // Start move operation if clicked on selected stroke + if (this.selectedStrokes.includes(clickedIndex)) { + this.moveSelection.active = true; + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + this.canvas.style.cursor = "move"; + } + } else if (!e.ctrlKey && !e.metaKey) { + // Start selection rectangle if not clicking on any stroke and not holding modifier keys + this.isSelecting = true; + this.selectionRect = { + x: pos.x, + y: pos.y, + width: 0, + height: 0, + }; + this.selectedStrokes = []; + } + + this.redrawCanvas(); + } else if (this.currentTool === "pen") { + this.isDrawing = true; + this.currentStroke = [ + { + x: pos.x, + y: pos.y, + color: this.currentColor, + thickness: this.currentThickness, + }, + ]; + } + } + }); + + this.canvas.addEventListener("mousemove", (e) => { + if (this.readonly && !this.isPanning) { return; + } // Only handle panning in readonly mode + + // Update cursor based on resize handles when in select mode + if ( + this.currentTool === "select" && + !this.isSelecting && + !this.moveSelection.active && + !this.resizeHandles.active + ) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + const handle = this.getResizeHandleAtPoint(pos); + this.canvas.style.cursor = handle ? handle.cursor : "default"; } - this.currentColor = color; - // Immediately update selected strokes when color changes - if (this.selectedStrokes.length > 0) { - // Create color change operation - const colorOp = { - type: 'colorChange', - strokes: this.selectedStrokes.map(index => { + + if (this.isPanning) { + this.offsetX = e.clientX - this.startPanX; + this.offsetY = e.clientY - this.startPanY; + this.applyPanLimits(); + this.redrawCanvas(); + } else if (this.resizeHandles.active) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + const dx = pos.x - this.startX; + const dy = pos.y - this.startY; + + // Resize the shapes + this.resizeSelectedShapes(this.resizeHandles.activeHandle, dx, dy); + + this.startX = pos.x; + this.startY = pos.y; + this.redrawCanvas(); + } else if (this.isDrawing && this.currentTool === "pen") { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + + // Calculate velocity for pressure + const velocity = Math.sqrt( + Math.pow(pos.x - this.lastX, 2) + Math.pow(pos.y - this.lastY, 2), + ); + + // Smooth velocity + this.lastVelocity = this.lastVelocity + ? this.lastVelocity * this.velocityFilterWeight + + velocity * (1 - this.velocityFilterWeight) + : velocity; + + // Add point with pressure data + this.currentStroke.push({ + x: pos.x, + y: pos.y, + color: this.currentColor, + pressure: Math.max(0.1, 1 - this.lastVelocity / 4), + thickness: this.currentThickness, + }); + + this.redrawCanvas(); + + this.lastX = pos.x; + this.lastY = pos.y; + } else if (this.isSelecting) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + this.selectionRect.width = pos.x - this.selectionRect.x; + this.selectionRect.height = pos.y - this.selectionRect.y; + this.redrawCanvas(); + } else if (this.moveSelection.active) { + const pos = this.getTransformedPosition(e.clientX, e.clientY); + const dx = pos.x - this.moveSelection.startX; + const dy = pos.y - this.moveSelection.startY; + this.moveSelectedStrokes(dx, dy); + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + this.redrawCanvas(); + } else if ( + this.startX !== null && + this.currentTool !== "pen" && + this.currentTool !== "select" + ) { + // Update preview shape + const pos = this.getTransformedPosition(e.clientX, e.clientY); + + this.previewShape = { + type: this.currentTool, + x: this.startX, + y: this.startY, + width: pos.x - this.startX, + height: pos.y - this.startY, + color: this.currentColor, + thickness: this.currentThickness, + isFilled: this.isFilled, + }; + + this.redrawCanvas(); + } + }); + + this.canvas.addEventListener("mouseup", (e) => { + if (this.readonly && !this.isPanning) { + return; + } // Only handle panning in readonly mode + + if (this.resizeHandles.active) { + this.resizeHandles.active = false; + this.resizeHandles.activeHandle = null; + this.canvas.style.cursor = "default"; + // Record resize operation in history + // You might want to add a specific resize operation type + } else if (this.isPanning) { + this.isPanning = false; + this.canvas.style.cursor = "default"; + } else if (this.currentTool === "select") { + if (this.isSelecting && !this.moveSelection.active) { + this.selectStrokesInRect(this.selectionRect); + } + + // Record the move operation in history only when the move is complete + if ( + this.moveSelection.active && + this.moveSelection.totalDx !== undefined + ) { + const moveOp = { + type: "move", + strokes: [...this.selectedStrokes], + dx: this.moveSelection.totalDx, + dy: this.moveSelection.totalDy, + }; + this.drawingHistory.push(moveOp); + this.redoHistory = []; + } + + this.isSelecting = false; + this.moveSelection.active = false; + this.moveSelection.totalDx = undefined; + this.moveSelection.totalDy = undefined; + this.canvas.style.cursor = "default"; + + // Keep the selection rectangle for the selected strokes + if (this.selectedStrokes.length > 0) { + // Update selection rectangle to encompass all selected strokes + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + this.selectedStrokes.forEach((index) => { const stroke = this.drawingHistory[index]; - let oldColor; if (Array.isArray(stroke) && !stroke[0]?.type) { // For freehand strokes - oldColor = stroke[0].color; - stroke.forEach(point => point.color = color); + stroke.forEach((point) => { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + }); } else if (stroke[0]?.type) { // For shapes - oldColor = stroke[0].color; - stroke[0].color = color; + const bounds = this.getShapeBounds(stroke[0]); + minX = Math.min(minX, bounds.x); + minY = Math.min(minY, bounds.y); + maxX = Math.max(maxX, bounds.x + bounds.width); + maxY = Math.max(maxY, bounds.y + bounds.height); } - return { index, oldColor }; - }) - }; - - // Add operation to history - this.drawingHistory.push(colorOp); - this.redoHistory = []; + }); + + this.selectionRect = { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; + } else { + this.selectionRect = null; + } + this.redrawCanvas(); - this.showStatus('Color updated for selection'); - } - } - - setThickness(thickness) { - if (this.readonly) { - this.showStatus('Cannot change thickness in read-only mode'); - return; - } - this.currentThickness = parseInt(thickness); - // Update thickness of selected strokes/shapes - if (this.selectedStrokes.length > 0) { - // Create thickness change operation - const thicknessOp = { - type: 'thicknessChange', - strokes: this.selectedStrokes.map(index => ({ - index, - oldThickness: this.drawingHistory[index][0].thickness, - newThickness: thickness - })) - }; - - // Update thicknesses - this.selectedStrokes.forEach(index => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - stroke.forEach(point => point.thickness = thickness); - } else if (stroke[0]?.type) { - stroke[0].thickness = thickness; - } - }); - - // Add operation to history - this.drawingHistory.push(thicknessOp); - this.redoHistory = []; + } else if (this.currentTool === "pen" && this.isDrawing) { + this.isDrawing = false; + if (this.currentStroke.length > 1) { + this.drawingHistory.push(this.currentStroke); + this.redoHistory = []; // Clear redo history on new stroke + } + this.currentStroke = []; + } else if (this.startX !== null && this.previewShape) { + // Add the final shape to history + this.drawingHistory.push([this.previewShape]); + this.redoHistory = []; // Clear redo history on new shape + this.previewShape = null; // Clear preview shape this.redrawCanvas(); - this.showStatus('Thickness updated for selection'); - } - } - - drawShape(shape, isPreview = false, isSelected = false) { - const {ctx} = this; - - // Draw selection highlight background if selected - if (isSelected) { - ctx.save(); - ctx.strokeStyle = '#0066ff'; - // Cap the highlight thickness to a maximum of 4px more than the shape thickness - const highlightThickness = Math.min(shape.thickness + 4, shape.thickness * 1.2); - ctx.lineWidth = highlightThickness; - ctx.setLineDash([]); - ctx.globalAlpha = 0.3; - this.drawShapePath(shape); - ctx.stroke(); - ctx.restore(); } - - // Draw the main shape - ctx.beginPath(); - ctx.strokeStyle = shape.color; - ctx.fillStyle = shape.color; - ctx.lineWidth = shape.thickness; - - // Save the current context state - ctx.save(); - - // If it's a preview, use dashed line and lighter color - if (isPreview) { - ctx.setLineDash([5, 5]); - ctx.globalAlpha = shape.isFilled ? 0.3 : 0.6; + + this.startX = null; + this.startY = null; + }); + + this.canvas.addEventListener("mouseleave", () => { + if (this.isDrawing) { + this.isDrawing = false; + if (this.currentStroke.length > 1) { + this.drawingHistory.push(this.currentStroke); + } + this.currentStroke = []; + this.redrawCanvas(); } - - this.drawShapePath(shape); - - if (shape.isFilled) { - ctx.fill(); + if (this.isPanning) { + this.isPanning = false; + this.canvas.style.cursor = "default"; } - ctx.stroke(); - - // Draw white highlight for better visibility when selected - if (isSelected) { - ctx.strokeStyle = '#ffffff'; - // Cap the inner highlight thickness to a maximum of 2px more than the shape thickness - const innerHighlightThickness = Math.min(shape.thickness + 2, shape.thickness * 1.1); - ctx.lineWidth = innerHighlightThickness; - ctx.setLineDash([]); - ctx.globalAlpha = 0.5; - ctx.stroke(); + }); + + // Touch events for mobile + this.canvas.addEventListener("touchstart", (e) => { + if (this.readonly && e.touches.length !== 2) { + return; + } // Only allow two-finger pan/zoom in readonly mode + + e.preventDefault(); + + if (e.touches.length === 2) { + // Two fingers for panning + this.isPanning = true; + this.startPanX = + (e.touches[0].clientX + e.touches[1].clientX) / 2 - this.offsetX; + this.startPanY = + (e.touches[0].clientY + e.touches[1].clientY) / 2 - this.offsetY; + return; } - - // Restore the context state - ctx.restore(); - } - - clear() { - this.drawingHistory = []; - this.redrawCanvas(); - console.log('Canvas cleared'); - } - - // Save current drawing state to JSON - saveToJSON() { - const saveData = { - version: 1, - timestamp: new Date().toISOString(), - scale: this.scale, - offsetX: this.offsetX, - offsetY: this.offsetY, - canvasWidth: this.canvas.width, - canvasHeight: this.canvas.height, - strokes: this.drawingHistory - }; - - return JSON.stringify(saveData); - } - - // Load drawing from JSON string - loadFromJSON(jsonString) { - try { - const jsonData = JSON.parse(jsonString); - - // Check version compatibility - if (jsonData.version !== 1) { - throw new Error('Unsupported file version'); + + const touch = e.touches[0]; + const pos = this.getTransformedPosition(touch.clientX, touch.clientY); + + if (this.currentTool === "select") { + // First check if clicking on a resize handle + const handle = this.getResizeHandleAtPoint(pos); + if (handle) { + this.resizeHandles.active = true; + this.resizeHandles.activeHandle = handle; + return; + } + + // Then check if clicking inside selection area + if (this.selectedStrokes.length > 0 && this.selectionRect) { + const clickPoint = { x: pos.x, y: pos.y }; + if (this.isPointInRect(clickPoint, this.selectionRect)) { + // Start moving the selection + this.moveSelection.active = true; + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; + return; + } } - - // Load the strokes - this.drawingHistory = jsonData.strokes || []; - - // Check if we have canvas dimensions stored - if (jsonData.canvasWidth && jsonData.canvasHeight) { - // Calculate scale factors for width and height differences - const widthRatio = this.canvas.width / jsonData.canvasWidth; - const heightRatio = this.canvas.height / jsonData.canvasHeight; - - // Adjust offset to maintain relative position - this.offsetX = jsonData.offsetX * widthRatio; - this.offsetY = jsonData.offsetY * heightRatio; - - // Set the scale (using original scale or adjusted) - this.scale = jsonData.scale || 1; - - this.showStatus('Drawing loaded with size adjustment'); + + // Check if clicking on any stroke + const clickedPoint = { x: pos.x, y: pos.y }; + let clickedIndex = -1; + + // Check selected strokes first + for (const index of this.selectedStrokes) { + if (this.isPointInStroke(clickedPoint, this.drawingHistory[index])) { + clickedIndex = index; + break; + } + } + + // If not clicking on selected stroke, check others + if (clickedIndex === -1) { + for (let i = this.drawingHistory.length - 1; i >= 0; i--) { + if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { + clickedIndex = i; + break; + } + } + } + + if (clickedIndex >= 0) { + // Clicked on a stroke + this.selectedStrokes = [clickedIndex]; + this.moveSelection.active = true; + this.moveSelection.startX = pos.x; + this.moveSelection.startY = pos.y; } else { - // Use the saved values directly if no canvas dimensions were stored - this.scale = jsonData.scale || 1; - this.offsetX = jsonData.offsetX || 0; - this.offsetY = jsonData.offsetY || 0; - - this.showStatus('Drawing loaded successfully'); + // Start selection rectangle + this.isSelecting = true; + this.selectionRect = { + x: pos.x, + y: pos.y, + width: 0, + height: 0, + }; + this.selectedStrokes = []; } - - // Apply pan limits to loaded state - this.applyPanLimits(); - - // Redraw + this.redrawCanvas(); - - return true; - } catch (error) { - this.showStatus('Error loading file: ' + error.message); - console.error('Error loading file:', error); - return false; + } else if (this.currentTool === "pen") { + this.isDrawing = true; + this.lastX = pos.x; + this.lastY = pos.y; + + this.currentStroke = [ + { + x: pos.x, + y: pos.y, + color: this.currentColor, + thickness: this.currentThickness, + }, + ]; + } else { + // For shapes, store the starting position + this.startX = pos.x; + this.startY = pos.y; } - } - - // Event binding - bindEvents() { - // Resize handler - window.addEventListener('resize', () => this.resizeCanvas()); - - // Mouse wheel for zooming - this.container.addEventListener('wheel', (e) => { - e.preventDefault(); - - const mouseX = e.clientX; - const mouseY = e.clientY; - - // Calculate position before zoom - const pointBeforeZoomX = (mouseX - this.offsetX) / this.scale; - const pointBeforeZoomY = (mouseY - this.offsetY) / this.scale; - - // Adjust zoom level - const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9; - this.scale *= zoomFactor; - - // Limit zoom - this.scale = Math.min(Math.max(0.1, this.scale), 10); - - // Calculate position after zoom - const pointAfterZoomX = (mouseX - this.offsetX) / this.scale; - const pointAfterZoomY = (mouseY - this.offsetY) / this.scale; - - // Adjust offset to keep mouse position fixed - this.offsetX += (pointAfterZoomX - pointBeforeZoomX) * this.scale; - this.offsetY += (pointAfterZoomY - pointBeforeZoomY) * this.scale; - - // Apply pan limits - this.applyPanLimits(); - - // Redraw - this.redrawCanvas(); - - // Show zoom level - // this.showStatus(`Zoom: ${Math.round(this.scale * 100)}%`); - }); - - // Mouse events for drawing and panning - this.canvas.addEventListener('mousedown', (e) => { - if (this.readonly && !e.shiftKey && e.button !== 1) { - return; - } // Only allow panning in readonly mode - - this.lastEvent = e; - if (e.shiftKey || e.button === 1) { - this.isPanning = true; - this.startPanX = e.clientX - this.offsetX; - this.startPanY = e.clientY - this.offsetY; - this.canvas.style.cursor = 'grabbing'; - } else if (!this.readonly) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - this.startX = pos.x; - this.startY = pos.y; - - if (this.currentTool === 'select') { - // First check if clicking on a resize handle - const handle = this.getResizeHandleAtPoint(pos); - if (handle) { - this.resizeHandles.active = true; - this.resizeHandles.activeHandle = handle; - this.canvas.style.cursor = handle.cursor; - return; - } - - // Then check if clicking inside selection area - if (this.selectedStrokes.length > 0 && this.selectionRect) { - const clickPoint = { x: pos.x, y: pos.y }; - if (this.isPointInRect(clickPoint, this.selectionRect)) { - // Start moving the selection - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - this.canvas.style.cursor = 'move'; - return; - } - } - - // Check if clicking on any stroke - const clickedPoint = { x: pos.x, y: pos.y }; - let clickedIndex = -1; - - // Check selected strokes first - for (const index of this.selectedStrokes) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[index])) { - clickedIndex = index; - break; - } - } - - // If not clicking on selected stroke, check others - if (clickedIndex === -1) { - for (let i = this.drawingHistory.length - 1; i >= 0; i--) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { - clickedIndex = i; - break; - } - } - } - - if (clickedIndex >= 0) { - // Clicked on a stroke - if (!e.ctrlKey && !e.metaKey && !this.selectedStrokes.includes(clickedIndex)) { - // New selection if not holding Ctrl/Cmd - this.selectedStrokes = [clickedIndex]; - } else if ((e.ctrlKey || e.metaKey) && this.selectedStrokes.includes(clickedIndex)) { - // Deselect if holding Ctrl/Cmd and clicking on selected stroke - this.selectedStrokes = this.selectedStrokes.filter(i => i !== clickedIndex); - } else if (e.ctrlKey || e.metaKey) { - // Add to selection if holding Ctrl/Cmd - this.selectedStrokes.push(clickedIndex); - } - - // Start move operation if clicked on selected stroke - if (this.selectedStrokes.includes(clickedIndex)) { - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - this.canvas.style.cursor = 'move'; - } - } else if (!e.ctrlKey && !e.metaKey) { - // Start selection rectangle if not clicking on any stroke and not holding modifier keys - this.isSelecting = true; - this.selectionRect = { - x: pos.x, - y: pos.y, - width: 0, - height: 0 - }; - this.selectedStrokes = []; - } - - this.redrawCanvas(); - } else if (this.currentTool === 'pen') { - this.isDrawing = true; - this.currentStroke = [{ - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness - }]; + }); + + this.canvas.addEventListener("touchmove", (e) => { + if (this.readonly && e.touches.length !== 2) { + return; + } // Only allow two-finger pan/zoom in readonly mode + + e.preventDefault(); + + if (e.touches.length === 2) { + // Two fingers for panning/zooming + const currentX = (e.touches[0].clientX + e.touches[1].clientX) / 2; + const currentY = (e.touches[0].clientY + e.touches[1].clientY) / 2; + + // Calculate distance between fingers for pinch-to-zoom + const initialDistance = Math.hypot( + e.touches[0].clientX - e.touches[1].clientX, + e.touches[0].clientY - e.touches[1].clientY, + ); + + if (e.touches.length === 2 && e.target === this.canvas) { + if (typeof this.lastDistance === "number") { + const delta = initialDistance - this.lastDistance; + const zoomFactor = delta > 0 ? 1.01 : 0.99; + this.scale *= zoomFactor; + this.scale = Math.min(Math.max(0.1, this.scale), 10); } + this.lastDistance = initialDistance; } - }); - - this.canvas.addEventListener('mousemove', (e) => { - if (this.readonly && !this.isPanning) { - return; - } // Only handle panning in readonly mode - - // Update cursor based on resize handles when in select mode - if (this.currentTool === 'select' && !this.isSelecting && !this.moveSelection.active && !this.resizeHandles.active) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - const handle = this.getResizeHandleAtPoint(pos); - this.canvas.style.cursor = handle ? handle.cursor : 'default'; - } - - if (this.isPanning) { - this.offsetX = e.clientX - this.startPanX; - this.offsetY = e.clientY - this.startPanY; - this.applyPanLimits(); - this.redrawCanvas(); - } else if (this.resizeHandles.active) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); + + this.offsetX = currentX - this.startPanX; + this.offsetY = currentY - this.startPanY; + + // Apply pan limits + this.applyPanLimits(); + + this.redrawCanvas(); + return; + } + + const touch = e.touches[0]; + const pos = this.getTransformedPosition(touch.clientX, touch.clientY); + + if (this.currentTool === "select") { + if (this.resizeHandles.active && this.resizeHandles.activeHandle) { + // Handle resize operation const dx = pos.x - this.startX; const dy = pos.y - this.startY; - - // Resize the shapes this.resizeSelectedShapes(this.resizeHandles.activeHandle, dx, dy); - this.startX = pos.x; this.startY = pos.y; - this.redrawCanvas(); - } else if (this.isDrawing && this.currentTool === 'pen') { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - - // Calculate velocity for pressure - const velocity = Math.sqrt( - Math.pow(pos.x - this.lastX, 2) + - Math.pow(pos.y - this.lastY, 2) - ); - - // Smooth velocity - this.lastVelocity = this.lastVelocity ? - this.lastVelocity * this.velocityFilterWeight + - velocity * (1 - this.velocityFilterWeight) : - velocity; - - // Add point with pressure data - this.currentStroke.push({ - x: pos.x, - y: pos.y, - color: this.currentColor, - pressure: Math.max(0.1, 1 - this.lastVelocity / 4), - thickness: this.currentThickness - }); - - this.redrawCanvas(); - - this.lastX = pos.x; - this.lastY = pos.y; - } else if (this.isSelecting) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); - this.selectionRect.width = pos.x - this.selectionRect.x; - this.selectionRect.height = pos.y - this.selectionRect.y; - this.redrawCanvas(); } else if (this.moveSelection.active) { - const pos = this.getTransformedPosition(e.clientX, e.clientY); + // Handle move operation const dx = pos.x - this.moveSelection.startX; const dy = pos.y - this.moveSelection.startY; this.moveSelectedStrokes(dx, dy); this.moveSelection.startX = pos.x; this.moveSelection.startY = pos.y; - this.redrawCanvas(); - } else if (this.startX !== null && this.currentTool !== 'pen' && this.currentTool !== 'select') { - // Update preview shape - const pos = this.getTransformedPosition(e.clientX, e.clientY); - - this.previewShape = { - type: this.currentTool, - x: this.startX, - y: this.startY, - width: pos.x - this.startX, - height: pos.y - this.startY, - color: this.currentColor, - thickness: this.currentThickness, - isFilled: this.isFilled - }; - - this.redrawCanvas(); + } else if (this.isSelecting) { + // Update selection rectangle + this.selectionRect.width = pos.x - this.selectionRect.x; + this.selectionRect.height = pos.y - this.selectionRect.y; } - }); - - this.canvas.addEventListener('mouseup', (e) => { - if (this.readonly && !this.isPanning) { - return; - } // Only handle panning in readonly mode - - if (this.resizeHandles.active) { - this.resizeHandles.active = false; - this.resizeHandles.activeHandle = null; - this.canvas.style.cursor = 'default'; - // Record resize operation in history - // You might want to add a specific resize operation type - } else if (this.isPanning) { - this.isPanning = false; - this.canvas.style.cursor = 'default'; - } else if (this.currentTool === 'select') { - if (this.isSelecting && !this.moveSelection.active) { + this.redrawCanvas(); + } else if (this.isDrawing && this.currentTool === "pen") { + // Add to current stroke for pen tool + this.currentStroke.push({ + x: pos.x, + y: pos.y, + color: this.currentColor, + thickness: this.currentThickness, + }); + + this.redrawCanvas(); + + this.lastX = pos.x; + this.lastY = pos.y; + } else if ( + this.startX !== null && + this.currentTool !== "pen" && + this.currentTool !== "select" + ) { + // Update preview shape for shape tools + this.previewShape = { + type: this.currentTool, + x: this.startX, + y: this.startY, + width: pos.x - this.startX, + height: pos.y - this.startY, + color: this.currentColor, + thickness: this.currentThickness, + isFilled: this.isFilled, + }; + + this.redrawCanvas(); + } + }); + + this.canvas.addEventListener("touchend", (e) => { + if (e.touches.length === 0) { + this.isPanning = false; + this.lastDistance = null; + + if (this.currentTool === "select") { + if (this.isSelecting) { this.selectStrokesInRect(this.selectionRect); + this.isSelecting = false; } - + // Record the move operation in history only when the move is complete - if (this.moveSelection.active && this.moveSelection.totalDx !== undefined) { + if ( + this.moveSelection.active && + this.moveSelection.totalDx !== undefined + ) { const moveOp = { - type: 'move', + type: "move", strokes: [...this.selectedStrokes], dx: this.moveSelection.totalDx, - dy: this.moveSelection.totalDy + dy: this.moveSelection.totalDy, }; this.drawingHistory.push(moveOp); this.redoHistory = []; } - - this.isSelecting = false; + this.moveSelection.active = false; this.moveSelection.totalDx = undefined; this.moveSelection.totalDy = undefined; - this.canvas.style.cursor = 'default'; - - // Keep the selection rectangle for the selected strokes + this.resizeHandles.active = false; + this.resizeHandles.activeHandle = null; + + // Update selection rectangle for selected strokes if (this.selectedStrokes.length > 0) { - // Update selection rectangle to encompass all selected strokes - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.selectedStrokes.forEach(index => { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + this.selectedStrokes.forEach((index) => { const stroke = this.drawingHistory[index]; if (Array.isArray(stroke) && !stroke[0]?.type) { - // For freehand strokes - stroke.forEach(point => { + stroke.forEach((point) => { minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y); }); } else if (stroke[0]?.type) { - // For shapes const bounds = this.getShapeBounds(stroke[0]); minX = Math.min(minX, bounds.x); minY = Math.min(minY, bounds.y); @@ -1239,1085 +1665,933 @@ class Canvas { maxY = Math.max(maxY, bounds.y + bounds.height); } }); - + this.selectionRect = { x: minX, y: minY, width: maxX - minX, - height: maxY - minY + height: maxY - minY, }; - } else { - this.selectionRect = null; } - - this.redrawCanvas(); - } else if (this.currentTool === 'pen' && this.isDrawing) { + } else if (this.isDrawing && this.currentTool === "pen") { this.isDrawing = false; if (this.currentStroke.length > 1) { this.drawingHistory.push(this.currentStroke); - this.redoHistory = []; // Clear redo history on new stroke } this.currentStroke = []; } else if (this.startX !== null && this.previewShape) { // Add the final shape to history this.drawingHistory.push([this.previewShape]); - this.redoHistory = []; // Clear redo history on new shape - this.previewShape = null; // Clear preview shape - this.redrawCanvas(); + this.previewShape = null; } - + this.startX = null; this.startY = null; - }); - - this.canvas.addEventListener('mouseleave', () => { - if (this.isDrawing) { - this.isDrawing = false; - if (this.currentStroke.length > 1) { - this.drawingHistory.push(this.currentStroke); - } - this.currentStroke = []; - this.redrawCanvas(); - } - if (this.isPanning) { - this.isPanning = false; - this.canvas.style.cursor = 'default'; - } - }); - - // Touch events for mobile - this.canvas.addEventListener('touchstart', (e) => { - if (this.readonly && e.touches.length !== 2) { - return; - } // Only allow two-finger pan/zoom in readonly mode - - e.preventDefault(); - - if (e.touches.length === 2) { // Two fingers for panning - this.isPanning = true; - this.startPanX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - this.offsetX; - this.startPanY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - this.offsetY; - return; - } - - const touch = e.touches[0]; - const pos = this.getTransformedPosition(touch.clientX, touch.clientY); - - if (this.currentTool === 'select') { - // First check if clicking on a resize handle - const handle = this.getResizeHandleAtPoint(pos); - if (handle) { - this.resizeHandles.active = true; - this.resizeHandles.activeHandle = handle; - return; - } - - // Then check if clicking inside selection area - if (this.selectedStrokes.length > 0 && this.selectionRect) { - const clickPoint = { x: pos.x, y: pos.y }; - if (this.isPointInRect(clickPoint, this.selectionRect)) { - // Start moving the selection - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - return; - } - } - - // Check if clicking on any stroke - const clickedPoint = { x: pos.x, y: pos.y }; - let clickedIndex = -1; - - // Check selected strokes first - for (const index of this.selectedStrokes) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[index])) { - clickedIndex = index; - break; - } - } - - // If not clicking on selected stroke, check others - if (clickedIndex === -1) { - for (let i = this.drawingHistory.length - 1; i >= 0; i--) { - if (this.isPointInStroke(clickedPoint, this.drawingHistory[i])) { - clickedIndex = i; - break; - } - } - } + this.redrawCanvas(); + } + }); - if (clickedIndex >= 0) { - // Clicked on a stroke - this.selectedStrokes = [clickedIndex]; - this.moveSelection.active = true; - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - } else { - // Start selection rectangle - this.isSelecting = true; - this.selectionRect = { - x: pos.x, - y: pos.y, - width: 0, - height: 0 - }; - this.selectedStrokes = []; - } - - this.redrawCanvas(); - } else if (this.currentTool === 'pen') { - this.isDrawing = true; - this.lastX = pos.x; - this.lastY = pos.y; - - this.currentStroke = [{ - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness - }]; - } else { - // For shapes, store the starting position - this.startX = pos.x; - this.startY = pos.y; - } - }); - - this.canvas.addEventListener('touchmove', (e) => { - if (this.readonly && e.touches.length !== 2) { - return; - } // Only allow two-finger pan/zoom in readonly mode - - e.preventDefault(); - - if (e.touches.length === 2) { // Two fingers for panning/zooming - const currentX = (e.touches[0].clientX + e.touches[1].clientX) / 2; - const currentY = (e.touches[0].clientY + e.touches[1].clientY) / 2; - - // Calculate distance between fingers for pinch-to-zoom - const initialDistance = Math.hypot( - e.touches[0].clientX - e.touches[1].clientX, - e.touches[0].clientY - e.touches[1].clientY - ); - - if (e.touches.length === 2 && e.target === this.canvas) { - if (typeof this.lastDistance === 'number') { - const delta = initialDistance - this.lastDistance; - const zoomFactor = delta > 0 ? 1.01 : 0.99; - this.scale *= zoomFactor; - this.scale = Math.min(Math.max(0.1, this.scale), 10); - } - this.lastDistance = initialDistance; - } - - this.offsetX = currentX - this.startPanX; - this.offsetY = currentY - this.startPanY; - - // Apply pan limits - this.applyPanLimits(); - - this.redrawCanvas(); - return; - } - - const touch = e.touches[0]; - const pos = this.getTransformedPosition(touch.clientX, touch.clientY); - - if (this.currentTool === 'select') { - if (this.resizeHandles.active && this.resizeHandles.activeHandle) { - // Handle resize operation - const dx = pos.x - this.startX; - const dy = pos.y - this.startY; - this.resizeSelectedShapes(this.resizeHandles.activeHandle, dx, dy); - this.startX = pos.x; - this.startY = pos.y; - } else if (this.moveSelection.active) { - // Handle move operation - const dx = pos.x - this.moveSelection.startX; - const dy = pos.y - this.moveSelection.startY; - this.moveSelectedStrokes(dx, dy); - this.moveSelection.startX = pos.x; - this.moveSelection.startY = pos.y; - } else if (this.isSelecting) { - // Update selection rectangle - this.selectionRect.width = pos.x - this.selectionRect.x; - this.selectionRect.height = pos.y - this.selectionRect.y; - } - this.redrawCanvas(); - } else if (this.isDrawing && this.currentTool === 'pen') { - // Add to current stroke for pen tool - this.currentStroke.push({ - x: pos.x, - y: pos.y, - color: this.currentColor, - thickness: this.currentThickness - }); - - this.redrawCanvas(); - - this.lastX = pos.x; - this.lastY = pos.y; - } else if (this.startX !== null && this.currentTool !== 'pen' && this.currentTool !== 'select') { - // Update preview shape for shape tools - this.previewShape = { - type: this.currentTool, - x: this.startX, - y: this.startY, - width: pos.x - this.startX, - height: pos.y - this.startY, - color: this.currentColor, - thickness: this.currentThickness, - isFilled: this.isFilled - }; - - this.redrawCanvas(); - } - }); - - this.canvas.addEventListener('touchend', (e) => { - if (e.touches.length === 0) { - this.isPanning = false; - this.lastDistance = null; - - if (this.currentTool === 'select') { - if (this.isSelecting) { - this.selectStrokesInRect(this.selectionRect); - this.isSelecting = false; - } + // Add keyboard shortcuts + window.addEventListener("keydown", (e) => { + if (this.readonly) { + return; + } // Disable keyboard shortcuts in readonly mode - // Record the move operation in history only when the move is complete - if (this.moveSelection.active && this.moveSelection.totalDx !== undefined) { - const moveOp = { - type: 'move', - strokes: [...this.selectedStrokes], - dx: this.moveSelection.totalDx, - dy: this.moveSelection.totalDy - }; - this.drawingHistory.push(moveOp); - this.redoHistory = []; - } + if (e.key === "Escape" && (this.previewShape || this.selectionRect)) { + this.previewShape = null; + this.selectionRect = null; + this.startX = null; + this.startY = null; + this.selectedStrokes = []; + this.redrawCanvas(); + this.showStatus("Operation cancelled"); + return; + } - this.moveSelection.active = false; - this.moveSelection.totalDx = undefined; - this.moveSelection.totalDy = undefined; - this.resizeHandles.active = false; - this.resizeHandles.activeHandle = null; - - // Update selection rectangle for selected strokes - if (this.selectedStrokes.length > 0) { - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - this.selectedStrokes.forEach(index => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke) && !stroke[0]?.type) { - stroke.forEach(point => { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - }); - } else if (stroke[0]?.type) { - const bounds = this.getShapeBounds(stroke[0]); - minX = Math.min(minX, bounds.x); - minY = Math.min(minY, bounds.y); - maxX = Math.max(maxX, bounds.x + bounds.width); - maxY = Math.max(maxY, bounds.y + bounds.height); - } - }); - - this.selectionRect = { - x: minX, - y: minY, - width: maxX - minX, - height: maxY - minY - }; - } - } else if (this.isDrawing && this.currentTool === 'pen') { - this.isDrawing = false; - if (this.currentStroke.length > 1) { - this.drawingHistory.push(this.currentStroke); + if (e.ctrlKey || e.metaKey) { + // Support both Windows/Linux and Mac + switch (e.key.toLowerCase()) { + case "z": + e.preventDefault(); + if (e.shiftKey) { + this.redo(); + } else { + this.undo(); } - this.currentStroke = []; - } else if (this.startX !== null && this.previewShape) { - // Add the final shape to history - this.drawingHistory.push([this.previewShape]); - this.previewShape = null; - } - - this.startX = null; - this.startY = null; - this.redrawCanvas(); - } - }); - - // Add keyboard shortcuts - window.addEventListener('keydown', (e) => { - if (this.readonly) { - return; - } // Disable keyboard shortcuts in readonly mode - - if (e.key === 'Escape' && (this.previewShape || this.selectionRect)) { - this.previewShape = null; - this.selectionRect = null; - this.startX = null; - this.startY = null; - this.selectedStrokes = []; - this.redrawCanvas(); - this.showStatus('Operation cancelled'); - return; - } - - if (e.ctrlKey || e.metaKey) { // Support both Windows/Linux and Mac - switch(e.key.toLowerCase()) { - case 'z': + break; + case "y": + e.preventDefault(); + this.redo(); + break; + case "a": + e.preventDefault(); + this.setTool("select"); + break; + case "p": + e.preventDefault(); + this.setTool("pen"); + break; + case "r": + e.preventDefault(); + this.setTool("rectangle"); + break; + case "c": + if (!e.shiftKey) { e.preventDefault(); - if (e.shiftKey) { - this.redo(); + if (e.altKey) { + this.setTool("circle"); } else { - this.undo(); - } - break; - case 'y': - e.preventDefault(); - this.redo(); - break; - case 'a': - e.preventDefault(); - this.setTool('select'); - break; - case 'p': - e.preventDefault(); - this.setTool('pen'); - break; - case 'r': - e.preventDefault(); - this.setTool('rectangle'); - break; - case 'c': - if (!e.shiftKey) { - e.preventDefault(); - if (e.altKey) { - this.setTool('circle'); - } else { - this.copySelectedStrokes(); - } - } - break; - case 'x': - e.preventDefault(); - this.cutSelectedStrokes(); - break; - case 'v': - e.preventDefault(); - this.pasteStrokes(); - break; - case 'l': - e.preventDefault(); - this.setTool('line'); - break; - case 'h': - e.preventDefault(); - this.setTool('hexagon'); - break; - case 's': - if (!e.shiftKey) { - e.preventDefault(); - this.setTool('star'); - } - break; - case 'f': - e.preventDefault(); - this.setFill(!this.isFilled); - break; - } - } else if ((e.key === 'Backspace' || e.key === 'Delete') && this.selectedStrokes.length > 0) { - e.preventDefault(); - this.deleteSelectedStrokes(); - } - }); - } - - // Perfect freehand drawing helper methods - getStrokePoints(points, options = {}) { - if (!Array.isArray(points) || points.length < 2) { - return points; + this.copySelectedStrokes(); + } + } + break; + case "x": + e.preventDefault(); + this.cutSelectedStrokes(); + break; + case "v": + e.preventDefault(); + this.pasteStrokes(); + break; + case "l": + e.preventDefault(); + this.setTool("line"); + break; + case "h": + e.preventDefault(); + this.setTool("hexagon"); + break; + case "s": + if (!e.shiftKey) { + e.preventDefault(); + this.setTool("star"); + } + break; + case "f": + e.preventDefault(); + this.setFill(!this.isFilled); + break; + } + } else if ( + (e.key === "Backspace" || e.key === "Delete") && + this.selectedStrokes.length > 0 + ) { + e.preventDefault(); + this.deleteSelectedStrokes(); } - - try { - const { thinning = this.thinning, smoothing = this.smoothing } = options; - - const strokePoints = []; - - // Convert points to vectors for easier manipulation - const vectors = points.filter(p => p && typeof p === 'object').map((p, i) => { + }); + } + + // Perfect freehand drawing helper methods + getStrokePoints(points, options = {}) { + if (!Array.isArray(points) || points.length < 2) { + return points; + } + + try { + const { thinning = this.thinning, smoothing = this.smoothing } = options; + + const strokePoints = []; + + // Convert points to vectors for easier manipulation + const vectors = points + .filter((p) => p && typeof p === "object") + .map((p, i) => { const next = points[i + 1]; - return next ? { - x: next.x - p.x, - y: next.y - p.y, - pressure: p.pressure || 1, - thickness: p.thickness, - color: p.color - } : null; - }).filter(v => v); - - // Calculate control points for each segment - for (let i = 0; i < points.length - 1; i++) { - const p0 = points[i]; - const p1 = points[i + 1]; - - if (!p0 || !p1) { - continue; - } // Skip invalid points - - // Calculate vector magnitude and direction - const vector = vectors[i]; - if (!vector) { - continue; - } - - const magnitude = Math.sqrt(vector.x * vector.x + vector.y * vector.y); - const angle = Math.atan2(vector.y, vector.x); - - // Calculate pressure based on velocity - const velocity = Math.min(magnitude / 2, 4); - const pressure = Math.max(0.1, 1 - velocity / 4); - - // Calculate control points for smoother curves - const ctrl1 = { - x: p0.x + Math.cos(angle) * magnitude * smoothing, - y: p0.y + Math.sin(angle) * magnitude * smoothing - }; - - const ctrl2 = { - x: p1.x - Math.cos(angle) * magnitude * smoothing, - y: p1.y - Math.sin(angle) * magnitude * smoothing - }; - - // Add points with calculated properties + return next + ? { + x: next.x - p.x, + y: next.y - p.y, + pressure: p.pressure || 1, + thickness: p.thickness, + color: p.color, + } + : null; + }) + .filter((v) => v); + + // Calculate control points for each segment + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + + if (!p0 || !p1) { + continue; + } // Skip invalid points + + // Calculate vector magnitude and direction + const vector = vectors[i]; + if (!vector) { + continue; + } + + const magnitude = Math.sqrt(vector.x * vector.x + vector.y * vector.y); + const angle = Math.atan2(vector.y, vector.x); + + // Calculate pressure based on velocity + const velocity = Math.min(magnitude / 2, 4); + const pressure = Math.max(0.1, 1 - velocity / 4); + + // Calculate control points for smoother curves + const ctrl1 = { + x: p0.x + Math.cos(angle) * magnitude * smoothing, + y: p0.y + Math.sin(angle) * magnitude * smoothing, + }; + + const ctrl2 = { + x: p1.x - Math.cos(angle) * magnitude * smoothing, + y: p1.y - Math.sin(angle) * magnitude * smoothing, + }; + + // Add points with calculated properties + strokePoints.push({ + x: p0.x, + y: p0.y, + ctrl1x: ctrl1.x, + ctrl1y: ctrl1.y, + ctrl2x: ctrl2.x, + ctrl2y: ctrl2.y, + pressure, + thickness: p0.thickness * (1 - thinning * (1 - pressure)), + color: p0.color, + }); + } + + // Add the last point + if (points.length > 0) { + const last = points[points.length - 1]; + if (last) { strokePoints.push({ - x: p0.x, - y: p0.y, - ctrl1x: ctrl1.x, - ctrl1y: ctrl1.y, - ctrl2x: ctrl2.x, - ctrl2y: ctrl2.y, - pressure, - thickness: p0.thickness * (1 - thinning * (1 - pressure)), - color: p0.color + x: last.x, + y: last.y, + pressure: 1, + thickness: last.thickness, + color: last.color, }); } - - // Add the last point - if (points.length > 0) { - const last = points[points.length - 1]; - if (last) { - strokePoints.push({ - x: last.x, - y: last.y, - pressure: 1, - thickness: last.thickness, - color: last.color - }); - } - } - - return strokePoints; - } catch (error) { - console.warn('Error calculating stroke points:', error); - return points; } + + return strokePoints; + } catch (error) { + console.warn("Error calculating stroke points:", error); + return points; } - - // LocalForage methods - async autoSave() { - try { - await this.storage.setItem('lastSession', this.saveToJSON()); - this.showStatus('Auto-saved'); - } catch (error) { - console.error('Auto-save failed:', error); - } + } + + // LocalForage methods + async autoSave() { + try { + await this.storage.setItem("lastSession", this.saveToJSON()); + this.showStatus("Auto-saved"); + } catch (error) { + console.error("Auto-save failed:", error); } - - // Selection methods - isPointInStroke(point, stroke) { - if (Array.isArray(stroke)) { - // For freehand strokes - for (let i = 0; i < stroke.length - 1; i++) { - const p1 = stroke[i]; - const p2 = stroke[i + 1]; - const distance = this.pointToLineDistance(point, p1, p2); - if (distance < 5) { - return true; - } // 5px tolerance - } - return false; - } else if (stroke[0]?.type) { - // For shapes - const shape = stroke[0]; - const bounds = this.getShapeBounds(shape); - return this.isPointInRect(point, bounds); + } + + // Selection methods + isPointInStroke(point, stroke) { + if (Array.isArray(stroke)) { + // For freehand strokes + for (let i = 0; i < stroke.length - 1; i++) { + const p1 = stroke[i]; + const p2 = stroke[i + 1]; + const distance = this.pointToLineDistance(point, p1, p2); + if (distance < 5) { + return true; + } // 5px tolerance } return false; + } else if (stroke[0]?.type) { + // For shapes + const shape = stroke[0]; + const bounds = this.getShapeBounds(shape); + return this.isPointInRect(point, bounds); } - - pointToLineDistance(point, lineStart, lineEnd) { - const A = point.x - lineStart.x; - const B = point.y - lineStart.y; - const C = lineEnd.x - lineStart.x; - const D = lineEnd.y - lineStart.y; - - const dot = A * C + B * D; - const lenSq = C * C + D * D; - let param = -1; - - if (lenSq !== 0) { - param = dot / lenSq; - } - - let xx, yy; - - if (param < 0) { - xx = lineStart.x; - yy = lineStart.y; - } else if (param > 1) { - xx = lineEnd.x; - yy = lineEnd.y; - } else { - xx = lineStart.x + param * C; - yy = lineStart.y + param * D; - } - - const dx = point.x - xx; - const dy = point.y - yy; - - return Math.sqrt(dx * dx + dy * dy); - } - - getShapeBounds(shape) { - return { - x: Math.min(shape.x, shape.x + shape.width), - y: Math.min(shape.y, shape.y + shape.height), - width: Math.abs(shape.width), - height: Math.abs(shape.height) - }; + return false; + } + + pointToLineDistance(point, lineStart, lineEnd) { + const A = point.x - lineStart.x; + const B = point.y - lineStart.y; + const C = lineEnd.x - lineStart.x; + const D = lineEnd.y - lineStart.y; + + const dot = A * C + B * D; + const lenSq = C * C + D * D; + let param = -1; + + if (lenSq !== 0) { + param = dot / lenSq; } - - isPointInRect(point, rect) { - return point.x >= rect.x && - point.x <= rect.x + rect.width && - point.y >= rect.y && - point.y <= rect.y + rect.height; - } - - selectStrokesInRect(rect) { - const normalizedRect = { - x: Math.min(rect.x, rect.x + rect.width), - y: Math.min(rect.y, rect.y + rect.height), - width: Math.abs(rect.width), - height: Math.abs(rect.height) - }; - - // Don't clear previous selection if Ctrl/Cmd is held - if (!this.lastEvent?.ctrlKey && !this.lastEvent?.metaKey) { - this.selectedStrokes = []; - } - - // Keep track of newly selected strokes - const newlySelected = []; - - this.drawingHistory.forEach((stroke, index) => { - if (Array.isArray(stroke) && !stroke[0]?.type) { - // For freehand strokes - for (const point of stroke) { - if (this.isPointInRect(point, normalizedRect)) { - if (!this.selectedStrokes.includes(index)) { - newlySelected.push(index); - } - break; + + let xx, yy; + + if (param < 0) { + xx = lineStart.x; + yy = lineStart.y; + } else if (param > 1) { + xx = lineEnd.x; + yy = lineEnd.y; + } else { + xx = lineStart.x + param * C; + yy = lineStart.y + param * D; + } + + const dx = point.x - xx; + const dy = point.y - yy; + + return Math.sqrt(dx * dx + dy * dy); + } + + getShapeBounds(shape) { + return { + x: Math.min(shape.x, shape.x + shape.width), + y: Math.min(shape.y, shape.y + shape.height), + width: Math.abs(shape.width), + height: Math.abs(shape.height), + }; + } + + isPointInRect(point, rect) { + return ( + point.x >= rect.x && + point.x <= rect.x + rect.width && + point.y >= rect.y && + point.y <= rect.y + rect.height + ); + } + + selectStrokesInRect(rect) { + const normalizedRect = { + x: Math.min(rect.x, rect.x + rect.width), + y: Math.min(rect.y, rect.y + rect.height), + width: Math.abs(rect.width), + height: Math.abs(rect.height), + }; + + // Don't clear previous selection if Ctrl/Cmd is held + if (!this.lastEvent?.ctrlKey && !this.lastEvent?.metaKey) { + this.selectedStrokes = []; + } + + // Keep track of newly selected strokes + const newlySelected = []; + + this.drawingHistory.forEach((stroke, index) => { + if (Array.isArray(stroke) && !stroke[0]?.type) { + // For freehand strokes + for (const point of stroke) { + if (this.isPointInRect(point, normalizedRect)) { + if (!this.selectedStrokes.includes(index)) { + newlySelected.push(index); } - } - } else if (stroke[0]?.type) { - // For shapes - const shapeBounds = this.getShapeBounds(stroke[0]); - if (this.rectsIntersect(normalizedRect, shapeBounds) && !this.selectedStrokes.includes(index)) { - newlySelected.push(index); + break; } } - }); - - // Add newly selected strokes to selection - this.selectedStrokes.push(...newlySelected); - - // Update UI controls based on the last selected stroke - if (this.selectedStrokes.length > 0) { - const lastSelectedStroke = this.drawingHistory[this.selectedStrokes[this.selectedStrokes.length - 1]]; - if (Array.isArray(lastSelectedStroke) && !lastSelectedStroke[0]?.type) { - // For freehand strokes - const color = lastSelectedStroke[0]?.color; - const thickness = lastSelectedStroke[0]?.thickness; - this.updateUIControls(color, thickness); - } else if (lastSelectedStroke[0]?.type) { - // For shapes - const color = lastSelectedStroke[0]?.color; - const thickness = lastSelectedStroke[0]?.thickness; - this.updateUIControls(color, thickness); + } else if (stroke[0]?.type) { + // For shapes + const shapeBounds = this.getShapeBounds(stroke[0]); + if ( + this.rectsIntersect(normalizedRect, shapeBounds) && + !this.selectedStrokes.includes(index) + ) { + newlySelected.push(index); } } - - this.redrawCanvas(); + }); + + // Add newly selected strokes to selection + this.selectedStrokes.push(...newlySelected); + + // Update UI controls based on the last selected stroke + if (this.selectedStrokes.length > 0) { + const lastSelectedStroke = + this.drawingHistory[ + this.selectedStrokes[this.selectedStrokes.length - 1] + ]; + if (Array.isArray(lastSelectedStroke) && !lastSelectedStroke[0]?.type) { + // For freehand strokes + const color = lastSelectedStroke[0]?.color; + const thickness = lastSelectedStroke[0]?.thickness; + this.updateUIControls(color, thickness); + } else if (lastSelectedStroke[0]?.type) { + // For shapes + const color = lastSelectedStroke[0]?.color; + const thickness = lastSelectedStroke[0]?.thickness; + this.updateUIControls(color, thickness); + } } - - // Add new method to update UI controls - updateUIControls(color, thickness) { - if (this.externalUpdateUIControls) { - this.externalUpdateUIControls(color, thickness); - } else { - // Fallback implementation - if (color) { - const colorPicker = document.getElementById('pathColorPicker'); - if (colorPicker) { - colorPicker.value = color; - // Update Coloris field and button - const clrField = colorPicker.closest('.clr-field'); - if (clrField) { - clrField.style.color = color; - const button = clrField.querySelector('button'); - if (button) { - button.style.backgroundColor = color; - } + + this.redrawCanvas(); + } + + // Add new method to update UI controls + updateUIControls(color, thickness) { + if (this.externalUpdateUIControls) { + this.externalUpdateUIControls(color, thickness); + } else { + // Fallback implementation + if (color) { + const colorPicker = document.getElementById("pathColorPicker"); + if (colorPicker) { + colorPicker.value = color; + // Update Coloris field and button + const clrField = colorPicker.closest(".clr-field"); + if (clrField) { + clrField.style.color = color; + const button = clrField.querySelector("button"); + if (button) { + button.style.backgroundColor = color; } } } - if (thickness) { - const thicknessSlider = document.getElementById('pathThickness'); - const thicknessDisplay = document.getElementById('pathThicknessValue'); - if (thicknessSlider) { - thicknessSlider.value = thickness; - if (thicknessDisplay) { - thicknessDisplay.textContent = thickness; - } + } + if (thickness) { + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessDisplay = document.getElementById("pathThicknessValue"); + if (thicknessSlider) { + thicknessSlider.value = thickness; + if (thicknessDisplay) { + thicknessDisplay.textContent = thickness; } } } } - - rectsIntersect(rect1, rect2) { - return !(rect2.x > rect1.x + rect1.width || - rect2.x + rect2.width < rect1.x || - rect2.y > rect1.y + rect1.height || - rect2.y + rect2.height < rect1.y); + } + + rectsIntersect(rect1, rect2) { + return !( + rect2.x > rect1.x + rect1.width || + rect2.x + rect2.width < rect1.x || + rect2.y > rect1.y + rect1.height || + rect2.y + rect2.height < rect1.y + ); + } + + moveSelectedStrokes(dx, dy) { + if (dx === 0 && dy === 0) { + return; } - - moveSelectedStrokes(dx, dy) { - if (dx === 0 && dy === 0) { - return; - } - - // Just move the strokes without recording history during dragging - for (const index of this.selectedStrokes) { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - // Move freehand stroke - stroke.forEach(point => { - point.x += dx; - point.y += dy; - }); - } else if (stroke[0]?.type) { - // Move shape - stroke[0].x += dx; - stroke[0].y += dy; - } - } - - // Move the selection rectangle along with the strokes - if (this.selectionRect) { - this.selectionRect.x += dx; - this.selectionRect.y += dy; - } - - // Accumulate total movement - if (!this.moveSelection.totalDx) { - this.moveSelection.totalDx = 0; - } - if (!this.moveSelection.totalDy) { - this.moveSelection.totalDy = 0; + + // Just move the strokes without recording history during dragging + for (const index of this.selectedStrokes) { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + // Move freehand stroke + stroke.forEach((point) => { + point.x += dx; + point.y += dy; + }); + } else if (stroke[0]?.type) { + // Move shape + stroke[0].x += dx; + stroke[0].y += dy; } - this.moveSelection.totalDx += dx; - this.moveSelection.totalDy += dy; } - - copySelectedStrokes() { - this.selectedStrokesCopy = this.selectedStrokes.map(index => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke)) { - // Deep copy freehand stroke - return stroke.map(point => ({...point})); - } else if (stroke[0]?.type) { - // Deep copy shape - return [{...stroke[0]}]; - } - }); - this.showStatus('Selection copied'); - } - - cutSelectedStrokes() { - this.copySelectedStrokes(); - this.deleteSelectedStrokes(); - this.showStatus('Selection cut'); - } - - pasteStrokes() { - if (!this.selectedStrokesCopy) { - this.showStatus('Nothing to paste'); - return; + + // Move the selection rectangle along with the strokes + if (this.selectionRect) { + this.selectionRect.x += dx; + this.selectionRect.y += dy; + } + + // Accumulate total movement + if (!this.moveSelection.totalDx) { + this.moveSelection.totalDx = 0; + } + if (!this.moveSelection.totalDy) { + this.moveSelection.totalDy = 0; + } + this.moveSelection.totalDx += dx; + this.moveSelection.totalDy += dy; + } + + copySelectedStrokes() { + this.selectedStrokesCopy = this.selectedStrokes.map((index) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke)) { + // Deep copy freehand stroke + return stroke.map((point) => ({ ...point })); + } else if (stroke[0]?.type) { + // Deep copy shape + return [{ ...stroke[0] }]; } - - // Create paste operation - const pasteOp = { - type: 'paste', - newStrokes: [] - }; - - // Add offset to avoid exact overlap - const offset = 20; - this.selectedStrokes = []; - - this.selectedStrokesCopy.forEach(stroke => { - if (Array.isArray(stroke)) { - // Paste freehand stroke with offset - const newStroke = stroke.map(point => ({ - ...point, - x: point.x + offset, - y: point.y + offset - })); - this.drawingHistory.push(newStroke); - const newIndex = this.drawingHistory.length - 1; - this.selectedStrokes.push(newIndex); - pasteOp.newStrokes.push({ index: newIndex, stroke: newStroke }); - } else if (stroke[0]?.type) { - // Paste shape with offset - const newShape = [{ + }); + this.showStatus("Selection copied"); + } + + cutSelectedStrokes() { + this.copySelectedStrokes(); + this.deleteSelectedStrokes(); + this.showStatus("Selection cut"); + } + + pasteStrokes() { + if (!this.selectedStrokesCopy) { + this.showStatus("Nothing to paste"); + return; + } + + // Create paste operation + const pasteOp = { + type: "paste", + newStrokes: [], + }; + + // Add offset to avoid exact overlap + const offset = 20; + this.selectedStrokes = []; + + this.selectedStrokesCopy.forEach((stroke) => { + if (Array.isArray(stroke)) { + // Paste freehand stroke with offset + const newStroke = stroke.map((point) => ({ + ...point, + x: point.x + offset, + y: point.y + offset, + })); + this.drawingHistory.push(newStroke); + const newIndex = this.drawingHistory.length - 1; + this.selectedStrokes.push(newIndex); + pasteOp.newStrokes.push({ index: newIndex, stroke: newStroke }); + } else if (stroke[0]?.type) { + // Paste shape with offset + const newShape = [ + { ...stroke[0], x: stroke[0].x + offset, - y: stroke[0].y + offset - }]; - this.drawingHistory.push(newShape); - const newIndex = this.drawingHistory.length - 1; - this.selectedStrokes.push(newIndex); - pasteOp.newStrokes.push({ index: newIndex, stroke: newShape }); - } - }); - - // Add paste operation to history - this.drawingHistory.push(pasteOp); - this.redoHistory = []; - this.redrawCanvas(); - this.showStatus('Selection pasted'); + y: stroke[0].y + offset, + }, + ]; + this.drawingHistory.push(newShape); + const newIndex = this.drawingHistory.length - 1; + this.selectedStrokes.push(newIndex); + pasteOp.newStrokes.push({ index: newIndex, stroke: newShape }); + } + }); + + // Add paste operation to history + this.drawingHistory.push(pasteOp); + this.redoHistory = []; + this.redrawCanvas(); + this.showStatus("Selection pasted"); + } + + deleteSelectedStrokes() { + if (this.selectedStrokes.length === 0) { + return; } - - deleteSelectedStrokes() { - if (this.selectedStrokes.length === 0) { - return; - } - - // Create delete operation - const deleteOp = { - type: 'delete', - strokes: this.selectedStrokes.map(index => ({ - index, - stroke: this.drawingHistory[index] - })) - }; - - // Sort indices in descending order to avoid shifting issues - const sortedIndices = [...this.selectedStrokes].sort((a, b) => b - a); - sortedIndices.forEach(index => { - this.drawingHistory.splice(index, 1); - }); - - // Add delete operation to history - this.drawingHistory.push(deleteOp); - - // Clear selection and selection rectangle - this.selectedStrokes = []; - this.selectionRect = null; - - this.redoHistory = []; - this.redrawCanvas(); - this.showStatus('Selection deleted'); - } - - // Clean up method - destroy() { - clearInterval(this.autoSaveInterval); - } - - // Helper method to draw a regular polygon - drawPolygon(ctx, x, y, radius, sides, startAngle = 0) { - ctx.beginPath(); - for (let i = 0; i < sides; i++) { - const angle = startAngle + (i * 2 * Math.PI / sides); - const pointX = x + radius * Math.cos(angle); - const pointY = y + radius * Math.sin(angle); - if (i === 0) { - ctx.moveTo(pointX, pointY); - } else { - ctx.lineTo(pointX, pointY); - } + + // Create delete operation + const deleteOp = { + type: "delete", + strokes: this.selectedStrokes.map((index) => ({ + index, + stroke: this.drawingHistory[index], + })), + }; + + // Sort indices in descending order to avoid shifting issues + const sortedIndices = [...this.selectedStrokes].sort((a, b) => b - a); + sortedIndices.forEach((index) => { + this.drawingHistory.splice(index, 1); + }); + + // Add delete operation to history + this.drawingHistory.push(deleteOp); + + // Clear selection and selection rectangle + this.selectedStrokes = []; + this.selectionRect = null; + + this.redoHistory = []; + this.redrawCanvas(); + this.showStatus("Selection deleted"); + } + + // Clean up method + destroy() { + clearInterval(this.autoSaveInterval); + } + + // Helper method to draw a regular polygon + drawPolygon(ctx, x, y, radius, sides, startAngle = 0) { + ctx.beginPath(); + for (let i = 0; i < sides; i++) { + const angle = startAngle + (i * 2 * Math.PI) / sides; + const pointX = x + radius * Math.cos(angle); + const pointY = y + radius * Math.sin(angle); + if (i === 0) { + ctx.moveTo(pointX, pointY); + } else { + ctx.lineTo(pointX, pointY); } - ctx.closePath(); } - - // Helper method to draw a star - drawStar(ctx, x, y, radius, points = 5, innerRadius = null) { - if (innerRadius === null) { - innerRadius = radius / 2; - } - - ctx.beginPath(); - for (let i = 0; i < points * 2; i++) { - const angle = (i * Math.PI) / points; - const r = i % 2 === 0 ? radius : innerRadius; - const pointX = x + r * Math.cos(angle - Math.PI / 2); - const pointY = y + r * Math.sin(angle - Math.PI / 2); - if (i === 0) { - ctx.moveTo(pointX, pointY); - } else { - ctx.lineTo(pointX, pointY); - } - } - ctx.closePath(); - } - - // Helper method to draw shape paths - drawShapePath(shape) { - const width = Math.abs(shape.width); - const height = Math.abs(shape.height); - const centerX = shape.x + shape.width / 2; - const centerY = shape.y + shape.height / 2; - const radius = Math.sqrt(shape.width * shape.width + shape.height * shape.height) / 2; - - this.ctx.beginPath(); - switch (shape.type) { - case 'rectangle': - this.ctx.rect(shape.x, shape.y, shape.width, shape.height); - break; - case 'circle': - this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); - break; - case 'line': - this.ctx.moveTo(shape.x, shape.y); - this.ctx.lineTo(shape.x + shape.width, shape.y + shape.height); - break; - case 'arrow': - // Draw the main line - this.ctx.moveTo(shape.x, shape.y); - this.ctx.lineTo(shape.x + shape.width, shape.y + shape.height); - - // Draw the arrowhead - const angle = Math.atan2(shape.height, shape.width); - const arrowLength = Math.min(20, Math.sqrt(width * width + height * height) / 3); - const arrowAngle = Math.PI / 6; // 30 degrees - - // Calculate arrowhead points - const x2 = shape.x + shape.width; - const y2 = shape.y + shape.height; - - // Draw the two lines of the arrowhead - this.ctx.moveTo(x2, y2); - this.ctx.lineTo( - x2 - arrowLength * Math.cos(angle - arrowAngle), - y2 - arrowLength * Math.sin(angle - arrowAngle) - ); - this.ctx.moveTo(x2, y2); - this.ctx.lineTo( - x2 - arrowLength * Math.cos(angle + arrowAngle), - y2 - arrowLength * Math.sin(angle + arrowAngle) - ); - break; - case 'hexagon': - this.drawPolygon(this.ctx, centerX, centerY, radius, 6, Math.PI / 6); - break; - case 'star': - this.drawStar(this.ctx, centerX, centerY, radius); - break; - } + ctx.closePath(); + } + + // Helper method to draw a star + drawStar(ctx, x, y, radius, points = 5, innerRadius = null) { + if (innerRadius === null) { + innerRadius = radius / 2; } - - // Add method to draw resize handles - drawResizeHandles() { - if (!this.selectionRect || this.selectedStrokes.length === 0) { - return; + + ctx.beginPath(); + for (let i = 0; i < points * 2; i++) { + const angle = (i * Math.PI) / points; + const r = i % 2 === 0 ? radius : innerRadius; + const pointX = x + r * Math.cos(angle - Math.PI / 2); + const pointY = y + r * Math.sin(angle - Math.PI / 2); + if (i === 0) { + ctx.moveTo(pointX, pointY); + } else { + ctx.lineTo(pointX, pointY); } - - const handles = [ - { x: this.selectionRect.x, y: this.selectionRect.y, cursor: 'nw-resize', position: 'nw' }, - { x: this.selectionRect.x + this.selectionRect.width, y: this.selectionRect.y, cursor: 'ne-resize', position: 'ne' }, - { x: this.selectionRect.x + this.selectionRect.width, y: this.selectionRect.y + this.selectionRect.height, cursor: 'se-resize', position: 'se' }, - { x: this.selectionRect.x, y: this.selectionRect.y + this.selectionRect.height, cursor: 'sw-resize', position: 'sw' } - ]; - - this.resizeHandles.positions = handles; - - // Draw handles - this.ctx.save(); - this.ctx.fillStyle = '#ffffff'; - this.ctx.strokeStyle = '#0066ff'; - this.ctx.lineWidth = 2; - - handles.forEach(handle => { - this.ctx.beginPath(); - this.ctx.rect( - handle.x - this.resizeHandles.size / 2, - handle.y - this.resizeHandles.size / 2, - this.resizeHandles.size, - this.resizeHandles.size + } + ctx.closePath(); + } + + // Helper method to draw shape paths + drawShapePath(shape) { + const width = Math.abs(shape.width); + const height = Math.abs(shape.height); + const centerX = shape.x + shape.width / 2; + const centerY = shape.y + shape.height / 2; + const radius = + Math.sqrt(shape.width * shape.width + shape.height * shape.height) / 2; + + this.ctx.beginPath(); + switch (shape.type) { + case "rectangle": + this.ctx.rect(shape.x, shape.y, shape.width, shape.height); + break; + case "circle": + this.ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); + break; + case "line": + this.ctx.moveTo(shape.x, shape.y); + this.ctx.lineTo(shape.x + shape.width, shape.y + shape.height); + break; + case "arrow": + // Draw the main line + this.ctx.moveTo(shape.x, shape.y); + this.ctx.lineTo(shape.x + shape.width, shape.y + shape.height); + + // Draw the arrowhead + const angle = Math.atan2(shape.height, shape.width); + const arrowLength = Math.min( + 20, + Math.sqrt(width * width + height * height) / 3, ); - this.ctx.fill(); - this.ctx.stroke(); - }); - - this.ctx.restore(); + const arrowAngle = Math.PI / 6; // 30 degrees + + // Calculate arrowhead points + const x2 = shape.x + shape.width; + const y2 = shape.y + shape.height; + + // Draw the two lines of the arrowhead + this.ctx.moveTo(x2, y2); + this.ctx.lineTo( + x2 - arrowLength * Math.cos(angle - arrowAngle), + y2 - arrowLength * Math.sin(angle - arrowAngle), + ); + this.ctx.moveTo(x2, y2); + this.ctx.lineTo( + x2 - arrowLength * Math.cos(angle + arrowAngle), + y2 - arrowLength * Math.sin(angle + arrowAngle), + ); + break; + case "hexagon": + this.drawPolygon(this.ctx, centerX, centerY, radius, 6, Math.PI / 6); + break; + case "star": + this.drawStar(this.ctx, centerX, centerY, radius); + break; } - - // Add method to check if a point is inside a resize handle - getResizeHandleAtPoint(point) { - if (!this.resizeHandles.positions.length) { - return null; - } - - for (const handle of this.resizeHandles.positions) { - const handleBounds = { - x: handle.x - this.resizeHandles.size / 2, - y: handle.y - this.resizeHandles.size / 2, - width: this.resizeHandles.size, - height: this.resizeHandles.size - }; - - if (this.isPointInRect(point, handleBounds)) { - return handle; - } - } + } + + // Add method to draw resize handles + drawResizeHandles() { + if (!this.selectionRect || this.selectedStrokes.length === 0) { + return; + } + + const handles = [ + { + x: this.selectionRect.x, + y: this.selectionRect.y, + cursor: "nw-resize", + position: "nw", + }, + { + x: this.selectionRect.x + this.selectionRect.width, + y: this.selectionRect.y, + cursor: "ne-resize", + position: "ne", + }, + { + x: this.selectionRect.x + this.selectionRect.width, + y: this.selectionRect.y + this.selectionRect.height, + cursor: "se-resize", + position: "se", + }, + { + x: this.selectionRect.x, + y: this.selectionRect.y + this.selectionRect.height, + cursor: "sw-resize", + position: "sw", + }, + ]; + + this.resizeHandles.positions = handles; + + // Draw handles + this.ctx.save(); + this.ctx.fillStyle = "#ffffff"; + this.ctx.strokeStyle = "#0066ff"; + this.ctx.lineWidth = 2; + + handles.forEach((handle) => { + this.ctx.beginPath(); + this.ctx.rect( + handle.x - this.resizeHandles.size / 2, + handle.y - this.resizeHandles.size / 2, + this.resizeHandles.size, + this.resizeHandles.size, + ); + this.ctx.fill(); + this.ctx.stroke(); + }); + + this.ctx.restore(); + } + + // Add method to check if a point is inside a resize handle + getResizeHandleAtPoint(point) { + if (!this.resizeHandles.positions.length) { return null; } - - // Add method to resize selected shapes - resizeSelectedShapes(handle, dx, dy) { - if (!this.selectionRect || this.selectedStrokes.length === 0) { - return; - } - - const originalRect = { ...this.selectionRect }; - let scaleX = 1, scaleY = 1; - let translateX = 0, translateY = 0; - - // Calculate scale factors based on which handle is being dragged - switch (handle.position) { - case 'nw': - scaleX = (originalRect.width - dx) / originalRect.width; - scaleY = (originalRect.height - dy) / originalRect.height; - translateX = dx; - translateY = dy; - this.selectionRect.x += dx; - this.selectionRect.y += dy; - this.selectionRect.width -= dx; - this.selectionRect.height -= dy; - break; - case 'ne': - scaleX = (originalRect.width + dx) / originalRect.width; - scaleY = (originalRect.height - dy) / originalRect.height; - translateY = dy; - this.selectionRect.y += dy; - this.selectionRect.width += dx; - this.selectionRect.height -= dy; - break; - case 'se': - scaleX = (originalRect.width + dx) / originalRect.width; - scaleY = (originalRect.height + dy) / originalRect.height; - this.selectionRect.width += dx; - this.selectionRect.height += dy; - break; - case 'sw': - scaleX = (originalRect.width - dx) / originalRect.width; - scaleY = (originalRect.height + dy) / originalRect.height; - translateX = dx; - this.selectionRect.x += dx; - this.selectionRect.width -= dx; - this.selectionRect.height += dy; - break; + + for (const handle of this.resizeHandles.positions) { + const handleBounds = { + x: handle.x - this.resizeHandles.size / 2, + y: handle.y - this.resizeHandles.size / 2, + width: this.resizeHandles.size, + height: this.resizeHandles.size, + }; + + if (this.isPointInRect(point, handleBounds)) { + return handle; } - - // Update all selected shapes - this.selectedStrokes.forEach(index => { - const stroke = this.drawingHistory[index]; - if (Array.isArray(stroke) && !stroke[0]?.type) { - // For freehand strokes - stroke.forEach(point => { - // Calculate point position relative to selection rect - const relX = (point.x - originalRect.x) / originalRect.width; - const relY = (point.y - originalRect.y) / originalRect.height; - - // Apply scaling and translation - point.x = originalRect.x + translateX + (relX * originalRect.width * scaleX); - point.y = originalRect.y + translateY + (relY * originalRect.height * scaleY); - }); - } else if (stroke[0]?.type) { - // For shapes - const shape = stroke[0]; - // Calculate shape position relative to selection rect - const relX = (shape.x - originalRect.x) / originalRect.width; - const relY = (shape.y - originalRect.y) / originalRect.height; - + } + return null; + } + + // Add method to resize selected shapes + resizeSelectedShapes(handle, dx, dy) { + if (!this.selectionRect || this.selectedStrokes.length === 0) { + return; + } + + const originalRect = { ...this.selectionRect }; + let scaleX = 1, + scaleY = 1; + let translateX = 0, + translateY = 0; + + // Calculate scale factors based on which handle is being dragged + switch (handle.position) { + case "nw": + scaleX = (originalRect.width - dx) / originalRect.width; + scaleY = (originalRect.height - dy) / originalRect.height; + translateX = dx; + translateY = dy; + this.selectionRect.x += dx; + this.selectionRect.y += dy; + this.selectionRect.width -= dx; + this.selectionRect.height -= dy; + break; + case "ne": + scaleX = (originalRect.width + dx) / originalRect.width; + scaleY = (originalRect.height - dy) / originalRect.height; + translateY = dy; + this.selectionRect.y += dy; + this.selectionRect.width += dx; + this.selectionRect.height -= dy; + break; + case "se": + scaleX = (originalRect.width + dx) / originalRect.width; + scaleY = (originalRect.height + dy) / originalRect.height; + this.selectionRect.width += dx; + this.selectionRect.height += dy; + break; + case "sw": + scaleX = (originalRect.width - dx) / originalRect.width; + scaleY = (originalRect.height + dy) / originalRect.height; + translateX = dx; + this.selectionRect.x += dx; + this.selectionRect.width -= dx; + this.selectionRect.height += dy; + break; + } + + // Update all selected shapes + this.selectedStrokes.forEach((index) => { + const stroke = this.drawingHistory[index]; + if (Array.isArray(stroke) && !stroke[0]?.type) { + // For freehand strokes + stroke.forEach((point) => { + // Calculate point position relative to selection rect + const relX = (point.x - originalRect.x) / originalRect.width; + const relY = (point.y - originalRect.y) / originalRect.height; + // Apply scaling and translation - shape.x = originalRect.x + translateX + (relX * originalRect.width * scaleX); - shape.y = originalRect.y + translateY + (relY * originalRect.height * scaleY); - shape.width *= scaleX; - shape.height *= scaleY; - } - }); - - // Update handle positions - this.updateResizeHandles(); + point.x = + originalRect.x + translateX + relX * originalRect.width * scaleX; + point.y = + originalRect.y + translateY + relY * originalRect.height * scaleY; + }); + } else if (stroke[0]?.type) { + // For shapes + const shape = stroke[0]; + // Calculate shape position relative to selection rect + const relX = (shape.x - originalRect.x) / originalRect.width; + const relY = (shape.y - originalRect.y) / originalRect.height; + + // Apply scaling and translation + shape.x = + originalRect.x + translateX + relX * originalRect.width * scaleX; + shape.y = + originalRect.y + translateY + relY * originalRect.height * scaleY; + shape.width *= scaleX; + shape.height *= scaleY; + } + }); + + // Update handle positions + this.updateResizeHandles(); + } + + updateResizeHandles() { + if (!this.selectionRect) { + return; } - - updateResizeHandles() { - if (!this.selectionRect) { - return; - } - - const rect = this.selectionRect; - const handleSize = this.resizeHandles.size; - - this.resizeHandles.positions = [ - { x: rect.x - handleSize/2, y: rect.y - handleSize/2, position: 'nw', cursor: 'nw-resize' }, - { x: rect.x + rect.width/2 - handleSize/2, y: rect.y - handleSize/2, position: 'n', cursor: 'n-resize' }, - { x: rect.x + rect.width - handleSize/2, y: rect.y - handleSize/2, position: 'ne', cursor: 'ne-resize' }, - { x: rect.x - handleSize/2, y: rect.y + rect.height/2 - handleSize/2, position: 'w', cursor: 'w-resize' }, - { x: rect.x + rect.width - handleSize/2, y: rect.y + rect.height/2 - handleSize/2, position: 'e', cursor: 'e-resize' }, - { x: rect.x - handleSize/2, y: rect.y + rect.height - handleSize/2, position: 'sw', cursor: 'sw-resize' }, - { x: rect.x + rect.width/2 - handleSize/2, y: rect.y + rect.height - handleSize/2, position: 's', cursor: 's-resize' }, - { x: rect.x + rect.width - handleSize/2, y: rect.y + rect.height - handleSize/2, position: 'se', cursor: 'se-resize' } - ]; - } - - // Add method to toggle readonly mode - setReadonly(readonly) { - this.readonly = readonly; - if (readonly) { - // Clear any ongoing operations - this.isDrawing = false; - this.isPanning = false; - this.isSelecting = false; - this.moveSelection.active = false; - this.resizeHandles.active = false; - this.selectedStrokes = []; - this.selectionRect = null; - this.currentStroke = []; - this.previewShape = null; - this.canvas.style.cursor = 'default'; - this.redrawCanvas(); - console.log('Read-only mode enabled'); - } else { - console.log('Edit mode enabled'); - } + + const rect = this.selectionRect; + const handleSize = this.resizeHandles.size; + + this.resizeHandles.positions = [ + { + x: rect.x - handleSize / 2, + y: rect.y - handleSize / 2, + position: "nw", + cursor: "nw-resize", + }, + { + x: rect.x + rect.width / 2 - handleSize / 2, + y: rect.y - handleSize / 2, + position: "n", + cursor: "n-resize", + }, + { + x: rect.x + rect.width - handleSize / 2, + y: rect.y - handleSize / 2, + position: "ne", + cursor: "ne-resize", + }, + { + x: rect.x - handleSize / 2, + y: rect.y + rect.height / 2 - handleSize / 2, + position: "w", + cursor: "w-resize", + }, + { + x: rect.x + rect.width - handleSize / 2, + y: rect.y + rect.height / 2 - handleSize / 2, + position: "e", + cursor: "e-resize", + }, + { + x: rect.x - handleSize / 2, + y: rect.y + rect.height - handleSize / 2, + position: "sw", + cursor: "sw-resize", + }, + { + x: rect.x + rect.width / 2 - handleSize / 2, + y: rect.y + rect.height - handleSize / 2, + position: "s", + cursor: "s-resize", + }, + { + x: rect.x + rect.width - handleSize / 2, + y: rect.y + rect.height - handleSize / 2, + position: "se", + cursor: "se-resize", + }, + ]; + } + + // Add method to toggle readonly mode + setReadonly(readonly) { + this.readonly = readonly; + if (readonly) { + // Clear any ongoing operations + this.isDrawing = false; + this.isPanning = false; + this.isSelecting = false; + this.moveSelection.active = false; + this.resizeHandles.active = false; + this.selectedStrokes = []; + this.selectionRect = null; + this.currentStroke = []; + this.previewShape = null; + this.canvas.style.cursor = "default"; + this.redrawCanvas(); + console.log("Read-only mode enabled"); + } else { + console.log("Edit mode enabled"); } - } \ No newline at end of file + } + + // Flip canvas horizontally + flipHorizontal() { + this.flipX = !this.flipX; + this.redrawCanvas(); + this.showStatus( + this.flipX ? "Canvas flipped horizontally" : "Horizontal flip removed", + ); + } + + // Flip canvas vertically + flipVertical() { + this.flipY = !this.flipY; + this.redrawCanvas(); + this.showStatus( + this.flipY ? "Canvas flipped vertically" : "Vertical flip removed", + ); + } + + // Rotate canvas clockwise by 90 degrees + rotateClockwise() { + this.rotation = (this.rotation + 90) % 360; + this.redrawCanvas(); + this.showStatus(`Canvas rotated to ${this.rotation}°`); + } + + // Rotate canvas counter-clockwise by 90 degrees + rotateCounterClockwise() { + this.rotation = (this.rotation - 90 + 360) % 360; + this.redrawCanvas(); + this.showStatus(`Canvas rotated to ${this.rotation}°`); + } + + // Reset all transformations (flip and rotation) + resetTransformations() { + this.flipX = false; + this.flipY = false; + this.rotation = 0; + this.redrawCanvas(); + this.showStatus("All transformations reset"); + } +} diff --git a/app/static/js/coloris.js b/app/static/js/coloris.js new file mode 100644 index 0000000..d77aa16 --- /dev/null +++ b/app/static/js/coloris.js @@ -0,0 +1,1269 @@ +/*! + * Copyright (c) 2021 Momo Bassit. + * Licensed under the MIT License (MIT) + * https://github.com/mdbassit/Coloris + */ + +((window, document, Math, undefined) => { + const ctx = document.createElement('canvas').getContext('2d'); + const currentColor = { r: 0, g: 0, b: 0, h: 0, s: 0, v: 0, a: 1 }; + let container, picker, colorArea, colorMarker, colorPreview, colorValue, clearButton, closeButton, + hueSlider, hueMarker, alphaSlider, alphaMarker, currentEl, currentFormat, oldColor, keyboardNav, + colorAreaDims = {}; + + // Default settings + const settings = { + el: '[data-coloris]', + parent: 'body', + theme: 'default', + themeMode: 'light', + rtl: false, + wrap: true, + margin: 2, + format: 'hex', + formatToggle : false, + swatches: [], + swatchesOnly: false, + alpha: true, + forceAlpha: false, + focusInput: true, + selectInput: false, + inline: false, + defaultColor: '#000000', + clearButton: false, + clearLabel: 'Clear', + closeButton: false, + closeLabel: 'Close', + onChange: () => undefined, + a11y: { + open: 'Open color picker', + close: 'Close color picker', + clear: 'Clear the selected color', + marker: 'Saturation: {s}. Brightness: {v}.', + hueSlider: 'Hue slider', + alphaSlider: 'Opacity slider', + input: 'Color value field', + format: 'Color format', + swatch: 'Color swatch', + instruction: 'Saturation and brightness selector. Use up, down, left and right arrow keys to select.' + } + }; + + // Virtual instances cache + const instances = {}; + let currentInstanceId = ''; + let defaultInstance = {}; + let hasInstance = false; + + /** + * Configure the color picker. + * @param {object} options Configuration options. + */ + function configure(options) { + if (typeof options !== 'object') { + return; + } + + for (const key in options) { + switch (key) { + case 'el': + bindFields(options.el); + if (options.wrap !== false) { + wrapFields(options.el); + } + break; + case 'parent': + container = options.parent instanceof HTMLElement ? options.parent : document.querySelector(options.parent); + if (container) { + container.appendChild(picker); + settings.parent = options.parent; + + // document.body is special + if (container === document.body) { + container = undefined; + } + } + break; + case 'themeMode': + settings.themeMode = options.themeMode; + if (options.themeMode === 'auto' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + settings.themeMode = 'dark'; + } + // The lack of a break statement is intentional + case 'theme': + if (options.theme) { + settings.theme = options.theme; + } + + // Set the theme and color scheme + picker.className = `clr-picker clr-${settings.theme} clr-${settings.themeMode}`; + + // Update the color picker's position if inline mode is in use + if (settings.inline) { + updatePickerPosition(); + } + break; + case 'rtl': + settings.rtl = !!options.rtl; + Array.from(document.getElementsByClassName('clr-field')).forEach(field => field.classList.toggle('clr-rtl', settings.rtl)); + break; + case 'margin': + options.margin *= 1; + settings.margin = !isNaN(options.margin) ? options.margin : settings.margin; + break; + case 'wrap': + if (options.el && options.wrap) { + wrapFields(options.el); + } + break; + case 'formatToggle': + settings.formatToggle = !!options.formatToggle; + getEl('clr-format').style.display = settings.formatToggle ? 'block' : 'none'; + if (settings.formatToggle) { + settings.format = 'auto'; + } + break; + case 'swatches': + if (Array.isArray(options.swatches)) { + const swatchesContainer = getEl('clr-swatches'); + const swatches = document.createElement('div'); + + // Clear current swatches + swatchesContainer.textContent = ''; + + // Build new swatches + options.swatches.forEach((swatch, i) => { + const button = document.createElement('button'); + + button.setAttribute('type', `button`); + button.setAttribute('id', `clr-swatch-${i}`); + button.setAttribute('aria-labelledby', `clr-swatch-label clr-swatch-${i}`); + button.style.color = swatch; + button.textContent = swatch; + + swatches.appendChild(button); + }); + + // Append new swatches if any + if (options.swatches.length) { + swatchesContainer.appendChild(swatches); + } + + settings.swatches = options.swatches.slice(); + } + break; + case 'swatchesOnly': + settings.swatchesOnly = !!options.swatchesOnly; + picker.setAttribute('data-minimal', settings.swatchesOnly); + break; + case 'alpha': + settings.alpha = !!options.alpha; + picker.setAttribute('data-alpha', settings.alpha); + break; + case 'inline': + settings.inline = !!options.inline; + picker.setAttribute('data-inline', settings.inline); + + if (settings.inline) { + const defaultColor = options.defaultColor || settings.defaultColor; + + currentFormat = getColorFormatFromStr(defaultColor); + updatePickerPosition(); + setColorFromStr(defaultColor); + } + break; + case 'clearButton': + // Backward compatibility + if (typeof options.clearButton === 'object') { + if (options.clearButton.label) { + settings.clearLabel = options.clearButton.label; + clearButton.innerHTML = settings.clearLabel; + } + + options.clearButton = options.clearButton.show; + } + + settings.clearButton = !!options.clearButton; + clearButton.style.display = settings.clearButton ? 'block' : 'none'; + break; + case 'clearLabel': + settings.clearLabel = options.clearLabel; + clearButton.innerHTML = settings.clearLabel; + break; + case 'closeButton': + settings.closeButton = !!options.closeButton; + + if (settings.closeButton) { + picker.insertBefore(closeButton, colorPreview); + } else { + colorPreview.appendChild(closeButton); + } + + break; + case 'closeLabel': + settings.closeLabel = options.closeLabel; + closeButton.innerHTML = settings.closeLabel; + break; + case 'a11y': + const labels = options.a11y; + let update = false; + + if (typeof labels === 'object') { + for (const label in labels) { + if (labels[label] && settings.a11y[label]) { + settings.a11y[label] = labels[label]; + update = true; + } + } + } + + if (update) { + const openLabel = getEl('clr-open-label'); + const swatchLabel = getEl('clr-swatch-label'); + + openLabel.innerHTML = settings.a11y.open; + swatchLabel.innerHTML = settings.a11y.swatch; + closeButton.setAttribute('aria-label', settings.a11y.close); + clearButton.setAttribute('aria-label', settings.a11y.clear); + hueSlider.setAttribute('aria-label', settings.a11y.hueSlider); + alphaSlider.setAttribute('aria-label', settings.a11y.alphaSlider); + colorValue.setAttribute('aria-label', settings.a11y.input); + colorArea.setAttribute('aria-label', settings.a11y.instruction); + } + break; + default: + settings[key] = options[key]; + } + } + } + + /** + * Add or update a virtual instance. + * @param {String} selector The CSS selector of the elements to which the instance is attached. + * @param {Object} options Per-instance options to apply. + */ + function setVirtualInstance(selector, options) { + if (typeof selector === 'string' && typeof options === 'object') { + instances[selector] = options; + hasInstance = true; + } + } + + /** + * Remove a virtual instance. + * @param {String} selector The CSS selector of the elements to which the instance is attached. + */ + function removeVirtualInstance(selector) { + delete instances[selector]; + + if (Object.keys(instances).length === 0) { + hasInstance = false; + + if (selector === currentInstanceId) { + resetVirtualInstance(); + } + } + } + + /** + * Attach a virtual instance to an element if it matches a selector. + * @param {Object} element Target element that will receive a virtual instance if applicable. + */ + function attachVirtualInstance(element) { + if (hasInstance) { + // These options can only be set globally, not per instance + const unsupportedOptions = ['el', 'wrap', 'rtl', 'inline', 'defaultColor', 'a11y']; + + for (let selector in instances) { + const options = instances[selector]; + + // If the element matches an instance's CSS selector + if (element.matches(selector)) { + currentInstanceId = selector; + defaultInstance = {}; + + // Delete unsupported options + unsupportedOptions.forEach(option => delete options[option]); + + // Back up the default options so we can restore them later + for (let option in options) { + defaultInstance[option] = Array.isArray(settings[option]) ? settings[option].slice() : settings[option]; + } + + // Set the instance's options + configure(options); + break; + } + } + } + } + + /** + * Revert any per-instance options that were previously applied. + */ + function resetVirtualInstance() { + if (Object.keys(defaultInstance).length > 0) { + configure(defaultInstance); + currentInstanceId = ''; + defaultInstance = {}; + } + } + + /** + * Bind the color picker to input fields that match the selector. + * @param {(string|HTMLElement|HTMLElement[])} selector A CSS selector string, a DOM element or a list of DOM elements. + */ + function bindFields(selector) { + if (selector instanceof HTMLElement) { + selector = [selector]; + } + + if (Array.isArray(selector)) { + selector.forEach(field => { + addListener(field, 'click', openPicker); + addListener(field, 'input', updateColorPreview); + }); + } else { + addListener(document, 'click', selector, openPicker); + addListener(document, 'input', selector, updateColorPreview); + } + } + + /** + * Open the color picker. + * @param {object} event The event that opens the color picker. + */ + function openPicker(event) { + // Skip if inline mode is in use + if (settings.inline) { + return; + } + + // Apply any per-instance options first + attachVirtualInstance(event.target); + + currentEl = event.target; + oldColor = currentEl.value; + currentFormat = getColorFormatFromStr(oldColor); + picker.classList.add('clr-open'); + + updatePickerPosition(); + setColorFromStr(oldColor); + + if (settings.focusInput || settings.selectInput) { + colorValue.focus({ preventScroll: true }); + colorValue.setSelectionRange(currentEl.selectionStart, currentEl.selectionEnd); + } + + if (settings.selectInput) { + colorValue.select(); + } + + // Always focus the first element when using keyboard navigation + if (keyboardNav || settings.swatchesOnly) { + getFocusableElements().shift().focus(); + } + + // Trigger an "open" event + currentEl.dispatchEvent(new Event('open', { bubbles: false })); + } + + /** + * Update the color picker's position and the color gradient's offset + */ + function updatePickerPosition() { + const parent = container; + const scrollY = window.scrollY; + const pickerWidth = picker.offsetWidth; + const pickerHeight = picker.offsetHeight; + const reposition = { left: false, top: false }; + let parentStyle, parentMarginTop, parentBorderTop; + let offset = { x: 0, y: 0 }; + + if (parent) { + parentStyle = window.getComputedStyle(parent); + parentMarginTop = parseFloat(parentStyle.marginTop); + parentBorderTop = parseFloat(parentStyle.borderTopWidth); + + offset = parent.getBoundingClientRect(); + offset.y += parentBorderTop + scrollY; + } + + if (!settings.inline) { + const coords = currentEl.getBoundingClientRect(); + let left = coords.x; + let top = scrollY + coords.y + coords.height + settings.margin; + + // If the color picker is inside a custom container + // set the position relative to it + if (parent) { + left -= offset.x; + top -= offset.y; + + if (left + pickerWidth > parent.clientWidth) { + left += coords.width - pickerWidth; + reposition.left = true; + } + + if (top + pickerHeight > parent.clientHeight - parentMarginTop) { + if (pickerHeight + settings.margin <= coords.top - (offset.y - scrollY)) { + top -= coords.height + pickerHeight + settings.margin * 2; + reposition.top = true; + } + } + + top += parent.scrollTop; + + // Otherwise set the position relative to the whole document + } else { + if (left + pickerWidth > document.documentElement.clientWidth) { + left += coords.width - pickerWidth; + reposition.left = true; + } + + if (top + pickerHeight - scrollY > document.documentElement.clientHeight) { + if (pickerHeight + settings.margin <= coords.top) { + top = scrollY + coords.y - pickerHeight - settings.margin; + reposition.top = true; + } + } + } + + picker.classList.toggle('clr-left', reposition.left); + picker.classList.toggle('clr-top', reposition.top); + picker.style.left = `${left}px`; + picker.style.top = `${top}px`; + offset.x += picker.offsetLeft; + offset.y += picker.offsetTop; + } + + colorAreaDims = { + width: colorArea.offsetWidth, + height: colorArea.offsetHeight, + x: colorArea.offsetLeft + offset.x, + y: colorArea.offsetTop + offset.y + }; + } + + /** + * Wrap the linked input fields in a div that adds a color preview. + * @param {(string|HTMLElement|HTMLElement[])} selector A CSS selector string, a DOM element or a list of DOM elements. + */ + function wrapFields(selector) { + if (selector instanceof HTMLElement) { + wrapColorField(selector); + } else if (Array.isArray(selector)) { + selector.forEach(wrapColorField); + } else { + document.querySelectorAll(selector).forEach(wrapColorField); + } + } + + /** + * Wrap an input field in a div that adds a color preview. + * @param {object} field The input field. + */ + function wrapColorField(field) { + const parentNode = field.parentNode; + + if (!parentNode.classList.contains('clr-field')) { + const wrapper = document.createElement('div'); + let classes = 'clr-field'; + + if (settings.rtl || field.classList.contains('clr-rtl')) { + classes += ' clr-rtl'; + } + + wrapper.innerHTML = ''; + parentNode.insertBefore(wrapper, field); + wrapper.className = classes; + wrapper.style.color = field.value; + wrapper.appendChild(field); + } + } + + /** + * Update the color preview of an input field + * @param {object} event The "input" event that triggers the color change. + */ + function updateColorPreview(event) { + const parent = event.target.parentNode; + + // Only update the preview if the field has been previously wrapped + if (parent.classList.contains('clr-field')) { + parent.style.color = event.target.value; + } + } + + /** + * Close the color picker. + * @param {boolean} [revert] If true, revert the color to the original value. + */ + function closePicker(revert) { + if (currentEl && !settings.inline) { + const prevEl = currentEl; + + // Revert the color to the original value if needed + if (revert) { + // This will prevent the "change" event on the colorValue input to execute its handler + currentEl = undefined; + + if (oldColor !== prevEl.value) { + prevEl.value = oldColor; + + // Trigger an "input" event to force update the thumbnail next to the input field + prevEl.dispatchEvent(new Event('input', { bubbles: true })); + } + } + + // Trigger a "change" event if needed + setTimeout(() => { // Add this to the end of the event loop + if (oldColor !== prevEl.value) { + prevEl.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + + // Hide the picker dialog + picker.classList.remove('clr-open'); + + // Reset any previously set per-instance options + if (hasInstance) { + resetVirtualInstance(); + } + + // Trigger a "close" event + prevEl.dispatchEvent(new Event('close', { bubbles: false })); + + if (settings.focusInput) { + prevEl.focus({ preventScroll: true }); + } + + // This essentially marks the picker as closed + currentEl = undefined; + } + } + + /** + * Set the active color from a string. + * @param {string} str String representing a color. + */ + function setColorFromStr(str) { + const rgba = strToRGBA(str); + const hsva = RGBAtoHSVA(rgba); + + updateMarkerA11yLabel(hsva.s, hsva.v); + updateColor(rgba, hsva); + + // Update the UI + hueSlider.value = hsva.h; + picker.style.color = `hsl(${hsva.h}, 100%, 50%)`; + hueMarker.style.left = `${hsva.h / 360 * 100}%`; + + colorMarker.style.left = `${colorAreaDims.width * hsva.s / 100}px`; + colorMarker.style.top = `${colorAreaDims.height - (colorAreaDims.height * hsva.v / 100)}px`; + + alphaSlider.value = hsva.a * 100; + alphaMarker.style.left = `${hsva.a * 100}%`; + } + + /** + * Guess the color format from a string. + * @param {string} str String representing a color. + * @return {string} The color format. + */ + function getColorFormatFromStr(str) { + const format = str.substring(0, 3).toLowerCase(); + + if (format === 'rgb' || format === 'hsl' ) { + return format; + } + + return 'hex'; + } + + /** + * Copy the active color to the linked input field. + * @param {number} [color] Color value to override the active color. + */ + function pickColor(color) { + color = color !== undefined ? color : colorValue.value; + + if (currentEl) { + currentEl.value = color; + currentEl.dispatchEvent(new Event('input', { bubbles: true })); + } + + if (settings.onChange) { + settings.onChange.call(window, color, currentEl); + } + + document.dispatchEvent(new CustomEvent('coloris:pick', { detail: { color, currentEl } })); + } + + /** + * Set the active color based on a specific point in the color gradient. + * @param {number} x Left position. + * @param {number} y Top position. + */ + function setColorAtPosition(x, y) { + const hsva = { + h: hueSlider.value * 1, + s: x / colorAreaDims.width * 100, + v: 100 - (y / colorAreaDims.height * 100), + a: alphaSlider.value / 100 + }; + const rgba = HSVAtoRGBA(hsva); + + updateMarkerA11yLabel(hsva.s, hsva.v); + updateColor(rgba, hsva); + pickColor(); + } + + /** + * Update the color marker's accessibility label. + * @param {number} saturation + * @param {number} value + */ + function updateMarkerA11yLabel(saturation, value) { + let label = settings.a11y.marker; + + saturation = saturation.toFixed(1) * 1; + value = value.toFixed(1) * 1; + label = label.replace('{s}', saturation); + label = label.replace('{v}', value); + colorMarker.setAttribute('aria-label', label); + } + + // + /** + * Get the pageX and pageY positions of the pointer. + * @param {object} event The MouseEvent or TouchEvent object. + * @return {object} The pageX and pageY positions. + */ + function getPointerPosition(event) { + return { + pageX: event.changedTouches ? event.changedTouches[0].pageX : event.pageX, + pageY: event.changedTouches ? event.changedTouches[0].pageY : event.pageY + }; + } + + /** + * Move the color marker when dragged. + * @param {object} event The MouseEvent object. + */ + function moveMarker(event) { + const pointer = getPointerPosition(event); + let x = pointer.pageX - colorAreaDims.x; + let y = pointer.pageY - colorAreaDims.y; + + if (container) { + y += container.scrollTop; + } + + setMarkerPosition(x, y); + + // Prevent scrolling while dragging the marker + event.preventDefault(); + event.stopPropagation(); + } + + /** + * Move the color marker when the arrow keys are pressed. + * @param {number} offsetX The horizontal amount to move. + * @param {number} offsetY The vertical amount to move. + */ + function moveMarkerOnKeydown(offsetX, offsetY) { + let x = colorMarker.style.left.replace('px', '') * 1 + offsetX; + let y = colorMarker.style.top.replace('px', '') * 1 + offsetY; + + setMarkerPosition(x, y); + } + + /** + * Set the color marker's position. + * @param {number} x Left position. + * @param {number} y Top position. + */ + function setMarkerPosition(x, y) { + // Make sure the marker doesn't go out of bounds + x = (x < 0) ? 0 : (x > colorAreaDims.width) ? colorAreaDims.width : x; + y = (y < 0) ? 0 : (y > colorAreaDims.height) ? colorAreaDims.height : y; + + // Set the position + colorMarker.style.left = `${x}px`; + colorMarker.style.top = `${y}px`; + + // Update the color + setColorAtPosition(x, y); + + // Make sure the marker is focused + colorMarker.focus(); + } + + /** + * Update the color picker's input field and preview thumb. + * @param {Object} rgba Red, green, blue and alpha values. + * @param {Object} [hsva] Hue, saturation, value and alpha values. + */ + function updateColor(rgba = {}, hsva = {}) { + let format = settings.format; + + for (const key in rgba) { + currentColor[key] = rgba[key]; + } + + for (const key in hsva) { + currentColor[key] = hsva[key]; + } + + const hex = RGBAToHex(currentColor); + const opaqueHex = hex.substring(0, 7); + + colorMarker.style.color = opaqueHex; + alphaMarker.parentNode.style.color = opaqueHex; + alphaMarker.style.color = hex; + colorPreview.style.color = hex; + + // Force repaint the color and alpha gradients as a workaround for a Google Chrome bug + colorArea.style.display = 'none'; + colorArea.offsetHeight; + colorArea.style.display = ''; + alphaMarker.nextElementSibling.style.display = 'none'; + alphaMarker.nextElementSibling.offsetHeight; + alphaMarker.nextElementSibling.style.display = ''; + + if (format === 'mixed') { + format = currentColor.a === 1 ? 'hex' : 'rgb'; + } else if (format === 'auto') { + format = currentFormat; + } + + switch (format) { + case 'hex': + colorValue.value = hex; + break; + case 'rgb': + colorValue.value = RGBAToStr(currentColor); + break; + case 'hsl': + colorValue.value = HSLAToStr(HSVAtoHSLA(currentColor)); + break; + } + + // Select the current format in the format switcher + document.querySelector(`.clr-format [value="${format}"]`).checked = true; + } + + /** + * Set the hue when its slider is moved. + */ + function setHue() { + const hue = hueSlider.value * 1; + const x = colorMarker.style.left.replace('px', '') * 1; + const y = colorMarker.style.top.replace('px', '') * 1; + + picker.style.color = `hsl(${hue}, 100%, 50%)`; + hueMarker.style.left = `${hue / 360 * 100}%`; + + setColorAtPosition(x, y); + } + + /** + * Set the alpha when its slider is moved. + */ + function setAlpha() { + const alpha = alphaSlider.value / 100; + + alphaMarker.style.left = `${alpha * 100}%`; + updateColor({ a: alpha }); + pickColor(); + } + + /** + * Convert HSVA to RGBA. + * @param {object} hsva Hue, saturation, value and alpha values. + * @return {object} Red, green, blue and alpha values. + */ + function HSVAtoRGBA(hsva) { + const saturation = hsva.s / 100; + const value = hsva.v / 100; + let chroma = saturation * value; + let hueBy60 = hsva.h / 60; + let x = chroma * (1 - Math.abs(hueBy60 % 2 - 1)); + let m = value - chroma; + + chroma = (chroma + m); + x = (x + m); + + const index = Math.floor(hueBy60) % 6; + const red = [chroma, x, m, m, x, chroma][index]; + const green = [x, chroma, chroma, x, m, m][index]; + const blue = [m, m, x, chroma, chroma, x][index]; + + return { + r: Math.round(red * 255), + g: Math.round(green * 255), + b: Math.round(blue * 255), + a: hsva.a + }; + } + + /** + * Convert HSVA to HSLA. + * @param {object} hsva Hue, saturation, value and alpha values. + * @return {object} Hue, saturation, lightness and alpha values. + */ + function HSVAtoHSLA(hsva) { + const value = hsva.v / 100; + const lightness = value * (1 - (hsva.s / 100) / 2); + let saturation; + + if (lightness > 0 && lightness < 1) { + saturation = Math.round((value - lightness) / Math.min(lightness, 1 - lightness) * 100); + } + + return { + h: hsva.h, + s: saturation || 0, + l: Math.round(lightness * 100), + a: hsva.a + }; + } + + /** + * Convert RGBA to HSVA. + * @param {object} rgba Red, green, blue and alpha values. + * @return {object} Hue, saturation, value and alpha values. + */ + function RGBAtoHSVA(rgba) { + const red = rgba.r / 255; + const green = rgba.g / 255; + const blue = rgba.b / 255; + const xmax = Math.max(red, green, blue); + const xmin = Math.min(red, green, blue); + const chroma = xmax - xmin; + const value = xmax; + let hue = 0; + let saturation = 0; + + if (chroma) { + if (xmax === red ) { hue = ((green - blue) / chroma); } + if (xmax === green ) { hue = 2 + (blue - red) / chroma; } + if (xmax === blue ) { hue = 4 + (red - green) / chroma; } + if (xmax) { saturation = chroma / xmax; } + } + + hue = Math.floor(hue * 60); + + return { + h: hue < 0 ? hue + 360 : hue, + s: Math.round(saturation * 100), + v: Math.round(value * 100), + a: rgba.a + }; + } + + /** + * Parse a string to RGBA. + * @param {string} str String representing a color. + * @return {object} Red, green, blue and alpha values. + */ + function strToRGBA(str) { + const regex = /^((rgba)|rgb)[\D]+([\d.]+)[\D]+([\d.]+)[\D]+([\d.]+)[\D]*?([\d.]+|$)/i; + let match, rgba; + + // Default to black for invalid color strings + ctx.fillStyle = '#000'; + + // Use canvas to convert the string to a valid color string + ctx.fillStyle = str; + match = regex.exec(ctx.fillStyle); + + if (match) { + rgba = { + r: match[3] * 1, + g: match[4] * 1, + b: match[5] * 1, + a: match[6] * 1 + }; + + } else { + match = ctx.fillStyle.replace('#', '').match(/.{2}/g).map(h => parseInt(h, 16)); + rgba = { + r: match[0], + g: match[1], + b: match[2], + a: 1 + }; + } + + return rgba; + } + + /** + * Convert RGBA to Hex. + * @param {object} rgba Red, green, blue and alpha values. + * @return {string} Hex color string. + */ + function RGBAToHex(rgba) { + let R = rgba.r.toString(16); + let G = rgba.g.toString(16); + let B = rgba.b.toString(16); + let A = ''; + + if (rgba.r < 16) { + R = '0' + R; + } + + if (rgba.g < 16) { + G = '0' + G; + } + + if (rgba.b < 16) { + B = '0' + B; + } + + if (settings.alpha && (rgba.a < 1 || settings.forceAlpha)) { + const alpha = rgba.a * 255 | 0; + A = alpha.toString(16); + + if (alpha < 16) { + A = '0' + A; + } + } + + return '#' + R + G + B + A; + } + + /** + * Convert RGBA values to a CSS rgb/rgba string. + * @param {object} rgba Red, green, blue and alpha values. + * @return {string} CSS color string. + */ + function RGBAToStr(rgba) { + if (!settings.alpha || (rgba.a === 1 && !settings.forceAlpha)) { + return `rgb(${rgba.r}, ${rgba.g}, ${rgba.b})`; + } else { + return `rgba(${rgba.r}, ${rgba.g}, ${rgba.b}, ${rgba.a})`; + } + } + + /** + * Convert HSLA values to a CSS hsl/hsla string. + * @param {object} hsla Hue, saturation, lightness and alpha values. + * @return {string} CSS color string. + */ + function HSLAToStr(hsla) { + if (!settings.alpha || (hsla.a === 1 && !settings.forceAlpha)) { + return `hsl(${hsla.h}, ${hsla.s}%, ${hsla.l}%)`; + } else { + return `hsla(${hsla.h}, ${hsla.s}%, ${hsla.l}%, ${hsla.a})`; + } + } + + /** + * Init the color picker. + */ + function init() { + // Render the UI + container = undefined; + picker = document.createElement('div'); + picker.setAttribute('id', 'clr-picker'); + picker.className = 'clr-picker'; + picker.innerHTML = + ``+ + `
`+ + '
'+ + '
'+ + '
'+ + ``+ + '
'+ + '
'+ + '
'+ + ``+ + '
'+ + ''+ + '
'+ + '
'+ + '
'+ + `${settings.a11y.format}`+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + ''+ + '
'+ + '
'+ + '
'+ + ``+ + '
'+ + ``+ + '
'+ + ``+ + ``; + + // Append the color picker to the DOM + document.body.appendChild(picker); + + // Reference the UI elements + colorArea = getEl('clr-color-area'); + colorMarker = getEl('clr-color-marker'); + clearButton = getEl('clr-clear'); + closeButton = getEl('clr-close'); + colorPreview = getEl('clr-color-preview'); + colorValue = getEl('clr-color-value'); + hueSlider = getEl('clr-hue-slider'); + hueMarker = getEl('clr-hue-marker'); + alphaSlider = getEl('clr-alpha-slider'); + alphaMarker = getEl('clr-alpha-marker'); + + // Bind the picker to the default selector + bindFields(settings.el); + wrapFields(settings.el); + + addListener(picker, 'mousedown', event => { + picker.classList.remove('clr-keyboard-nav'); + event.stopPropagation(); + }); + + addListener(colorArea, 'mousedown', event => { + addListener(document, 'mousemove', moveMarker); + }); + + addListener(colorArea, 'contextmenu', event => { + event.preventDefault(); + }); + + addListener(colorArea, 'touchstart', event => { + document.addEventListener('touchmove', moveMarker, { passive: false }); + }); + + addListener(colorMarker, 'mousedown', event => { + addListener(document, 'mousemove', moveMarker); + }); + + addListener(colorMarker, 'touchstart', event => { + document.addEventListener('touchmove', moveMarker, { passive: false }); + }); + + addListener(colorValue, 'change', event => { + const value = colorValue.value; + + if (currentEl || settings.inline) { + const color = value === '' ? value : setColorFromStr(value); + pickColor(color); + } + }); + + addListener(clearButton, 'click', event => { + pickColor(''); + closePicker(); + }); + + addListener(closeButton, 'click', event => { + pickColor(); + closePicker(); + }); + + addListener(getEl('clr-format'), 'click', '.clr-format input', event => { + currentFormat = event.target.value; + updateColor(); + pickColor(); + }); + + addListener(picker, 'click', '.clr-swatches button', event => { + setColorFromStr(event.target.textContent); + pickColor(); + + if (settings.swatchesOnly) { + closePicker(); + } + }); + + addListener(document, 'mouseup', event => { + document.removeEventListener('mousemove', moveMarker); + }); + + addListener(document, 'touchend', event => { + document.removeEventListener('touchmove', moveMarker); + }); + + addListener(document, 'mousedown', event => { + keyboardNav = false; + picker.classList.remove('clr-keyboard-nav'); + closePicker(); + }); + + addListener(document, 'keydown', event => { + const key = event.key; + const target = event.target; + const shiftKey = event.shiftKey; + const navKeys = ['Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']; + + if (key === 'Escape') { + closePicker(true); + return; + + // Close the color picker and keep the selected color on press on Enter + } else if (key === 'Enter' && target.tagName !== 'BUTTON') { + closePicker(); + return; + + // Display focus rings when using the keyboard + } else if (navKeys.includes(key)) { + keyboardNav = true; + picker.classList.add('clr-keyboard-nav'); + } + + // Trap the focus within the color picker while it's open + if (key === 'Tab' && target.matches('.clr-picker *')) { + const focusables = getFocusableElements(); + const firstFocusable = focusables.shift(); + const lastFocusable = focusables.pop(); + + if (shiftKey && target === firstFocusable) { + lastFocusable.focus(); + event.preventDefault(); + } else if (!shiftKey && target === lastFocusable) { + firstFocusable.focus(); + event.preventDefault(); + } + } + }); + + addListener(document, 'click', '.clr-field button', event => { + // Reset any previously set per-instance options + if (hasInstance) { + resetVirtualInstance(); + } + + // Open the color picker + event.target.nextElementSibling.dispatchEvent(new Event('click', { bubbles: true })); + }); + + addListener(colorMarker, 'keydown', event => { + const movements = { + ArrowUp: [0, -1], + ArrowDown: [0, 1], + ArrowLeft: [-1, 0], + ArrowRight: [1, 0] + }; + + if (Object.keys(movements).includes(event.key)) { + moveMarkerOnKeydown(...movements[event.key]); + event.preventDefault(); + } + }); + + addListener(colorArea, 'click', moveMarker); + addListener(hueSlider, 'input', setHue); + addListener(alphaSlider, 'input', setAlpha); + } + + /** + * Return a list of focusable elements within the color picker. + * @return {array} The list of focusable DOM elemnts. + */ + function getFocusableElements() { + const controls = Array.from(picker.querySelectorAll('input, button')); + const focusables = controls.filter(node => !!node.offsetWidth); + + return focusables; + } + + /** + * Shortcut for getElementById to optimize the minified JS. + * @param {string} id The element id. + * @return {object} The DOM element with the provided id. + */ + function getEl(id) { + return document.getElementById(id); + } + + /** + * Shortcut for addEventListener to optimize the minified JS. + * @param {object} context The context to which the listener is attached. + * @param {string} type Event type. + * @param {(string|function)} selector Event target if delegation is used, event handler if not. + * @param {function} [fn] Event handler if delegation is used. + */ + function addListener(context, type, selector, fn) { + const matches = Element.prototype.matches || Element.prototype.msMatchesSelector; + + // Delegate event to the target of the selector + if (typeof selector === 'string') { + context.addEventListener(type, event => { + if (matches.call(event.target, selector)) { + fn.call(event.target, event); + } + }); + + // If the selector is not a string then it's a function + // in which case we need a regular event listener + } else { + fn = selector; + context.addEventListener(type, fn); + } + } + + /** + * Call a function only when the DOM is ready. + * @param {function} fn The function to call. + * @param {array} [args] Arguments to pass to the function. + */ + function DOMReady(fn, args) { + args = args !== undefined ? args : []; + + if (document.readyState !== 'loading') { + fn(...args); + } else { + document.addEventListener('DOMContentLoaded', () => { + fn(...args); + }); + } + } + + // Polyfill for Nodelist.forEach + if (NodeList !== undefined && NodeList.prototype && !NodeList.prototype.forEach) { + NodeList.prototype.forEach = Array.prototype.forEach; + } + + // Expose the color picker to the global scope + window.Coloris = (() => { + const methods = { + set: configure, + wrap: wrapFields, + close: closePicker, + setInstance: setVirtualInstance, + removeInstance: removeVirtualInstance, + updatePosition: updatePickerPosition, + ready: DOMReady + }; + + function Coloris(options) { + DOMReady(() => { + if (options) { + if (typeof options === 'string') { + bindFields(options); + } else { + configure(options); + } + } + }); + } + + for (const key in methods) { + Coloris[key] = (...args) => { + DOMReady(methods[key], args); + }; + } + + return Coloris; + })(); + + // Init the color picker when the DOM is ready + DOMReady(init); + +})(window, document, Math); diff --git a/app/static/js/lighthouse/auton.js b/app/static/js/lighthouse/auton.js index 4f7f69c..679ccf9 100644 --- a/app/static/js/lighthouse/auton.js +++ b/app/static/js/lighthouse/auton.js @@ -1,14 +1,14 @@ // Constants -const API_ENDPOINT = '/api/team_paths'; +const API_ENDPOINT = "/api/team_paths"; const MAX_PATHS = 6; const MAX_PER_ALLIANCE = 3; const TEAM_COLORS = [ - '#2563eb', // blue - '#dc2626', // red - '#059669', // green - '#7c3aed', // purple - '#d97706', // amber - '#db2777' // pink + "#2563eb", // blue + "#dc2626", // red + "#059669", // green + "#7c3aed", // purple + "#d97706", // amber + "#db2777", // pink ]; // State @@ -18,258 +18,294 @@ let availablePaths = []; let currentTeam = null; // DOM Elements -document.addEventListener('DOMContentLoaded', () => { - // Initialize elements - const searchInput = document.getElementById('team-search'); - const searchBtn = document.getElementById('search-btn'); - const resetViewBtn = document.getElementById('reset-view-btn'); - const clearAllBtn = document.getElementById('clear-all-btn'); - const selectedPathsContainer = document.getElementById('selected-paths'); - - // Initialize canvas - initCanvas(); - - // Initialize Sortable.js for drag and drop - Sortable.create(selectedPathsContainer, { - animation: 150, - ghostClass: 'bg-gray-100', - onEnd: updatePathOrder +document.addEventListener("DOMContentLoaded", () => { + // Initialize elements + const searchInput = document.getElementById("team-search"); + const searchBtn = document.getElementById("search-btn"); + const resetViewBtn = document.getElementById("reset-view-btn"); + const clearAllBtn = document.getElementById("clear-all-btn"); + const flipHorizontalBtn = document.getElementById("flip-horizontal-btn"); + const flipVerticalBtn = document.getElementById("flip-vertical-btn"); + const rotateCwBtn = document.getElementById("rotate-cw-btn"); + const rotateCcwBtn = document.getElementById("rotate-ccw-btn"); + const selectedPathsContainer = document.getElementById("selected-paths"); + + // Initialize canvas + initCanvas(); + + // Initialize Sortable.js for drag and drop + Sortable.create(selectedPathsContainer, { + animation: 150, + ghostClass: "bg-gray-100", + onEnd: updatePathOrder, + }); + + // Add event listeners + searchBtn.addEventListener("click", searchTeam); + searchInput.addEventListener("keypress", (e) => { + if (e.key === "Enter") { + searchTeam(); + } + }); + resetViewBtn.addEventListener("click", () => { + canvasField.resizeCanvas(); + canvasField.resetView(); + canvasField.redrawCanvas(); + console.log("View reset to origin"); + }); + clearAllBtn.addEventListener("click", clearAllPaths); + + // Add flip and rotate button listeners + if (flipHorizontalBtn) { + flipHorizontalBtn.addEventListener("click", () => { + if (canvasField) { + canvasField.flipHorizontal(); + } }); - - // Add event listeners - searchBtn.addEventListener('click', searchTeam); - searchInput.addEventListener('keypress', e => { - if (e.key === 'Enter') { - searchTeam(); - } + } + + if (flipVerticalBtn) { + flipVerticalBtn.addEventListener("click", () => { + if (canvasField) { + canvasField.flipVertical(); + } }); - resetViewBtn.addEventListener('click', () => { - canvasField.resizeCanvas(); - canvasField.resetView(); - canvasField.redrawCanvas(); - console.log('View reset to origin'); + } + + if (rotateCwBtn) { + rotateCwBtn.addEventListener("click", () => { + if (canvasField) { + canvasField.rotateClockwise(); + } + }); + } + + if (rotateCcwBtn) { + rotateCcwBtn.addEventListener("click", () => { + if (canvasField) { + canvasField.rotateCounterClockwise(); + } }); - clearAllBtn.addEventListener('click', clearAllPaths); + } }); // Initialize Canvas function initCanvas() { + try { + const container = document.getElementById("canvas-container"); + const canvas = document.getElementById("pathCanvas"); + + if (!container || !canvas) { + console.error("Canvas container or canvas element not found"); + return; + } + + console.log("Initializing canvas with container:", container); + + // Check if Canvas.js is loaded + if (typeof Canvas !== "function") { + console.error( + "Canvas class not found! Make sure Canvas.js is loaded properly.", + ); + return; + } + + // Make sure the canvas is visible and has dimensions + if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) { + console.warn("Canvas has zero dimensions. Check CSS and layout."); + } + + // Create canvas with error handling try { - const container = document.getElementById('canvas-container'); - const canvas = document.getElementById('pathCanvas'); - - if (!container || !canvas) { - console.error('Canvas container or canvas element not found'); - return; - } - - console.log('Initializing canvas with container:', container); - - // Check if Canvas.js is loaded - if (typeof Canvas !== 'function') { - console.error('Canvas class not found! Make sure Canvas.js is loaded properly.'); - return; - } - - // Make sure the canvas is visible and has dimensions - if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) { - console.warn('Canvas has zero dimensions. Check CSS and layout.'); - } - - // Create canvas with error handling - try { - canvasField = new Canvas({ - canvas: canvas, - container: container, - backgroundImage: '/static/images/field-2026.png', - maxPanDistance: 1000, - // Add a simple status display function - showStatus: (message) => { - console.log(`Canvas status: ${message}`); - } - }); - - console.log('Canvas initialized successfully'); - } catch (initError) { - console.error('Failed to initialize Canvas:', initError); - alert('Failed to initialize drawing canvas. Please reload the page.'); - return; - } - - // Apply initial reset to ensure proper sizing - try { - canvasField.resetView(); - console.log('Canvas view reset'); - } catch (resetError) { - console.error('Error resetting canvas view:', resetError); - } - - // Set to readonly mode + canvasField = new Canvas({ + canvas: canvas, + container: container, + backgroundImage: "/static/images/field-2026.png", + maxPanDistance: 1000, + // Add a simple status display function + showStatus: (message) => { + console.log(`Canvas status: ${message}`); + }, + }); + + console.log("Canvas initialized successfully"); + } catch (initError) { + console.error("Failed to initialize Canvas:", initError); + alert("Failed to initialize drawing canvas. Please reload the page."); + return; + } + + // Apply initial reset to ensure proper sizing + try { + canvasField.resetView(); + console.log("Canvas view reset"); + } catch (resetError) { + console.error("Error resetting canvas view:", resetError); + } + + // Set to readonly mode + try { + canvasField.setReadonly(true); + console.log("Canvas set to readonly mode"); + } catch (readonlyError) { + console.error("Error setting canvas to readonly mode:", readonlyError); + } + + // Handle window resize + window.addEventListener("resize", () => { + if (canvasField) { try { - canvasField.setReadonly(true); - console.log('Canvas set to readonly mode'); - } catch (readonlyError) { - console.error('Error setting canvas to readonly mode:', readonlyError); + canvasField.resizeCanvas(); + } catch (resizeError) { + console.error("Error resizing canvas:", resizeError); } - - // Handle window resize - window.addEventListener('resize', () => { - if (canvasField) { - try { - canvasField.resizeCanvas(); - } catch (resizeError) { - console.error('Error resizing canvas:', resizeError); - } - } - }); - } catch (error) { - console.error('Critical error initializing canvas:', error); - alert('Could not initialize the canvas. Please reload the page.'); - } + } + }); + } catch (error) { + console.error("Critical error initializing canvas:", error); + alert("Could not initialize the canvas. Please reload the page."); + } } // Search for a team async function searchTeam() { - const teamNumber = document.getElementById('team-search').value.trim(); - const searchBtn = document.getElementById('search-btn'); - - if (!teamNumber) { - alert('Please enter a team number'); - return; + const teamNumber = document.getElementById("team-search").value.trim(); + const searchBtn = document.getElementById("search-btn"); + + if (!teamNumber) { + alert("Please enter a team number"); + return; + } + + try { + // Show loading state + searchBtn.innerHTML = ''; + + const response = await fetch(`${API_ENDPOINT}?team=${teamNumber}`); + + // Check if the response is ok + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Server error (${response.status}): ${errorText}`); } - + + let data; try { - // Show loading state - searchBtn.innerHTML = ''; - - const response = await fetch(`${API_ENDPOINT}?team=${teamNumber}`); - - // Check if the response is ok - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Server error (${response.status}): ${errorText}`); - } - - let data; - try { - data = await response.json(); - } catch (jsonError) { - throw new Error(`Failed to parse response: ${jsonError.message}`); - } - - console.log('Team data:', data); - - // Check if data is valid - if (!data || typeof data !== 'object') { - throw new Error('Invalid response format from server'); - } - - // Update current team - currentTeam = { - number: data.team_number, - info: data.team_info || { - nickname: 'Unknown', - city: '', - state_prov: '', - country: '' - } - }; - - // Update available paths - availablePaths = data.paths || []; - - // Update the UI - updateTeamInfo(data); - updateAvailablePaths(); - - } catch (error) { - console.error('Error searching team:', error); - alert(`Error: ${error.message}`); - } finally { - // Reset button state - searchBtn.textContent = 'Search'; + data = await response.json(); + } catch (jsonError) { + throw new Error(`Failed to parse response: ${jsonError.message}`); } + + console.log("Team data:", data); + + // Check if data is valid + if (!data || typeof data !== "object") { + throw new Error("Invalid response format from server"); + } + + // Update current team + currentTeam = { + number: data.team_number, + info: data.team_info || { + nickname: "Unknown", + city: "", + state_prov: "", + country: "", + }, + }; + + // Update available paths + availablePaths = data.paths || []; + + // Update the UI + updateTeamInfo(data); + updateAvailablePaths(); + } catch (error) { + console.error("Error searching team:", error); + alert(`Error: ${error.message}`); + } finally { + // Reset button state + searchBtn.textContent = "Search"; + } } // Update team info in the UI function updateTeamInfo(data) { - if (!data) { - console.error('No data provided to updateTeamInfo'); - return; - } - - const searchResults = document.getElementById('search-results'); - const teamName = document.getElementById('team-name'); - const teamInfo = document.getElementById('team-info'); - - if (!searchResults || !teamName || !teamInfo) { - console.error('Required DOM elements not found for team info update'); - return; - } - - const teamNumber = data.team_number || 'Unknown'; - const info = data.team_info || {}; - const nickname = info.nickname || 'Unknown'; - - // Update team name and info - teamName.textContent = `Team ${teamNumber} - ${nickname}`; - - const location = [ - info.city, - info.state_prov, - info.country - ].filter(part => part && part.trim()).join(', '); - - teamInfo.textContent = location || 'Location unknown'; - - // Show the results section - searchResults.classList.remove('hidden'); + if (!data) { + console.error("No data provided to updateTeamInfo"); + return; + } + + const searchResults = document.getElementById("search-results"); + const teamName = document.getElementById("team-name"); + const teamInfo = document.getElementById("team-info"); + + if (!searchResults || !teamName || !teamInfo) { + console.error("Required DOM elements not found for team info update"); + return; + } + + const teamNumber = data.team_number || "Unknown"; + const info = data.team_info || {}; + const nickname = info.nickname || "Unknown"; + + // Update team name and info + teamName.textContent = `Team ${teamNumber} - ${nickname}`; + + const location = [info.city, info.state_prov, info.country] + .filter((part) => part && part.trim()) + .join(", "); + + teamInfo.textContent = location || "Location unknown"; + + // Show the results section + searchResults.classList.remove("hidden"); } // Update available paths in the UI function updateAvailablePaths() { - const availablePathsContainer = document.getElementById('available-paths'); - const pathCountAvailable = document.getElementById('path-count-available'); - const noPathsMessage = document.getElementById('no-paths-message'); - - // Clear existing cards (not the no-paths-message) - Array.from(availablePathsContainer.children).forEach(child => { - if (child.id !== 'no-paths-message') { - child.remove(); - } - }); - - // Update path count - pathCountAvailable.textContent = `(${availablePaths.length})`; - - // Show/hide no paths message - if (availablePaths.length === 0) { - noPathsMessage.classList.remove('hidden'); - return; - } else { - noPathsMessage.classList.add('hidden'); + const availablePathsContainer = document.getElementById("available-paths"); + const pathCountAvailable = document.getElementById("path-count-available"); + const noPathsMessage = document.getElementById("no-paths-message"); + + // Clear existing cards (not the no-paths-message) + Array.from(availablePathsContainer.children).forEach((child) => { + if (child.id !== "no-paths-message") { + child.remove(); } - - // Add each path as a card - availablePaths.forEach((path, index) => { - const card = document.createElement('div'); - card.className = 'path-card'; - card.dataset.index = index; - - // Alliance color indicator border - if (path.alliance === 'red' || path.alliance === 'blue') { - card.classList.add(`border-l-4`); - card.classList.add(`border-${path.alliance}-500`); - } - - card.innerHTML = ` + }); + + // Update path count + pathCountAvailable.textContent = `(${availablePaths.length})`; + + // Show/hide no paths message + if (availablePaths.length === 0) { + noPathsMessage.classList.remove("hidden"); + return; + } else { + noPathsMessage.classList.add("hidden"); + } + + // Add each path as a card + availablePaths.forEach((path, index) => { + const card = document.createElement("div"); + card.className = "path-card"; + card.dataset.index = index; + + // Alliance color indicator border + if (path.alliance === "red" || path.alliance === "blue") { + card.classList.add(`border-l-4`); + card.classList.add(`border-${path.alliance}-500`); + } + + card.innerHTML = `
Match ${path.match_number}
${path.event_name || path.event_code}
- ${path.alliance ? `${path.alliance}` : ''} - ${path.auto_notes ? `${path.auto_notes}` : ''} + ${path.alliance ? `${path.alliance}` : ""} + ${path.auto_notes ? `${path.auto_notes}` : ""}
`; - - // Add event listener - card.querySelector('.add-path-btn').addEventListener('click', () => { - addPathToSelection(index); - }); - - availablePathsContainer.appendChild(card); + + // Add event listener + card.querySelector(".add-path-btn").addEventListener("click", () => { + addPathToSelection(index); }); - - // If we have many paths, add a scroll hint - if (availablePaths.length > 2) { - const scrollHint = document.createElement('div'); - scrollHint.className = 'text-xs text-gray-500 text-center mt-2'; - scrollHint.textContent = 'Scroll to see more paths'; - availablePathsContainer.appendChild(scrollHint); - } + + availablePathsContainer.appendChild(card); + }); + + // If we have many paths, add a scroll hint + if (availablePaths.length > 2) { + const scrollHint = document.createElement("div"); + scrollHint.className = "text-xs text-gray-500 text-center mt-2"; + scrollHint.textContent = "Scroll to see more paths"; + availablePathsContainer.appendChild(scrollHint); + } } // Add a path to the selected paths function addPathToSelection(index) { - if (selectedPaths.length >= MAX_PATHS) { - alert(`Cannot add more than ${MAX_PATHS} paths`); - return; - } - - const path = availablePaths[index]; - if (!path) { - return; - } - - // Check if this path is already selected - if (selectedPaths.some(p => p.id === path._id)) { - alert('This path is already selected'); - return; - } - - // Get alliance and check limits - const alliance = path.alliance || 'unknown'; - const allianceCount = selectedPaths.filter(p => p.alliance === alliance).length; - - // Check if we've reached the per-alliance limit - if (allianceCount >= MAX_PER_ALLIANCE) { - alert(`Cannot add more than ${MAX_PER_ALLIANCE} teams from the ${alliance} alliance`); - return; - } - - // Add to selected paths - const colorIndex = selectedPaths.length; - const newPath = { - id: path._id, - teamNumber: path.team_number, - teamName: currentTeam?.info?.nickname || '', - matchNumber: path.match_number, - eventCode: path.event_code, - alliance: alliance, - pathData: path.auto_path, - notes: path.auto_notes, - color: TEAM_COLORS[colorIndex % TEAM_COLORS.length] - }; - - selectedPaths.push(newPath); - - // Update UI - updateSelectedPaths(); - drawPaths(); + if (selectedPaths.length >= MAX_PATHS) { + alert(`Cannot add more than ${MAX_PATHS} paths`); + return; + } + + const path = availablePaths[index]; + if (!path) { + return; + } + + // Check if this path is already selected + if (selectedPaths.some((p) => p.id === path._id)) { + alert("This path is already selected"); + return; + } + + // Get alliance and check limits + const alliance = path.alliance || "unknown"; + const allianceCount = selectedPaths.filter( + (p) => p.alliance === alliance, + ).length; + + // Check if we've reached the per-alliance limit + if (allianceCount >= MAX_PER_ALLIANCE) { + alert( + `Cannot add more than ${MAX_PER_ALLIANCE} teams from the ${alliance} alliance`, + ); + return; + } + + // Add to selected paths + const colorIndex = selectedPaths.length; + const newPath = { + id: path._id, + teamNumber: path.team_number, + teamName: currentTeam?.info?.nickname || "", + matchNumber: path.match_number, + eventCode: path.event_code, + alliance: alliance, + pathData: path.auto_path, + notes: path.auto_notes, + color: TEAM_COLORS[colorIndex % TEAM_COLORS.length], + }; + + selectedPaths.push(newPath); + + // Update UI + updateSelectedPaths(); + drawPaths(); } // Update the selected paths in the UI function updateSelectedPaths() { - const selectedPathsContainer = document.getElementById('selected-paths'); - const emptyPrompt = document.getElementById('empty-prompt'); - const pathCount = document.getElementById('path-count'); - - // Update path count - pathCount.textContent = selectedPaths.length; - - // Show/hide empty prompt - if (selectedPaths.length === 0) { - emptyPrompt.classList.remove('hidden'); - } else { - emptyPrompt.classList.add('hidden'); + const selectedPathsContainer = document.getElementById("selected-paths"); + const emptyPrompt = document.getElementById("empty-prompt"); + const pathCount = document.getElementById("path-count"); + + // Update path count + pathCount.textContent = selectedPaths.length; + + // Show/hide empty prompt + if (selectedPaths.length === 0) { + emptyPrompt.classList.remove("hidden"); + } else { + emptyPrompt.classList.add("hidden"); + } + + // Remove all existing path cards + const existingCards = selectedPathsContainer.querySelectorAll( + ".selected-path-card", + ); + existingCards.forEach((card) => card.remove()); + + // Add each selected path as a card + selectedPaths.forEach((path, index) => { + const card = document.createElement("div"); + card.className = "selected-path-card path-card flex items-center"; + card.dataset.id = path.id; + + // Add an alliance class for styling + if (path.alliance === "red" || path.alliance === "blue") { + card.classList.add(`border-l-4`); + card.classList.add(`border-${path.alliance}-500`); } - - // Remove all existing path cards - const existingCards = selectedPathsContainer.querySelectorAll('.selected-path-card'); - existingCards.forEach(card => card.remove()); - - // Add each selected path as a card - selectedPaths.forEach((path, index) => { - const card = document.createElement('div'); - card.className = 'selected-path-card path-card flex items-center'; - card.dataset.id = path.id; - - // Add an alliance class for styling - if (path.alliance === 'red' || path.alliance === 'blue') { - card.classList.add(`border-l-4`); - card.classList.add(`border-${path.alliance}-500`); - } - - // Format team name display - include name in parentheses if available - const teamDisplay = path.teamName - ? `Team ${path.teamNumber} (${path.teamName})` - : `Team ${path.teamNumber}`; - - card.innerHTML = ` + + // Format team name display - include name in parentheses if available + const teamDisplay = path.teamName + ? `Team ${path.teamNumber} (${path.teamName})` + : `Team ${path.teamNumber}`; + + card.innerHTML = `
${teamDisplay} - Match ${path.matchNumber}
${path.eventCode} - ${path.alliance ? - `${path.alliance}` - : ''} + ${ + path.alliance + ? `${path.alliance}` + : "" + }
× `; - - // Add event listener for removal - card.querySelector('.remove-path').addEventListener('click', () => { - removePath(index); - }); - - selectedPathsContainer.appendChild(card); + + // Add event listener for removal + card.querySelector(".remove-path").addEventListener("click", () => { + removePath(index); }); + + selectedPathsContainer.appendChild(card); + }); } // Remove a path from the selection function removePath(index) { - if (index >= 0 && index < selectedPaths.length) { - selectedPaths.splice(index, 1); - updateSelectedPaths(); - drawPaths(); - } + if (index >= 0 && index < selectedPaths.length) { + selectedPaths.splice(index, 1); + updateSelectedPaths(); + drawPaths(); + } } // Update path order after drag and drop function updatePathOrder(evt) { - if (evt.oldIndex !== evt.newIndex) { - const path = selectedPaths.splice(evt.oldIndex, 1)[0]; - selectedPaths.splice(evt.newIndex, 0, path); - drawPaths(); - } + if (evt.oldIndex !== evt.newIndex) { + const path = selectedPaths.splice(evt.oldIndex, 1)[0]; + selectedPaths.splice(evt.newIndex, 0, path); + drawPaths(); + } } // Draw all selected paths on the canvas function drawPaths() { - if (!canvasField) { - console.error('Canvas not initialized'); - return; + if (!canvasField) { + console.error("Canvas not initialized"); + return; + } + + try { + // Clear the canvas and prepare for drawing all paths + canvasField.drawingHistory = []; + canvasField.redrawCanvas(); + + if (selectedPaths.length === 0) { + console.log("No paths to draw"); + return; } - - try { - // Clear the canvas and prepare for drawing all paths - canvasField.drawingHistory = []; - canvasField.redrawCanvas(); - - if (selectedPaths.length === 0) { - console.log('No paths to draw'); + + console.log(`Drawing ${selectedPaths.length} paths`); + + // Prepare a combined drawing history for all paths + let combinedDrawingHistory = []; + + // First collect and process all paths + selectedPaths.forEach((path, pathIndex) => { + try { + // Process the path data + let pathToDraw = path.pathData; + + // Skip if no path data + if (!pathToDraw) { + console.warn(`No path data available for path ${pathIndex}`); + return; + } + + // Handle string data if needed + if (typeof pathToDraw === "string") { + try { + // Remove any potential HTML entities + const sanitizedValue = pathToDraw + .trim() + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + // Parse the JSON data + pathToDraw = JSON.parse(sanitizedValue); + } catch (parseError) { + console.error( + `Error parsing path data for path ${pathIndex}:`, + parseError, + ); return; + } } - - console.log(`Drawing ${selectedPaths.length} paths`); - - // Prepare a combined drawing history for all paths - let combinedDrawingHistory = []; - - // First collect and process all paths - selectedPaths.forEach((path, pathIndex) => { - try { - // Process the path data - let pathToDraw = path.pathData; - - // Skip if no path data - if (!pathToDraw) { - console.warn(`No path data available for path ${pathIndex}`); - return; - } - - // Handle string data if needed - if (typeof pathToDraw === 'string') { - try { - // Remove any potential HTML entities - const sanitizedValue = pathToDraw.trim() - .replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); - - // Parse the JSON data - pathToDraw = JSON.parse(sanitizedValue); - } catch (parseError) { - console.error(`Error parsing path data for path ${pathIndex}:`, parseError); - return; - } - } - - // Ensure pathToDraw is an array - if (!Array.isArray(pathToDraw)) { - console.warn(`Path data is not an array for path ${pathIndex}:`, pathToDraw); - return; - } - - // Skip empty paths - if (pathToDraw.length === 0) { - console.warn(`Path data is empty for path ${pathIndex}`); - return; - } - - console.log(`Processing path ${pathIndex} with color ${path.color}, ${pathToDraw.length} strokes`); - - // Make a deep copy of the path data - const processedPath = JSON.parse(JSON.stringify(pathToDraw)); - - // Apply the color to each point in the path - processedPath.forEach(stroke => { - if (Array.isArray(stroke)) { - // For freehand strokes - stroke.forEach(point => { - if (point) { - point.color = path.color; - } - }); - } else if (stroke && typeof stroke === 'object') { - // For shapes - stroke.color = path.color; - } - }); - - // Add the processed path to our combined history - combinedDrawingHistory = combinedDrawingHistory.concat(processedPath); - - } catch (pathError) { - console.error(`General error processing path ${pathIndex}:`, pathError, path); - } - }); - - // Now draw all paths at once - try { - // Temporarily set canvas to not readonly to allow drawing - canvasField.setReadonly(false); - - // Set the drawing history to our combined paths - canvasField.drawingHistory = combinedDrawingHistory; - - // Redraw everything - canvasField.redrawCanvas(); - - // Set back to readonly - canvasField.setReadonly(true); - - console.log(`Successfully drew ${combinedDrawingHistory.length} strokes from ${selectedPaths.length} paths`); - } catch (drawError) { - console.error('Error drawing combined paths:', drawError); + + // Ensure pathToDraw is an array + if (!Array.isArray(pathToDraw)) { + console.warn( + `Path data is not an array for path ${pathIndex}:`, + pathToDraw, + ); + return; + } + + // Skip empty paths + if (pathToDraw.length === 0) { + console.warn(`Path data is empty for path ${pathIndex}`); + return; } - - } catch (error) { - console.error('Critical error in drawPaths:', error); + + console.log( + `Processing path ${pathIndex} with color ${path.color}, ${pathToDraw.length} strokes`, + ); + + // Make a deep copy of the path data + const processedPath = JSON.parse(JSON.stringify(pathToDraw)); + + // Apply the color to each point in the path + processedPath.forEach((stroke) => { + if (Array.isArray(stroke)) { + // For freehand strokes + stroke.forEach((point) => { + if (point) { + point.color = path.color; + } + }); + } else if (stroke && typeof stroke === "object") { + // For shapes + stroke.color = path.color; + } + }); + + // Add the processed path to our combined history + combinedDrawingHistory = combinedDrawingHistory.concat(processedPath); + } catch (pathError) { + console.error( + `General error processing path ${pathIndex}:`, + pathError, + path, + ); + } + }); + + // Now draw all paths at once + try { + // Temporarily set canvas to not readonly to allow drawing + canvasField.setReadonly(false); + + // Set the drawing history to our combined paths + canvasField.drawingHistory = combinedDrawingHistory; + + // Redraw everything + canvasField.redrawCanvas(); + + // Set back to readonly + canvasField.setReadonly(true); + + console.log( + `Successfully drew ${combinedDrawingHistory.length} strokes from ${selectedPaths.length} paths`, + ); + } catch (drawError) { + console.error("Error drawing combined paths:", drawError); } + } catch (error) { + console.error("Critical error in drawPaths:", error); + } } // Clear all selected paths function clearAllPaths() { - if (confirm('Are you sure you want to clear all selected paths?')) { - selectedPaths = []; - updateSelectedPaths(); - - // Clear the canvas directly - if (canvasField) { - canvasField.drawingHistory = []; - canvasField.redrawCanvas(); - } + if (confirm("Are you sure you want to clear all selected paths?")) { + selectedPaths = []; + updateSelectedPaths(); + + // Clear the canvas directly + if (canvasField) { + canvasField.drawingHistory = []; + canvasField.redrawCanvas(); } + } } diff --git a/app/static/js/pit-scouting/list.js b/app/static/js/pit-scouting/list.js index 66cc10a..1d1977e 100644 --- a/app/static/js/pit-scouting/list.js +++ b/app/static/js/pit-scouting/list.js @@ -1,151 +1,160 @@ function exportToCSV() { - const headers = [ - 'Team Number', - 'Drive Type', - 'Swerve Modules', - 'Motor Count', - 'Motor Types', - 'Dimensions (L x W x H)', - 'Programming Language', - 'Has Auto', - 'Auto Routes', - 'Preferred Start', - 'Auto Notes', - 'Driver Experience', - 'Driver Notes', - 'Scouter' - ]; - - let csvContent = headers.join(',') + '\n'; - - // Get all rows from the table - const rows = Array.from(document.querySelectorAll('tbody tr')); - - rows.forEach(row => { - try { - // Team Number - const teamNumber = row.querySelector('td:nth-child(1) .text-lg').textContent.trim(); - - // Drive Type & Swerve Modules - const driveTypeCell = row.querySelector('td:nth-child(2)'); - const driveTypes = Array.from(driveTypeCell.querySelectorAll('.rounded-full')) - .map(span => span.textContent.trim()) - .filter(text => text !== '') - .join('/'); - const swerveModules = driveTypeCell.querySelector('.text-sm.text-gray-500')?.textContent.trim() || ''; - - // Motors - const motorCell = row.querySelector('td:nth-child(3)'); - const motorText = motorCell.textContent.trim(); - const motorParts = motorText.split('/').map(part => part.trim()); - const motorCount = motorParts[0].split(' ')[0]; - const motorTypes = motorParts.slice(1).join('/'); - - // Dimensions - const dimensions = row.querySelector('td:nth-child(4)').textContent.trim(); - const hasClimber = !climberCell.textContent.includes('🗙'); - let climberType = '', climberNotes = ''; - if (hasClimber) { - const climberText = climberCell.textContent.trim(); - if (climberText.includes(' - ')) { - [climberType, climberNotes] = climberText.split(' - ').map(s => s.trim()); - } else { - climberType = climberText; - } - } - - // Programming & Auto - const progCell = row.querySelector('td:nth-child(8)'); - const progText = progCell.textContent.trim(); - const programmingLang = (progText.match(/Programming Language: (.+?)(?:\n|$)/) || ['', ''])[1].trim(); - const hasAuto = progText.includes('✅'); - const autoRoutes = (progText.match(/(\d+) routes/) || ['', ''])[1]; - const preferredStart = (progText.match(/Preferred Start: (.+?)(?:\n|$)/) || ['', ''])[1]; - const autoNotes = (progText.match(/Auton Notes: (.+?)(?:\n|$)/) || ['', ''])[1]; - - // Driver Experience - const driverCell = row.querySelector('td:nth-child(9)'); - const driverText = driverCell.textContent.trim(); - let driverExp = '', driverNotes = ''; - if (driverText.includes(' - ')) { - [driverExp, driverNotes] = driverText.split(' - ').map(s => s.trim()); - } - - // Scouter - const scouterCell = row.querySelector('td:nth-child(10)'); - let scouterName = ''; - const scouterLink = scouterCell.querySelector('a'); - if (scouterLink) { - scouterName = scouterLink.textContent.trim(); - } - - // Escape and format fields that might contain commas or quotes - const escapeField = (field) => { - if (!field) { - return ''; - } - const escaped = field.replace(/"/g, '""'); - return field.includes(',') || field.includes('"') || field.includes('\n') - ? `"${escaped}"` - : escaped; - }; - - const rowData = [ - escapeField(teamNumber), - escapeField(driveTypes), - escapeField(swerveModules), - escapeField(motorCount), - escapeField(motorTypes), - escapeField(dimensions), - hasClimber ? 'Yes' : 'No', - escapeField(programmingLang), - hasAuto ? 'Yes' : 'No', - escapeField(autoRoutes), - escapeField(preferredStart), - escapeField(autoNotes), - escapeField(driverExp), - escapeField(driverNotes), - escapeField(scouterName) - ]; - - csvContent += rowData.join(',') + '\n'; - } catch (error) { - console.error('Error processing row:', error); + const headers = [ + "Team Number", + "Drive Type", + "Swerve Modules", + "Motor Count", + "Motor Types", + "Dimensions (L x W x H)", + "Programming Language", + "Has Auto", + "Auto Routes", + "Preferred Start", + "Auto Notes", + "Driver Experience", + "Driver Notes", + "Scouter", + ]; + + let csvContent = headers.join(",") + "\n"; + + // Get all rows from the table + const rows = Array.from(document.querySelectorAll("tbody tr")); + + rows.forEach((row) => { + try { + // Team Number + const teamNumber = row + .querySelector("td:nth-child(1) .text-lg") + .textContent.trim(); + + // Drive Type & Swerve Modules + const driveTypeCell = row.querySelector("td:nth-child(2)"); + const driveTypes = Array.from( + driveTypeCell.querySelectorAll(".rounded-full"), + ) + .map((span) => span.textContent.trim()) + .filter((text) => text !== "") + .join("/"); + const swerveModules = + driveTypeCell + .querySelector(".text-sm.text-gray-500") + ?.textContent.trim() || ""; + + // Motors + const motorCell = row.querySelector("td:nth-child(3)"); + const motorText = motorCell.textContent.trim(); + const motorParts = motorText.split("/").map((part) => part.trim()); + const motorCount = motorParts[0].split(" ")[0]; + const motorTypes = motorParts.slice(1).join("/"); + + // Dimensions + const dimensions = row + .querySelector("td:nth-child(4)") + .textContent.trim(); + + // Programming & Auto + const progCell = row.querySelector("td:nth-child(5)"); + const progText = progCell.textContent.trim(); + const programmingLang = (progText.match( + /Programming Language: (.+?)(?:\n|$)/, + ) || ["", ""])[1].trim(); + const hasAuto = progText.includes("✅"); + const autoRoutes = (progText.match(/(\d+) routes/) || ["", ""])[1]; + const preferredStart = (progText.match( + /Preferred Start: (.+?)(?:\n|$)/, + ) || ["", ""])[1]; + const autoNotes = (progText.match(/Auton Notes: (.+?)(?:\n|$)/) || [ + "", + "", + ])[1]; + + // Driver Experience + const driverCell = row.querySelector("td:nth-child(6)"); + const driverText = driverCell.textContent.trim(); + let driverExp = "", + driverNotes = ""; + if (driverText.includes(" - ")) { + [driverExp, driverNotes] = driverText.split(" - ").map((s) => s.trim()); + } + + // Scouter + const scouterCell = row.querySelector("td:nth-child(7)"); + const scouterLink = scouterCell.querySelector("a"); + const scouterSpan = scouterCell.querySelector("span"); + const scouterName = (scouterLink || scouterSpan)?.textContent.trim() || ""; + + // Escape and format fields that might contain commas or quotes + const escapeField = (field) => { + if (!field) { + return ""; } - }); + const escaped = field.replace(/"/g, '""'); + return field.includes(",") || + field.includes('"') || + field.includes("\n") + ? `"${escaped}"` + : escaped; + }; - // Create and trigger download - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - const url = URL.createObjectURL(blob); - link.setAttribute('href', url); - link.setAttribute('download', 'pit_scouting_data.csv'); - link.style.visibility = 'hidden'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -} + const rowData = [ + escapeField(teamNumber), + escapeField(driveTypes), + escapeField(swerveModules), + escapeField(motorCount), + escapeField(motorTypes), + escapeField(dimensions), + escapeField(programmingLang), + hasAuto ? "Yes" : "No", + escapeField(autoRoutes), + escapeField(preferredStart), + escapeField(autoNotes), + escapeField(driverExp), + escapeField(driverNotes), + escapeField(scouterName), + ]; -document.addEventListener('DOMContentLoaded', function() { - const exportButton = document.getElementById('exportCSV'); - if (exportButton) { - exportButton.addEventListener('click', exportToCSV); + csvContent += rowData.join(",") + "\n"; + } catch (error) { + console.error("Error processing row:", error); } + }); + + // Create and trigger download + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.setAttribute("href", url); + link.setAttribute("download", "pit_scouting_data.csv"); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +document.addEventListener("DOMContentLoaded", function () { + const exportButton = document.getElementById("exportCSV"); + if (exportButton) { + exportButton.addEventListener("click", exportToCSV); + } + + const searchInput = document.getElementById("teamSearchInput"); + const tableRows = document.querySelectorAll("tbody tr"); + + searchInput.addEventListener("input", function () { + const searchTerm = searchInput.value.trim(); + + tableRows.forEach((row) => { + const teamNumberCell = row.querySelector("td:first-child"); + if (teamNumberCell) { + const teamNumberText = teamNumberCell.textContent.trim(); - const searchInput = document.getElementById('teamSearchInput'); - const tableRows = document.querySelectorAll('tbody tr'); - - searchInput.addEventListener('input', function() { - const searchTerm = searchInput.value.trim(); - - tableRows.forEach(row => { - const teamNumberCell = row.querySelector('td:first-child'); - if (teamNumberCell) { - const teamNumberText = teamNumberCell.textContent.trim(); - - // Show/hide the row based on whether the team number contains the search term - row.style.display = searchTerm === '' || teamNumberText.includes(searchTerm) ? '' : 'none'; - } - }); + // Show/hide the row based on whether the team number contains the search term + row.style.display = + searchTerm === "" || teamNumberText.includes(searchTerm) + ? "" + : "none"; + } }); -}); \ No newline at end of file + }); +}); diff --git a/app/static/js/playoff-bracket.js b/app/static/js/playoff-bracket.js index 0982076..30fec06 100644 --- a/app/static/js/playoff-bracket.js +++ b/app/static/js/playoff-bracket.js @@ -290,7 +290,7 @@ function propagateBracket(sourceMatchId) { const winner = source.winner ? (source.winner === 'red' ? source.red : source.blue) : null; const loser = source.winner ? (source.winner === 'red' ? source.blue : source.red) : null; - // Double Elimination Bracket Mapping (2023+ FRC) + // Double Elimination Bracket Mapping const flow = { // Round 1 Upper 'm1': { win: { to: 'm7', role: 'red' }, lose: { to: 'm5', role: 'red' } }, diff --git a/app/static/js/scout/add.js b/app/static/js/scout/add.js index 4fcf188..52d8b29 100644 --- a/app/static/js/scout/add.js +++ b/app/static/js/scout/add.js @@ -1,800 +1,857 @@ const updateMatchResult = () => { - const allianceScore = parseInt(allianceScoreInput.value) || 0; - const opponentScore = parseInt(opponentScoreInput.value) || 0; - - if (allianceScore > opponentScore) { - matchResultInput.value = 'won'; - } else if (allianceScore < opponentScore) { - matchResultInput.value = 'lost'; - } else { - matchResultInput.value = 'tie'; - } + const allianceScore = parseInt(allianceScoreInput.value) || 0; + const opponentScore = parseInt(opponentScoreInput.value) || 0; + + if (allianceScore > opponentScore) { + matchResultInput.value = "won"; + } else if (allianceScore < opponentScore) { + matchResultInput.value = "lost"; + } else { + matchResultInput.value = "tie"; + } }; -document.addEventListener('DOMContentLoaded', function() { - // Event code input handling - const eventCodeInput = document.querySelector('input[name="event_code"]'); - if (eventCodeInput) { - eventCodeInput.addEventListener('input', function(e) { - this.value = this.value.toUpperCase(); - }); - } - - // Initialize CanvasField helper - const CanvasField = new Canvas({ - canvas: document.getElementById('autoPath'), - container: document.getElementById('autoPathContainer'), - externalUpdateUIControls: updateUIControls, - showStatus: (message) => { - const flashContainer = document.querySelector('.container'); - if (!flashContainer) { - return; - } - - const messageDiv = document.createElement('div'); - messageDiv.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up'; - - const innerDiv = document.createElement('div'); - innerDiv.className = 'flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200'; - - const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - icon.setAttribute('class', 'w-6 h-6 mr-3 flex-shrink-0'); - icon.setAttribute('fill', 'currentColor'); - icon.setAttribute('viewBox', '0 0 20 20'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('fill-rule', 'evenodd'); - path.setAttribute('d', 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'); - path.setAttribute('clip-rule', 'evenodd'); - - icon.appendChild(path); - - const text = document.createElement('p'); - text.className = 'text-base font-medium'; - text.textContent = message; - - const closeButton = document.createElement('button'); - closeButton.className = 'ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100'; - closeButton.onclick = () => messageDiv.remove(); - - const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - closeIcon.setAttribute('class', 'w-5 h-5'); - closeIcon.setAttribute('fill', 'currentColor'); - closeIcon.setAttribute('viewBox', '0 0 20 20'); - - const closePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - closePath.setAttribute('fill-rule', 'evenodd'); - closePath.setAttribute('d', 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'); - closePath.setAttribute('clip-rule', 'evenodd'); - - closeIcon.appendChild(closePath); - closeButton.appendChild(closeIcon); - - innerDiv.appendChild(icon); - innerDiv.appendChild(text); - innerDiv.appendChild(closeButton); - messageDiv.appendChild(innerDiv); - - flashContainer.appendChild(messageDiv); - - setTimeout(() => { - if (messageDiv.parentNode === flashContainer) { - messageDiv.remove(); - } - }, 3000); - }, - initialColor: '#2563eb', - initialThickness: 3, - maxPanDistance: 1000, - backgroundImage: '/static/images/field-2026.png', - readonly: false +document.addEventListener("DOMContentLoaded", function () { + // Event code input handling + const eventCodeInput = document.querySelector('input[name="event_code"]'); + if (eventCodeInput) { + eventCodeInput.addEventListener("input", function (e) { + this.value = this.value.toUpperCase(); }); - - // Verify background image loading - const testImage = new Image(); - testImage.onload = () => { - console.log('Background image loaded successfully'); - }; - testImage.onerror = () => { - console.error('Failed to load background image'); - CanvasField.showStatus('Error loading field image'); - }; - testImage.src = '/static/images/field-2026.png'; - - // Prevent page scrolling when using mouse wheel on canvas - const canvas = document.getElementById('autoPath'); - canvas.addEventListener('wheel', (e) => { - if (e.target === canvas && CanvasField.isPanning) { - e.preventDefault(); + } + + // Initialize CanvasField helper + const CanvasField = new Canvas({ + canvas: document.getElementById("autoPath"), + container: document.getElementById("autoPathContainer"), + externalUpdateUIControls: updateUIControls, + showStatus: (message) => { + const flashContainer = document.querySelector(".container"); + if (!flashContainer) { + return; + } + + const messageDiv = document.createElement("div"); + messageDiv.className = + "fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up"; + + const innerDiv = document.createElement("div"); + innerDiv.className = + "flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200"; + + const icon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + icon.setAttribute("class", "w-6 h-6 mr-3 flex-shrink-0"); + icon.setAttribute("fill", "currentColor"); + icon.setAttribute("viewBox", "0 0 20 20"); + + const path = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + path.setAttribute("fill-rule", "evenodd"); + path.setAttribute( + "d", + "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + ); + path.setAttribute("clip-rule", "evenodd"); + + icon.appendChild(path); + + const text = document.createElement("p"); + text.className = "text-base font-medium"; + text.textContent = message; + + const closeButton = document.createElement("button"); + closeButton.className = + "ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100"; + closeButton.onclick = () => messageDiv.remove(); + + const closeIcon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + closeIcon.setAttribute("class", "w-5 h-5"); + closeIcon.setAttribute("fill", "currentColor"); + closeIcon.setAttribute("viewBox", "0 0 20 20"); + + const closePath = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + closePath.setAttribute("fill-rule", "evenodd"); + closePath.setAttribute( + "d", + "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", + ); + closePath.setAttribute("clip-rule", "evenodd"); + + closeIcon.appendChild(closePath); + closeButton.appendChild(closeIcon); + + innerDiv.appendChild(icon); + innerDiv.appendChild(text); + innerDiv.appendChild(closeButton); + messageDiv.appendChild(innerDiv); + + flashContainer.appendChild(messageDiv); + + setTimeout(() => { + if (messageDiv.parentNode === flashContainer) { + messageDiv.remove(); } - }, { passive: false }); + }, 3000); + }, + initialColor: "#2563eb", + initialThickness: 3, + maxPanDistance: 1000, + backgroundImage: "/static/images/field-2026.png", + readonly: false, + }); + + // Verify background image loading + const testImage = new Image(); + testImage.onload = () => { + console.log("Background image loaded successfully"); + }; + testImage.onerror = () => { + console.error("Failed to load background image"); + CanvasField.showStatus("Error loading field image"); + }; + testImage.src = "/static/images/field-2026.png"; + + // Prevent page scrolling when using mouse wheel on canvas + const canvas = document.getElementById("autoPath"); + canvas.addEventListener( + "wheel", + (e) => { + if (e.target === canvas && CanvasField.isPanning) { + e.preventDefault(); + } + }, + { passive: false }, + ); + + // Prevent page scrolling when middle mouse button is pressed + canvas.addEventListener("mousedown", (e) => { + if (e.button === 1 && e.target === canvas) { + // Middle mouse button + e.preventDefault(); + CanvasField.startPanning(e); + } + }); - // Prevent page scrolling when middle mouse button is pressed - canvas.addEventListener('mousedown', (e) => { - if (e.button === 1 && e.target === canvas) { // Middle mouse button - e.preventDefault(); - CanvasField.startPanning(e); - } + // Add mouseup handler for panning + canvas.addEventListener("mouseup", (e) => { + if (e.button === 1 && e.target === canvas) { + CanvasField.stopPanning(); + } + }); + + // Configure Coloris + Coloris({ + theme: "polaroid", + themeMode: "light", + alpha: false, + formatToggle: false, + swatches: [ + "#2563eb", // Default blue + "#000000", + "#ffffff", + "#db4437", + "#4285f4", + "#0f9d58", + "#ffeb3b", + "#ff7f00", + ], + }); + + // Tool buttons + const toolButtons = { + select: document.getElementById("selectTool"), + pen: document.getElementById("penTool"), + rectangle: document.getElementById("rectangleTool"), + circle: document.getElementById("circleTool"), + line: document.getElementById("lineTool"), + arrow: document.getElementById("arrowTool"), + hexagon: document.getElementById("hexagonTool"), + star: document.getElementById("starTool"), + }; + + // Function to update active tool button + function updateActiveToolButton(activeTool) { + Object.entries(toolButtons).forEach(([tool, button]) => { + if (tool === activeTool) { + button.classList.add("active"); + } else { + button.classList.remove("active"); + } }); - - // Add mouseup handler for panning - canvas.addEventListener('mouseup', (e) => { - if (e.button === 1 && e.target === canvas) { - CanvasField.stopPanning(); - } + } + + // Add tool button event listeners + Object.entries(toolButtons).forEach(([tool, button]) => { + button.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.setTool(tool); + updateActiveToolButton(tool); }); + }); - // Configure Coloris - Coloris({ - theme: 'polaroid', - themeMode: 'light', - alpha: false, - formatToggle: false, - swatches: [ - '#2563eb', // Default blue - '#000000', - '#ffffff', - '#db4437', - '#4285f4', - '#0f9d58', - '#ffeb3b', - '#ff7f00' - ] + // Color picker + document + .getElementById("pathColorPicker") + .addEventListener("change", function (e) { + CanvasField.setColor(this.value); }); - // Tool buttons - const toolButtons = { - select: document.getElementById('selectTool'), - pen: document.getElementById('penTool'), - rectangle: document.getElementById('rectangleTool'), - circle: document.getElementById('circleTool'), - line: document.getElementById('lineTool'), - arrow: document.getElementById('arrowTool'), - hexagon: document.getElementById('hexagonTool'), - star: document.getElementById('starTool') + // Thickness control + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessValue = document.getElementById("pathThicknessValue"); + + thicknessSlider.addEventListener("input", function () { + const { value } = this; + thicknessValue.textContent = value; + CanvasField.setThickness(parseInt(value)); + }); + + // Fill toggle button + const fillToggleBtn = document.getElementById("fillToggle"); + fillToggleBtn.addEventListener("click", function (e) { + e.preventDefault(); // Prevent form submission + const newFillState = !CanvasField.isFilled; + CanvasField.setFill(newFillState); + this.textContent = `Fill: ${newFillState ? "On" : "Off"}`; + this.classList.toggle("bg-blue-800", newFillState); + }); + + // Function to update hidden path data + function updatePathData() { + const pathData = document.getElementById("autoPathData"); + if (pathData) { + pathData.value = JSON.stringify(CanvasField.drawingHistory); + } + } + + // Add mouseup listener to update path data after drawing + canvas.addEventListener("mouseup", updatePathData); + + // Undo button + document.getElementById("undoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.undo(); + updatePathData(); + }); + + // Redo button + document.getElementById("redoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.redo(); + updatePathData(); + }); + + // Clear button + document.getElementById("clearPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + if (confirm("Are you sure you want to clear the path?")) { + CanvasField.clear(); + updatePathData(); + } + }); + + // Save button + document.getElementById("savePath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + const jsonString = JSON.stringify(CanvasField.drawingHistory); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + CanvasField.showStatus("Path saved"); + }); + + // Load button and file input + const loadBtn = document.getElementById("loadPath"); + const loadFile = document.getElementById("loadFile"); + + loadBtn.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + loadFile.click(); + }); + + // Reset view button + document.getElementById("goHome").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.resizeCanvas(); + CanvasField.resetView(); + CanvasField.redrawCanvas(); + CanvasField.showStatus("View reset to origin"); + }); + + // Flip and rotate buttons + document.getElementById("flipHorizontal").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipHorizontal(); + }); + + document.getElementById("flipVertical").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipVertical(); + }); + + document.getElementById("rotateCw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateClockwise(); + }); + + document.getElementById("rotateCcw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateCounterClockwise(); + }); + + // Readonly toggle button + const readonlyToggle = document.getElementById("readonlyToggle"); + readonlyToggle.addEventListener("click", (e) => { + e.preventDefault(); + const newState = !CanvasField.readonly; + CanvasField.setReadonly(newState); + readonlyToggle.classList.toggle("bg-blue-800", newState); + readonlyToggle.classList.toggle("text-white", newState); + }); + + loadFile.addEventListener("change", (e) => { + if (e.target.files.length === 0) { + return; + } + + const file = e.target.files[0]; + const reader = new FileReader(); + + reader.onload = function (event) { + try { + const pathData = JSON.parse(event.target.result); + CanvasField.drawingHistory = pathData; + CanvasField.redrawCanvas(); + updatePathData(); + CanvasField.showStatus("Path loaded"); + } catch (error) { + console.error("Error loading path:", error); + CanvasField.showStatus("Error loading path"); + } }; - // Function to update active tool button - function updateActiveToolButton(activeTool) { - Object.entries(toolButtons).forEach(([tool, button]) => { - if (tool === activeTool) { - button.classList.add('active'); - } else { - button.classList.remove('active'); - } - }); - } + reader.readAsText(file); + e.target.value = null; // Reset file input + }); - // Add tool button event listeners - Object.entries(toolButtons).forEach(([tool, button]) => { - button.addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.setTool(tool); - updateActiveToolButton(tool); - }); - }); + // Add keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { + return; + } - // Color picker - document.getElementById('pathColorPicker').addEventListener('change', function(e) { - CanvasField.setColor(this.value); - }); - - // Thickness control - const thicknessSlider = document.getElementById('pathThickness'); - const thicknessValue = document.getElementById('pathThicknessValue'); - - thicknessSlider.addEventListener('input', function() { - const {value} = this; - thicknessValue.textContent = value; - CanvasField.setThickness(parseInt(value)); + if (e.ctrlKey) { + switch (e.key.toLowerCase()) { + case "a": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("select"); + updateActiveToolButton("select"); + break; + case "p": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("pen"); + updateActiveToolButton("pen"); + break; + case "r": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("rectangle"); + updateActiveToolButton("rectangle"); + break; + case "c": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("circle"); + updateActiveToolButton("circle"); + break; + case "l": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("line"); + updateActiveToolButton("line"); + break; + case "h": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("hexagon"); + updateActiveToolButton("hexagon"); + break; + case "w": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("arrow"); + updateActiveToolButton("arrow"); + break; + case "s": + if (!e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("star"); + updateActiveToolButton("star"); + } + break; + case "z": + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + CanvasField.redo(); + } else if (!e.repeat) { + // Only trigger once when key is first pressed + CanvasField.undo(); + } + updatePathData(); + break; + case "y": + e.preventDefault(); + e.stopPropagation(); + CanvasField.redo(); + updatePathData(); + break; + case "f": + e.preventDefault(); + e.stopPropagation(); + fillToggleBtn.click(); + break; + } + } + }); + + // Form submission handling + const form = document.getElementById("scoutingForm"); + if (form) { + form.addEventListener("submit", async function (e) { + e.preventDefault(); + + // Update path data before submission + updatePathData(); + + const teamNumber = teamSelect.value; + const eventCode = eventSelect.value; + const matchNumber = matchSelect.value; + const alliance = allianceInput.value; + + if (!teamNumber || !eventCode || !matchNumber || !alliance) { + alert("Please fill in all required fields"); + return; + } + + // try { + // const response = await fetch(`/scouting/check_team?team=${teamNumber}&event=${eventCode}&match=${matchNumber}`); + // const data = await response.json(); + + // if (data.exists) { + // alert(`Team ${teamNumber} already exists in match ${matchNumber} for event ${eventCode}`); + // return; + // } + + // form.submit(); + // } catch (error) { + // console.error('Error checking team:', error); + // form.submit(); + // } + form.submit(); }); + } + + // TBA Integration + const eventSelect = document.getElementById("event_select"); + const matchSelect = document.getElementById("match_select"); + const teamSelect = document.getElementById("team_select"); + const allianceInput = document.getElementById("alliance_color"); + + // Create searchable dropdown functionality + function createSearchableDropdown(selectElement, placeholder) { + const wrapper = document.createElement("div"); + wrapper.className = "relative"; + + // Create search input + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.placeholder = `Search ${placeholder}...`; + searchInput.className = + "w-full px-4 py-2 rounded-md border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"; + + // Create dropdown container + const dropdownContainer = document.createElement("div"); + dropdownContainer.className = + "absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto hidden"; + + // Insert new elements + selectElement.parentNode.insertBefore(wrapper, selectElement); + wrapper.appendChild(searchInput); + wrapper.appendChild(dropdownContainer); + wrapper.appendChild(selectElement); + selectElement.style.display = "none"; + + // Track options + let options = []; + let filteredOptions = []; + + // Update options list + function updateOptions() { + options = Array.from(selectElement.options).map((opt) => ({ + value: opt.value, + text: opt.text, + dataset: opt.dataset, + element: opt, + })); + filteredOptions = [...options]; + } - // Fill toggle button - const fillToggleBtn = document.getElementById('fillToggle'); - fillToggleBtn.addEventListener('click', function(e) { - e.preventDefault(); // Prevent form submission - const newFillState = !CanvasField.isFilled; - CanvasField.setFill(newFillState); - this.textContent = `Fill: ${newFillState ? 'On' : 'Off'}`; - this.classList.toggle('bg-blue-800', newFillState); - }); + // Render dropdown options + function renderDropdown() { + dropdownContainer.innerHTML = ""; + filteredOptions.forEach((opt, index) => { + if (opt.value === "") { + return; + } // Skip placeholder option + + const option = document.createElement("div"); + option.className = "px-4 py-2 cursor-pointer hover:bg-gray-100"; + option.textContent = opt.text; + + option.addEventListener("click", () => { + selectElement.value = opt.value; + searchInput.value = opt.text; + dropdownContainer.classList.add("hidden"); + // Trigger change event + selectElement.dispatchEvent(new Event("change")); + }); - // Function to update hidden path data - function updatePathData() { - const pathData = document.getElementById('autoPathData'); - if (pathData) { - pathData.value = JSON.stringify(CanvasField.drawingHistory); - } + dropdownContainer.appendChild(option); + }); } - // Add mouseup listener to update path data after drawing - canvas.addEventListener('mouseup', updatePathData); - - // Undo button - document.getElementById('undoPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.undo(); - updatePathData(); - }); - - // Redo button - document.getElementById('redoPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.redo(); - updatePathData(); - }); - - // Clear button - document.getElementById('clearPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - if (confirm('Are you sure you want to clear the path?')) { - CanvasField.clear(); - updatePathData(); + // Filter options based on search input + function filterOptions(searchTerm) { + searchTerm = searchTerm.toLowerCase(); + + // Special handling for finals searches + if (searchTerm.includes("final")) { + // Check if searching for a specific finals match like "finals 1" + const finalsMatchNumber = searchTerm.match(/finals?\s*(\d+)/i); + + if (finalsMatchNumber) { + // If looking for a specific finals match + const matchNumber = finalsMatchNumber[1]; + filteredOptions = options.filter( + (opt) => + opt.text.toLowerCase().includes("finals") && + !opt.text.toLowerCase().includes("semi") && + opt.text.includes(matchNumber), + ); + + if (filteredOptions.length > 0) { + renderDropdown(); + return; + } } - }); - // Save button - document.getElementById('savePath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - const jsonString = JSON.stringify(CanvasField.drawingHistory); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - CanvasField.showStatus('Path saved'); - }); + // If search contains "final" but not "semi", prioritize finals over semi-finals + if (searchTerm.includes("final") && !searchTerm.includes("semi")) { + const finalsOptions = options.filter( + (opt) => + opt.text.toLowerCase().includes("finals") && + !opt.text.toLowerCase().includes("semi"), + ); - // Load button and file input - const loadBtn = document.getElementById('loadPath'); - const loadFile = document.getElementById('loadFile'); + if (finalsOptions.length > 0) { + filteredOptions = finalsOptions; + renderDropdown(); + return; + } + } + } - loadBtn.addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - loadFile.click(); - }); + // Normal search behavior for other terms + filteredOptions = options.filter((opt) => + opt.text.toLowerCase().includes(searchTerm), + ); + renderDropdown(); + } - // Reset view button - document.getElementById('goHome').addEventListener('click', (e) => { - e.preventDefault(); - CanvasField.resizeCanvas(); - CanvasField.resetView(); - CanvasField.redrawCanvas(); - CanvasField.showStatus('View reset to origin'); + // Event listeners + searchInput.addEventListener("focus", () => { + updateOptions(); + renderDropdown(); + dropdownContainer.classList.remove("hidden"); }); - // Readonly toggle button - const readonlyToggle = document.getElementById('readonlyToggle'); - readonlyToggle.addEventListener('click', (e) => { - e.preventDefault(); - const newState = !CanvasField.readonly; - CanvasField.setReadonly(newState); - readonlyToggle.classList.toggle('bg-blue-800', newState); - readonlyToggle.classList.toggle('text-white', newState); + searchInput.addEventListener("input", (e) => { + filterOptions(e.target.value); + dropdownContainer.classList.remove("hidden"); }); - loadFile.addEventListener('change', (e) => { - if (e.target.files.length === 0) { - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - - reader.onload = function(event) { - try { - const pathData = JSON.parse(event.target.result); - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - updatePathData(); - CanvasField.showStatus('Path loaded'); - } catch (error) { - console.error('Error loading path:', error); - CanvasField.showStatus('Error loading path'); - } - }; - - reader.readAsText(file); - e.target.value = null; // Reset file input + // Close dropdown when clicking outside + document.addEventListener("click", (e) => { + if (!wrapper.contains(e.target)) { + dropdownContainer.classList.add("hidden"); + } }); - // Add keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { - return; - } - - if (e.ctrlKey) { - switch (e.key.toLowerCase()) { - case 'a': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('select'); - updateActiveToolButton('select'); - break; - case 'p': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('pen'); - updateActiveToolButton('pen'); - break; - case 'r': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('rectangle'); - updateActiveToolButton('rectangle'); - break; - case 'c': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('circle'); - updateActiveToolButton('circle'); - break; - case 'l': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('line'); - updateActiveToolButton('line'); - break; - case 'h': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('hexagon'); - updateActiveToolButton('hexagon'); - break; - case 'w': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('arrow'); - updateActiveToolButton('arrow'); - break; - case 's': - if (!e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('star'); - updateActiveToolButton('star'); - } - break; - case 'z': - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - CanvasField.redo(); - } - else if (!e.repeat) { // Only trigger once when key is first pressed - CanvasField.undo(); - } - updatePathData(); - break; - case 'y': - e.preventDefault(); - e.stopPropagation(); - CanvasField.redo(); - updatePathData(); - break; - case 'f': - e.preventDefault(); - e.stopPropagation(); - fillToggleBtn.click(); - break; - } - } + // Update input when select changes + selectElement.addEventListener("change", () => { + const selectedOption = selectElement.options[selectElement.selectedIndex]; + searchInput.value = selectedOption ? selectedOption.text : ""; }); - // Form submission handling - const form = document.getElementById('scoutingForm'); - if (form) { - form.addEventListener('submit', async function(e) { - e.preventDefault(); - - // Update path data before submission - updatePathData(); - - const teamNumber = teamSelect.value; - const eventCode = eventSelect.value; - const matchNumber = matchSelect.value; - const alliance = allianceInput.value; - - if (!teamNumber || !eventCode || !matchNumber || !alliance) { - alert('Please fill in all required fields'); - return; - } - - // try { - // const response = await fetch(`/scouting/check_team?team=${teamNumber}&event=${eventCode}&match=${matchNumber}`); - // const data = await response.json(); - - // if (data.exists) { - // alert(`Team ${teamNumber} already exists in match ${matchNumber} for event ${eventCode}`); - // return; - // } - - // form.submit(); - // } catch (error) { - // console.error('Error checking team:', error); - // form.submit(); - // } - form.submit(); - }); + return { + updateOptions, + clear: () => { + searchInput.value = ""; + selectElement.value = ""; + dropdownContainer.classList.add("hidden"); + }, + }; + } + + let currentMatches = null; + const eventMatches = JSON.parse( + document.getElementById("event_matches").textContent, + ); + + // Create searchable dropdowns + const eventSearchable = createSearchableDropdown(eventSelect, "Events"); + const matchSearchable = createSearchableDropdown(matchSelect, "Matches"); + + // Load events from server-side data + const events = JSON.parse(document.getElementById("events").textContent); + + // Use Object.entries to maintain server-side ordering + Object.entries(events).forEach(([name, data]) => { + const option = document.createElement("option"); + option.value = name; // Use event name as value for the server + option.dataset.key = data.key; // Store TBA key in dataset for API calls + option.textContent = name; // Name already includes the time indicator + eventSelect.appendChild(option); + }); + + // Update event searchable options after populating + eventSearchable.updateOptions(); + + // Load matches when event is selected + eventSelect.addEventListener("change", async function () { + const selectedOption = this.options[this.selectedIndex]; + const selectedEventKey = selectedOption?.dataset.key; + matchSelect.innerHTML = ''; + matchSearchable.clear(); + teamSelect.innerHTML = ''; + allianceInput.value = ""; + + if (!selectedEventKey) { + return; } - // TBA Integration - const eventSelect = document.getElementById('event_select'); - const matchSelect = document.getElementById('match_select'); - const teamSelect = document.getElementById('team_select'); - const allianceInput = document.getElementById('alliance_color'); - - // Create searchable dropdown functionality - function createSearchableDropdown(selectElement, placeholder) { - const wrapper = document.createElement('div'); - wrapper.className = 'relative'; - - // Create search input - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.placeholder = `Search ${placeholder}...`; - searchInput.className = 'w-full px-4 py-2 rounded-md border border-gray-300 focus:ring-2 focus:ring-blue-500 focus:border-blue-500'; - - // Create dropdown container - const dropdownContainer = document.createElement('div'); - dropdownContainer.className = 'absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto hidden'; - - // Insert new elements - selectElement.parentNode.insertBefore(wrapper, selectElement); - wrapper.appendChild(searchInput); - wrapper.appendChild(dropdownContainer); - wrapper.appendChild(selectElement); - selectElement.style.display = 'none'; - - // Track options - let options = []; - let filteredOptions = []; - - // Update options list - function updateOptions() { - options = Array.from(selectElement.options).map(opt => ({ - value: opt.value, - text: opt.text, - dataset: opt.dataset, - element: opt - })); - filteredOptions = [...options]; + try { + // Show loading state + matchSelect.disabled = true; + matchSearchable.clear(); + + // Fetch matches for selected event + const response = await fetch(`/api/tba/matches/${selectedEventKey}`); + if (!response.ok) { + throw new Error("Failed to fetch matches"); + } + + const matches = await response.json(); + if (!matches) { + return; + } + + currentMatches = matches; + matchSelect.innerHTML = ''; + + // Group matches by competition level + const groupedMatches = {}; + Object.entries(matches).forEach(([matchKey, match]) => { + const level = match.comp_level; + if (!groupedMatches[level]) { + groupedMatches[level] = []; } - - // Render dropdown options - function renderDropdown() { - dropdownContainer.innerHTML = ''; - filteredOptions.forEach((opt, index) => { - if (opt.value === '') { - return; - } // Skip placeholder option - - const option = document.createElement('div'); - option.className = 'px-4 py-2 cursor-pointer hover:bg-gray-100'; - option.textContent = opt.text; - - option.addEventListener('click', () => { - selectElement.value = opt.value; - searchInput.value = opt.text; - dropdownContainer.classList.add('hidden'); - // Trigger change event - selectElement.dispatchEvent(new Event('change')); - }); - - dropdownContainer.appendChild(option); + groupedMatches[level].push({ key: matchKey, ...match }); + }); + + // Add matches in order: Qualification, Semi-Finals, Finals + const levels = { + qm: "Qualification", + sf: "Semi-Finals", + f: "Finals", + }; + + Object.entries(levels).forEach(([level, label]) => { + if (groupedMatches[level]) { + const group = document.createElement("optgroup"); + group.label = label; + + // Sort matches within each group + groupedMatches[level] + .sort((a, b) => { + if (level === "sf") { + // Sort by set number first, then match number + return ( + a.set_number - b.set_number || a.match_number - b.match_number + ); + } + return a.match_number - b.match_number; + }) + .forEach((match) => { + const option = document.createElement("option"); + option.value = match.key; + + // Format display text based on match type + if (level === "qm") { + option.textContent = `Qual ${match.match_number}`; + } else if (level === "sf") { + option.textContent = `Semi-Finals ${match.set_number}`; + } else if (level === "f") { + option.textContent = `Finals ${match.match_number}`; + } + + group.appendChild(option); }); - } - - // Filter options based on search input - function filterOptions(searchTerm) { - searchTerm = searchTerm.toLowerCase(); - - // Special handling for finals searches - if (searchTerm.includes('final')) { - // Check if searching for a specific finals match like "finals 1" - const finalsMatchNumber = searchTerm.match(/finals?\s*(\d+)/i); - - if (finalsMatchNumber) { - // If looking for a specific finals match - const matchNumber = finalsMatchNumber[1]; - filteredOptions = options.filter(opt => - opt.text.toLowerCase().includes('finals') && - !opt.text.toLowerCase().includes('semi') && - opt.text.includes(matchNumber) - ); - - if (filteredOptions.length > 0) { - renderDropdown(); - return; - } - } - - // If search contains "final" but not "semi", prioritize finals over semi-finals - if (searchTerm.includes('final') && !searchTerm.includes('semi')) { - const finalsOptions = options.filter(opt => - opt.text.toLowerCase().includes('finals') && - !opt.text.toLowerCase().includes('semi') - ); - - if (finalsOptions.length > 0) { - filteredOptions = finalsOptions; - renderDropdown(); - return; - } - } - } - - // Normal search behavior for other terms - filteredOptions = options.filter(opt => - opt.text.toLowerCase().includes(searchTerm) - ); - renderDropdown(); - } - - // Event listeners - searchInput.addEventListener('focus', () => { - updateOptions(); - renderDropdown(); - dropdownContainer.classList.remove('hidden'); - }); - - searchInput.addEventListener('input', (e) => { - filterOptions(e.target.value); - dropdownContainer.classList.remove('hidden'); - }); - - // Close dropdown when clicking outside - document.addEventListener('click', (e) => { - if (!wrapper.contains(e.target)) { - dropdownContainer.classList.add('hidden'); - } - }); - - // Update input when select changes - selectElement.addEventListener('change', () => { - const selectedOption = selectElement.options[selectElement.selectedIndex]; - searchInput.value = selectedOption ? selectedOption.text : ''; - }); - - return { - updateOptions, - clear: () => { - searchInput.value = ''; - selectElement.value = ''; - dropdownContainer.classList.add('hidden'); - } - }; - } - let currentMatches = null; - const eventMatches = JSON.parse(document.getElementById('event_matches').textContent); - - // Create searchable dropdowns - const eventSearchable = createSearchableDropdown(eventSelect, 'Events'); - const matchSearchable = createSearchableDropdown(matchSelect, 'Matches'); - - // Load events from server-side data - const events = JSON.parse(document.getElementById('events').textContent); - - // Use Object.entries to maintain server-side ordering - Object.entries(events).forEach(([name, data]) => { - const option = document.createElement('option'); - option.value = name; // Use event name as value for the server - option.dataset.key = data.key; // Store TBA key in dataset for API calls - option.textContent = name; // Name already includes the time indicator - eventSelect.appendChild(option); - }); - - // Update event searchable options after populating - eventSearchable.updateOptions(); - - // Load matches when event is selected - eventSelect.addEventListener('change', async function() { - const selectedOption = this.options[this.selectedIndex]; - const selectedEventKey = selectedOption?.dataset.key; - matchSelect.innerHTML = ''; - matchSearchable.clear(); - teamSelect.innerHTML = ''; - allianceInput.value = ''; - - if (!selectedEventKey) { - return; + matchSelect.appendChild(group); } + }); + + // Update match searchable options after populating + matchSearchable.updateOptions(); + matchSelect.disabled = false; + } catch (error) { + console.error("Error fetching matches:", error); + matchSelect.innerHTML = ''; + matchSearchable.clear(); + } + }); - try { - // Show loading state - matchSelect.disabled = true; - matchSearchable.clear(); - - // Fetch matches for selected event - const response = await fetch(`/api/tba/matches/${selectedEventKey}`); - if (!response.ok) { - throw new Error('Failed to fetch matches'); - } - - const matches = await response.json(); - if (!matches) { - return; - } - - currentMatches = matches; - matchSelect.innerHTML = ''; - - // Group matches by competition level - const groupedMatches = {}; - Object.entries(matches).forEach(([matchKey, match]) => { - const level = match.comp_level; - if (!groupedMatches[level]) { - groupedMatches[level] = []; - } - groupedMatches[level].push({ key: matchKey, ...match }); - }); - - // Add matches in order: Qualification, Semi-Finals, Finals - const levels = { - 'qm': 'Qualification', - 'sf': 'Semi-Finals', - 'f': 'Finals' - }; - - Object.entries(levels).forEach(([level, label]) => { - if (groupedMatches[level]) { - const group = document.createElement('optgroup'); - group.label = label; - - // Sort matches within each group - groupedMatches[level] - .sort((a, b) => { - if (level === 'sf') { - // Sort by set number first, then match number - return (a.set_number - b.set_number) || (a.match_number - b.match_number); - } - return a.match_number - b.match_number; - }) - .forEach(match => { - const option = document.createElement('option'); - option.value = match.key; - - // Format display text based on match type - if (level === 'qm') { - option.textContent = `Qual ${match.match_number}`; - } else if (level === 'sf') { - option.textContent = `Semi-Finals ${match.set_number}`; - } else if (level === 'f') { - option.textContent = `Finals ${match.match_number}`; - } - - group.appendChild(option); - }); - - matchSelect.appendChild(group); - } - }); - - // Update match searchable options after populating - matchSearchable.updateOptions(); - matchSelect.disabled = false; - } catch (error) { - console.error('Error fetching matches:', error); - matchSelect.innerHTML = ''; - matchSearchable.clear(); - } - }); - - // Load teams when match is selected - matchSelect.addEventListener('change', function() { - const selectedMatch = this.value; - teamSelect.innerHTML = ''; - allianceInput.value = ''; + // Load teams when match is selected + matchSelect.addEventListener("change", function () { + const selectedMatch = this.value; + teamSelect.innerHTML = ''; + allianceInput.value = ""; - if (!selectedMatch || !currentMatches) { - return; - } + if (!selectedMatch || !currentMatches) { + return; + } - const match = currentMatches[selectedMatch]; - if (!match) { - return; - } + const match = currentMatches[selectedMatch]; + if (!match) { + return; + } - // Add red alliance teams - const redGroup = document.createElement('optgroup'); - redGroup.label = 'Red Alliance'; - match.red.forEach(team => { - const option = document.createElement('option'); - const teamNumber = team.replace('frc', ''); - option.value = teamNumber; - option.textContent = `Team ${teamNumber}`; - option.dataset.alliance = 'red'; - redGroup.appendChild(option); - }); - teamSelect.appendChild(redGroup); - - // Add blue alliance teams - const blueGroup = document.createElement('optgroup'); - blueGroup.label = 'Blue Alliance'; - match.blue.forEach(team => { - const option = document.createElement('option'); - const teamNumber = team.replace('frc', ''); - option.value = teamNumber; - option.textContent = `Team ${teamNumber}`; - option.dataset.alliance = 'blue'; - blueGroup.appendChild(option); - }); - teamSelect.appendChild(blueGroup); + // Add red alliance teams + const redGroup = document.createElement("optgroup"); + redGroup.label = "Red Alliance"; + match.red.forEach((team) => { + const option = document.createElement("option"); + const teamNumber = team.replace("frc", ""); + option.value = teamNumber; + option.textContent = `Team ${teamNumber}`; + option.dataset.alliance = "red"; + redGroup.appendChild(option); }); - - // Set alliance color when team is selected - teamSelect.addEventListener('change', function() { - const selectedOption = this.options[this.selectedIndex]; - if (selectedOption && selectedOption.dataset.alliance) { - allianceInput.value = selectedOption.dataset.alliance; - - // Optional: Add visual feedback of selected alliance - this.classList.remove('border-red-500', 'border-blue-500'); - this.classList.add(`border-${selectedOption.dataset.alliance}-500`); + teamSelect.appendChild(redGroup); + + // Add blue alliance teams + const blueGroup = document.createElement("optgroup"); + blueGroup.label = "Blue Alliance"; + match.blue.forEach((team) => { + const option = document.createElement("option"); + const teamNumber = team.replace("frc", ""); + option.value = teamNumber; + option.textContent = `Team ${teamNumber}`; + option.dataset.alliance = "blue"; + blueGroup.appendChild(option); + }); + teamSelect.appendChild(blueGroup); + }); + + // Set alliance color when team is selected + teamSelect.addEventListener("change", function () { + const selectedOption = this.options[this.selectedIndex]; + if (selectedOption && selectedOption.dataset.alliance) { + allianceInput.value = selectedOption.dataset.alliance; + + // Optional: Add visual feedback of selected alliance + this.classList.remove("border-red-500", "border-blue-500"); + this.classList.add(`border-${selectedOption.dataset.alliance}-500`); + } else { + allianceInput.value = ""; + this.classList.remove("border-red-500", "border-blue-500"); + } + }); + + // Tab switching functionality + const tabButtons = document.querySelectorAll(".tab-button"); + const tabContents = document.querySelectorAll(".tab-content"); + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + const { tab } = button.dataset; + + // Update button states + tabButtons.forEach((btn) => { + btn.classList.remove("border-blue-500", "text-blue-600"); + btn.classList.add("border-transparent", "text-gray-500"); + }); + button.classList.remove("border-transparent", "text-gray-500"); + button.classList.add("border-blue-500", "text-blue-600"); + + // Update content visibility + tabContents.forEach((content) => { + if (content.dataset.tab === tab) { + content.classList.remove("hidden"); + content.classList.add("active"); } else { - allianceInput.value = ''; - this.classList.remove('border-red-500', 'border-blue-500'); + content.classList.add("hidden"); + content.classList.remove("active"); } + }); }); - - // Tab switching functionality - const tabButtons = document.querySelectorAll('.tab-button'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const {tab} = button.dataset; - - // Update button states - tabButtons.forEach(btn => { - btn.classList.remove('border-blue-500', 'text-blue-600'); - btn.classList.add('border-transparent', 'text-gray-500'); - }); - button.classList.remove('border-transparent', 'text-gray-500'); - button.classList.add('border-blue-500', 'text-blue-600'); - - // Update content visibility - tabContents.forEach(content => { - if (content.dataset.tab === tab) { - content.classList.remove('hidden'); - content.classList.add('active'); - } else { - content.classList.add('hidden'); - content.classList.remove('active'); - } - }); - }); - }); + }); }); // Update the updateUIControls method to be more specific function updateUIControls(color, thickness) { - if (color) { - // Update color picker if it exists - const colorPicker = document.querySelector('input[name="pathColorPicker"]'); - if (colorPicker) { - colorPicker.value = color; - // Update Coloris - Coloris.setInstance('#pathColorPicker', { value: color }); - } + if (color) { + // Update color picker if it exists + const colorPicker = document.querySelector('input[name="pathColorPicker"]'); + if (colorPicker) { + colorPicker.value = color; + // Update Coloris + Coloris.setInstance("#pathColorPicker", { value: color }); } - - if (thickness) { - // Update thickness slider if it exists - be more specific with the selector - const thicknessSlider = document.getElementById('pathThickness'); - const thicknessDisplay = document.getElementById('pathThicknessValue'); - if (thicknessSlider) { - thicknessSlider.value = thickness; - if (thicknessDisplay) { - thicknessDisplay.textContent = thickness; - } - } + } + + if (thickness) { + // Update thickness slider if it exists - be more specific with the selector + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessDisplay = document.getElementById("pathThicknessValue"); + if (thicknessSlider) { + thicknessSlider.value = thickness; + if (thicknessDisplay) { + thicknessDisplay.textContent = thickness; + } } -} \ No newline at end of file + } +} diff --git a/app/static/js/scout/edit.js b/app/static/js/scout/edit.js index d28387c..82490ac 100644 --- a/app/static/js/scout/edit.js +++ b/app/static/js/scout/edit.js @@ -1,518 +1,575 @@ // Single DOMContentLoaded event handler -document.addEventListener('DOMContentLoaded', function() { - // Auto-capitalize event code - const eventCodeInput = document.querySelector('input[name="event_code"]'); - if (eventCodeInput) { - eventCodeInput.addEventListener('input', function(e) { - this.value = this.value.toUpperCase(); - }); - } - - // Initialize CanvasField helper - const CanvasField = new Canvas({ - canvas: document.getElementById('autoPath'), - container: document.getElementById('autoPathContainer'), - externalUpdateUIControls: updateUIControls, - showStatus: (message) => { - const flashContainer = document.querySelector('.container'); - if (!flashContainer) { - return; - } - - const messageDiv = document.createElement('div'); - messageDiv.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up'; - - const innerDiv = document.createElement('div'); - innerDiv.className = 'flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200'; - - const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - icon.setAttribute('class', 'w-6 h-6 mr-3 flex-shrink-0'); - icon.setAttribute('fill', 'currentColor'); - icon.setAttribute('viewBox', '0 0 20 20'); - - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - path.setAttribute('fill-rule', 'evenodd'); - path.setAttribute('d', 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'); - path.setAttribute('clip-rule', 'evenodd'); - - icon.appendChild(path); - - const text = document.createElement('p'); - text.className = 'text-base font-medium'; - text.textContent = message; - - const closeButton = document.createElement('button'); - closeButton.className = 'ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100'; - closeButton.onclick = () => messageDiv.remove(); - - const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - closeIcon.setAttribute('class', 'w-5 h-5'); - closeIcon.setAttribute('fill', 'currentColor'); - closeIcon.setAttribute('viewBox', '0 0 20 20'); - - const closePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - closePath.setAttribute('fill-rule', 'evenodd'); - closePath.setAttribute('d', 'M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z'); - closePath.setAttribute('clip-rule', 'evenodd'); - - closeIcon.appendChild(closePath); - closeButton.appendChild(closeIcon); - - innerDiv.appendChild(icon); - innerDiv.appendChild(text); - innerDiv.appendChild(closeButton); - messageDiv.appendChild(innerDiv); - - flashContainer.appendChild(messageDiv); - - setTimeout(() => { - if (messageDiv.parentNode === flashContainer) { - messageDiv.remove(); - } - }, 3000); - }, - initialColor: '#2563eb', - initialThickness: 3, - maxPanDistance: 1000, - backgroundImage: '/static/images/field-2026.png', - readonly: false +document.addEventListener("DOMContentLoaded", function () { + // Auto-capitalize event code + const eventCodeInput = document.querySelector('input[name="event_code"]'); + if (eventCodeInput) { + eventCodeInput.addEventListener("input", function (e) { + this.value = this.value.toUpperCase(); }); + } + + // Initialize CanvasField helper + const CanvasField = new Canvas({ + canvas: document.getElementById("autoPath"), + container: document.getElementById("autoPathContainer"), + externalUpdateUIControls: updateUIControls, + showStatus: (message) => { + const flashContainer = document.querySelector(".container"); + if (!flashContainer) { + return; + } + + const messageDiv = document.createElement("div"); + messageDiv.className = + "fixed bottom-6 left-1/2 -translate-x-1/2 sm:left-auto sm:right-6 sm:-translate-x-0 z-50 w-[90%] sm:w-full max-w-xl min-h-[60px] sm:min-h-[80px] mx-auto sm:mx-0 animate-fade-in-up"; + + const innerDiv = document.createElement("div"); + innerDiv.className = + "flex items-center p-6 rounded-lg shadow-xl bg-green-50 text-green-800 border-2 border-green-200"; + + const icon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + icon.setAttribute("class", "w-6 h-6 mr-3 flex-shrink-0"); + icon.setAttribute("fill", "currentColor"); + icon.setAttribute("viewBox", "0 0 20 20"); + + const path = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + path.setAttribute("fill-rule", "evenodd"); + path.setAttribute( + "d", + "M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z", + ); + path.setAttribute("clip-rule", "evenodd"); + + icon.appendChild(path); + + const text = document.createElement("p"); + text.className = "text-base font-medium"; + text.textContent = message; + + const closeButton = document.createElement("button"); + closeButton.className = + "ml-auto -mx-1.5 -my-1.5 rounded-lg p-1.5 inline-flex h-8 w-8 text-green-500 hover:bg-green-100"; + closeButton.onclick = () => messageDiv.remove(); + + const closeIcon = document.createElementNS( + "http://www.w3.org/2000/svg", + "svg", + ); + closeIcon.setAttribute("class", "w-5 h-5"); + closeIcon.setAttribute("fill", "currentColor"); + closeIcon.setAttribute("viewBox", "0 0 20 20"); + + const closePath = document.createElementNS( + "http://www.w3.org/2000/svg", + "path", + ); + closePath.setAttribute("fill-rule", "evenodd"); + closePath.setAttribute( + "d", + "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z", + ); + closePath.setAttribute("clip-rule", "evenodd"); + + closeIcon.appendChild(closePath); + closeButton.appendChild(closeIcon); + + innerDiv.appendChild(icon); + innerDiv.appendChild(text); + innerDiv.appendChild(closeButton); + messageDiv.appendChild(innerDiv); + + flashContainer.appendChild(messageDiv); + + setTimeout(() => { + if (messageDiv.parentNode === flashContainer) { + messageDiv.remove(); + } + }, 3000); + }, + initialColor: "#2563eb", + initialThickness: 3, + maxPanDistance: 1000, + backgroundImage: "/static/images/field-2026.png", + readonly: false, + }); + + // Verify background image loading + const testImage = new Image(); + testImage.onload = () => { + console.log("Background image loaded successfully"); + }; + testImage.onerror = () => { + console.error("Failed to load background image"); + CanvasField.showStatus("Error loading field image"); + }; + testImage.src = "/static/images/field-2026.png"; + + // Load existing path data if available + const pathDataInput = document.getElementById("autoPathData"); + if (pathDataInput && pathDataInput.value) { + try { + let sanitizedValue = pathDataInput.value.trim(); + + // Remove any potential HTML entities + sanitizedValue = sanitizedValue + .replace(/"/g, '"') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, "&"); + + // Check if the value is actually JSON + if ( + sanitizedValue && + sanitizedValue !== "None" && + sanitizedValue !== "null" + ) { + // Try to fix common JSON formatting issues + if (sanitizedValue.startsWith("'") && sanitizedValue.endsWith("'")) { + sanitizedValue = sanitizedValue.slice(1, -1); + } - // Verify background image loading - const testImage = new Image(); - testImage.onload = () => { - console.log('Background image loaded successfully'); - }; - testImage.onerror = () => { - console.error('Failed to load background image'); - CanvasField.showStatus('Error loading field image'); - }; - testImage.src = '/static/images/field-2026.png'; + // Convert single quotes to double quotes + sanitizedValue = sanitizedValue.replace(/'/g, '"'); + + // Convert Python boolean values to JSON boolean values + sanitizedValue = sanitizedValue + .replace(/: True/g, ": true") + .replace(/: False/g, ": false"); - // Load existing path data if available - const pathDataInput = document.getElementById('autoPathData'); - if (pathDataInput && pathDataInput.value) { try { - let sanitizedValue = pathDataInput.value.trim(); - - // Remove any potential HTML entities - sanitizedValue = sanitizedValue.replace(/"/g, '"') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&'); - - // Check if the value is actually JSON - if (sanitizedValue && sanitizedValue !== "None" && sanitizedValue !== "null") { - // Try to fix common JSON formatting issues - if (sanitizedValue.startsWith("'") && sanitizedValue.endsWith("'")) { - sanitizedValue = sanitizedValue.slice(1, -1); - } - - // Convert single quotes to double quotes - sanitizedValue = sanitizedValue.replace(/'/g, '"'); - - // Convert Python boolean values to JSON boolean values - sanitizedValue = sanitizedValue.replace(/: True/g, ': true') - .replace(/: False/g, ': false'); - - try { - const pathData = JSON.parse(sanitizedValue); - - if (Array.isArray(pathData)) { - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - CanvasField.showStatus('Existing path loaded'); - } else { - CanvasField.drawingHistory = []; - CanvasField.redrawCanvas(); - CanvasField.showStatus('Invalid path data format'); - } - } catch (error) { - console.error('Error loading existing path:', error); - CanvasField.drawingHistory = []; - CanvasField.redrawCanvas(); - CanvasField.showStatus('Error loading existing path'); - } - } else { - CanvasField.drawingHistory = []; - CanvasField.redrawCanvas(); - } - } catch (error) { - console.error('Error loading existing path:', error); + const pathData = JSON.parse(sanitizedValue); + + if (Array.isArray(pathData)) { + CanvasField.drawingHistory = pathData; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Existing path loaded"); + } else { CanvasField.drawingHistory = []; CanvasField.redrawCanvas(); - CanvasField.showStatus('Error loading existing path'); + CanvasField.showStatus("Invalid path data format"); + } + } catch (error) { + console.error("Error loading existing path:", error); + CanvasField.drawingHistory = []; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Error loading existing path"); } + } else { + CanvasField.drawingHistory = []; + CanvasField.redrawCanvas(); + } + } catch (error) { + console.error("Error loading existing path:", error); + CanvasField.drawingHistory = []; + CanvasField.redrawCanvas(); + CanvasField.showStatus("Error loading existing path"); } - - // Prevent page scrolling when using mouse wheel on canvas - const canvas = document.getElementById('autoPath'); - canvas.addEventListener('wheel', (e) => { - if (e.target === canvas && CanvasField.isPanning) { - e.preventDefault(); - } - }, { passive: false }); - - // Prevent page scrolling when middle mouse button is pressed - canvas.addEventListener('mousedown', (e) => { - if (e.button === 1 && e.target === canvas) { // Middle mouse button - e.preventDefault(); - CanvasField.startPanning(e); - } - }); - - // Add mouseup handler for panning - canvas.addEventListener('mouseup', (e) => { - if (e.button === 1 && e.target === canvas) { - CanvasField.stopPanning(); - } - }); - - // Configure Coloris - Coloris({ - theme: 'polaroid', - themeMode: 'light', - alpha: false, - formatToggle: false, - swatches: [ - '#2563eb', // Default blue - '#000000', - '#ffffff', - '#db4437', - '#4285f4', - '#0f9d58', - '#ffeb3b', - '#ff7f00' - ] - }); - - // Tool buttons - const toolButtons = { - select: document.getElementById('selectTool'), - pen: document.getElementById('penTool'), - rectangle: document.getElementById('rectangleTool'), - circle: document.getElementById('circleTool'), - line: document.getElementById('lineTool'), - arrow: document.getElementById('arrowTool'), - hexagon: document.getElementById('hexagonTool'), - star: document.getElementById('starTool') - }; - - // Function to update active tool button - function updateActiveToolButton(activeTool) { - Object.entries(toolButtons).forEach(([tool, button]) => { - if (tool === activeTool) { - button.classList.add('active'); - } else { - button.classList.remove('active'); - } - }); + } + + // Prevent page scrolling when using mouse wheel on canvas + const canvas = document.getElementById("autoPath"); + canvas.addEventListener( + "wheel", + (e) => { + if (e.target === canvas && CanvasField.isPanning) { + e.preventDefault(); + } + }, + { passive: false }, + ); + + // Prevent page scrolling when middle mouse button is pressed + canvas.addEventListener("mousedown", (e) => { + if (e.button === 1 && e.target === canvas) { + // Middle mouse button + e.preventDefault(); + CanvasField.startPanning(e); } + }); - // Add tool button event listeners + // Add mouseup handler for panning + canvas.addEventListener("mouseup", (e) => { + if (e.button === 1 && e.target === canvas) { + CanvasField.stopPanning(); + } + }); + + // Configure Coloris + Coloris({ + theme: "polaroid", + themeMode: "light", + alpha: false, + formatToggle: false, + swatches: [ + "#2563eb", // Default blue + "#000000", + "#ffffff", + "#db4437", + "#4285f4", + "#0f9d58", + "#ffeb3b", + "#ff7f00", + ], + }); + + // Tool buttons + const toolButtons = { + select: document.getElementById("selectTool"), + pen: document.getElementById("penTool"), + rectangle: document.getElementById("rectangleTool"), + circle: document.getElementById("circleTool"), + line: document.getElementById("lineTool"), + arrow: document.getElementById("arrowTool"), + hexagon: document.getElementById("hexagonTool"), + star: document.getElementById("starTool"), + }; + + // Function to update active tool button + function updateActiveToolButton(activeTool) { Object.entries(toolButtons).forEach(([tool, button]) => { - button.addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.setTool(tool); - updateActiveToolButton(tool); - }); - }); - - // Color picker - document.getElementById('pathColorPicker').addEventListener('change', function(e) { - CanvasField.setColor(this.value); + if (tool === activeTool) { + button.classList.add("active"); + } else { + button.classList.remove("active"); + } }); - - // Thickness control - const thicknessSlider = document.getElementById('pathThickness'); - const thicknessValue = document.getElementById('pathThicknessValue'); - - thicknessSlider.addEventListener('input', function() { - const {value} = this; - thicknessValue.textContent = value; - CanvasField.setThickness(parseInt(value)); + } + + // Add tool button event listeners + Object.entries(toolButtons).forEach(([tool, button]) => { + button.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.setTool(tool); + updateActiveToolButton(tool); }); + }); - // Fill toggle button - const fillToggleBtn = document.getElementById('fillToggle'); - fillToggleBtn.addEventListener('click', function(e) { - e.preventDefault(); // Prevent form submission - const newFillState = !CanvasField.isFilled; - CanvasField.setFill(newFillState); - this.textContent = `Fill: ${newFillState ? 'On' : 'Off'}`; - this.classList.toggle('bg-blue-800', newFillState); + // Color picker + document + .getElementById("pathColorPicker") + .addEventListener("change", function (e) { + CanvasField.setColor(this.value); }); - // Function to update hidden path data - function updatePathData() { - const pathData = document.getElementById('autoPathData'); - if (pathData) { - pathData.value = JSON.stringify(CanvasField.drawingHistory); - } + // Thickness control + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessValue = document.getElementById("pathThicknessValue"); + + thicknessSlider.addEventListener("input", function () { + const { value } = this; + thicknessValue.textContent = value; + CanvasField.setThickness(parseInt(value)); + }); + + // Fill toggle button + const fillToggleBtn = document.getElementById("fillToggle"); + fillToggleBtn.addEventListener("click", function (e) { + e.preventDefault(); // Prevent form submission + const newFillState = !CanvasField.isFilled; + CanvasField.setFill(newFillState); + this.textContent = `Fill: ${newFillState ? "On" : "Off"}`; + this.classList.toggle("bg-blue-800", newFillState); + }); + + // Function to update hidden path data + function updatePathData() { + const pathData = document.getElementById("autoPathData"); + if (pathData) { + pathData.value = JSON.stringify(CanvasField.drawingHistory); + } + } + + // Add mouseup listener to update path data after drawing + canvas.addEventListener("mouseup", updatePathData); + + // Undo button + document.getElementById("undoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.undo(); + updatePathData(); + }); + + // Redo button + document.getElementById("redoPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + CanvasField.redo(); + updatePathData(); + }); + + // Clear button + document.getElementById("clearPath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + if (confirm("Are you sure you want to clear the path?")) { + CanvasField.clear(); + updatePathData(); + } + }); + + // Save button + document.getElementById("savePath").addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + const jsonString = JSON.stringify(CanvasField.drawingHistory); + const blob = new Blob([jsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + CanvasField.showStatus("Path saved"); + }); + + // Load button and file input + const loadBtn = document.getElementById("loadPath"); + const loadFile = document.getElementById("loadFile"); + + loadBtn.addEventListener("click", (e) => { + e.preventDefault(); // Prevent form submission + loadFile.click(); + }); + + // Reset view button + document.getElementById("goHome").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.resetView(); + CanvasField.redrawCanvas(); + CanvasField.showStatus("View reset to origin"); + }); + + // Flip and rotate buttons + document.getElementById("flipHorizontal").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipHorizontal(); + }); + + document.getElementById("flipVertical").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.flipVertical(); + }); + + document.getElementById("rotateCw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateClockwise(); + }); + + document.getElementById("rotateCcw").addEventListener("click", (e) => { + e.preventDefault(); + CanvasField.rotateCounterClockwise(); + }); + + // Readonly toggle button + const readonlyToggle = document.getElementById("readonlyToggle"); + readonlyToggle.addEventListener("click", (e) => { + e.preventDefault(); + const newState = !CanvasField.readonly; + CanvasField.setReadonly(newState); + readonlyToggle.classList.toggle("bg-blue-800", newState); + readonlyToggle.classList.toggle("text-white", newState); + }); + + loadFile.addEventListener("change", (e) => { + if (e.target.files.length === 0) { + return; } - // Add mouseup listener to update path data after drawing - canvas.addEventListener('mouseup', updatePathData); - - // Undo button - document.getElementById('undoPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.undo(); - updatePathData(); - }); - - // Redo button - document.getElementById('redoPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - CanvasField.redo(); - updatePathData(); - }); - - // Clear button - document.getElementById('clearPath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - if (confirm('Are you sure you want to clear the path?')) { - CanvasField.clear(); - updatePathData(); - } - }); - - // Save button - document.getElementById('savePath').addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - const jsonString = JSON.stringify(CanvasField.drawingHistory); - const blob = new Blob([jsonString], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `autopath-${new Date().toISOString().slice(0, 10)}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - CanvasField.showStatus('Path saved'); - }); - - // Load button and file input - const loadBtn = document.getElementById('loadPath'); - const loadFile = document.getElementById('loadFile'); - - loadBtn.addEventListener('click', (e) => { - e.preventDefault(); // Prevent form submission - loadFile.click(); - }); + const file = e.target.files[0]; + const reader = new FileReader(); - // Reset view button - document.getElementById('goHome').addEventListener('click', (e) => { - e.preventDefault(); - CanvasField.resetView(); + reader.onload = function (event) { + try { + const pathData = JSON.parse(event.target.result); + CanvasField.drawingHistory = pathData; CanvasField.redrawCanvas(); - CanvasField.showStatus('View reset to origin'); - }); - - // Readonly toggle button - const readonlyToggle = document.getElementById('readonlyToggle'); - readonlyToggle.addEventListener('click', (e) => { - e.preventDefault(); - const newState = !CanvasField.readonly; - CanvasField.setReadonly(newState); - readonlyToggle.classList.toggle('bg-blue-800', newState); - readonlyToggle.classList.toggle('text-white', newState); - }); - - loadFile.addEventListener('change', (e) => { - if (e.target.files.length === 0) { - return; - } - - const file = e.target.files[0]; - const reader = new FileReader(); - - reader.onload = function(event) { - try { - const pathData = JSON.parse(event.target.result); - CanvasField.drawingHistory = pathData; - CanvasField.redrawCanvas(); - updatePathData(); - CanvasField.showStatus('Path loaded'); - } catch (error) { - console.error('Error loading path:', error); - CanvasField.showStatus('Error loading path'); - } - }; - - reader.readAsText(file); - e.target.value = null; // Reset file input - }); + updatePathData(); + CanvasField.showStatus("Path loaded"); + } catch (error) { + console.error("Error loading path:", error); + CanvasField.showStatus("Error loading path"); + } + }; - // Add keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { - return; - } + reader.readAsText(file); + e.target.value = null; // Reset file input + }); - if (e.ctrlKey) { - switch (e.key.toLowerCase()) { - case 'a': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('select'); - updateActiveToolButton('select'); - break; - case 'p': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('pen'); - updateActiveToolButton('pen'); - break; - case 'r': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('rectangle'); - updateActiveToolButton('rectangle'); - break; - case 'c': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('circle'); - updateActiveToolButton('circle'); - break; - case 'l': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('line'); - updateActiveToolButton('line'); - break; - case 'h': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('hexagon'); - updateActiveToolButton('hexagon'); - break; - case 'w': - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('arrow'); - updateActiveToolButton('arrow'); - break; - case 's': - if (!e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - CanvasField.setTool('star'); - updateActiveToolButton('star'); - } - break; - case 'z': - e.preventDefault(); - e.stopPropagation(); - if (e.shiftKey) { - CanvasField.redo(); - } - else if (!e.repeat) { // Only trigger once when key is first pressed - CanvasField.undo(); - } - updatePathData(); - break; - case 'y': - e.preventDefault(); - e.stopPropagation(); - CanvasField.redo(); - updatePathData(); - break; - case 'f': - e.preventDefault(); - e.stopPropagation(); - fillToggleBtn.click(); - break; - } - } - }); + // Add keyboard shortcuts + document.addEventListener("keydown", (e) => { + if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { + return; + } - // Form submission handling - const form = document.getElementById('scoutingForm'); - if (form) { - form.addEventListener('submit', async function(e) { + if (e.ctrlKey) { + switch (e.key.toLowerCase()) { + case "a": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("select"); + updateActiveToolButton("select"); + break; + case "p": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("pen"); + updateActiveToolButton("pen"); + break; + case "r": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("rectangle"); + updateActiveToolButton("rectangle"); + break; + case "c": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("circle"); + updateActiveToolButton("circle"); + break; + case "l": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("line"); + updateActiveToolButton("line"); + break; + case "h": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("hexagon"); + updateActiveToolButton("hexagon"); + break; + case "w": + e.preventDefault(); + e.stopPropagation(); + CanvasField.setTool("arrow"); + updateActiveToolButton("arrow"); + break; + case "s": + if (!e.shiftKey) { e.preventDefault(); - - // Update path data before submission - updatePathData(); - - // const teamNumber = form.querySelector('input[name="team_number"]').value; - // const eventCode = form.querySelector('input[name="event_code"]').value; - // const matchNumber = form.querySelector('input[name="match_number"]').value; - // const currentId = form.querySelector('input[name="current_id"]')?.value; - - // try { - // const response = await fetch(`/scouting/check_team?team=${teamNumber}&event=${eventCode}&match=${matchNumber}¤t_id=${currentId}`); - // const data = await response.json(); - - // if (data.exists) { - // alert(`Team ${teamNumber} already exists in match ${matchNumber} for event ${eventCode}`); - // return; - // } - - // form.submit(); - // } catch (error) { - // console.error('Error checking team:', error); - // form.submit(); - // } - form.submit(); - }); + e.stopPropagation(); + CanvasField.setTool("star"); + updateActiveToolButton("star"); + } + break; + case "z": + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + CanvasField.redo(); + } else if (!e.repeat) { + // Only trigger once when key is first pressed + CanvasField.undo(); + } + updatePathData(); + break; + case "y": + e.preventDefault(); + e.stopPropagation(); + CanvasField.redo(); + updatePathData(); + break; + case "f": + e.preventDefault(); + e.stopPropagation(); + fillToggleBtn.click(); + break; + } } - - // Tab switching functionality - const tabButtons = document.querySelectorAll('.tab-button'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - // Remove active class from all buttons and contents - tabButtons.forEach(btn => { - btn.classList.remove('active', 'border-blue-500', 'text-blue-600'); - btn.classList.add('border-transparent', 'text-gray-500'); - }); - tabContents.forEach(content => content.classList.add('hidden')); - - // Add active class to clicked button and show corresponding content - button.classList.add('active', 'border-blue-500', 'text-blue-600'); - button.classList.remove('border-transparent', 'text-gray-500'); - const tabName = button.getAttribute('data-tab'); - const activeContent = document.querySelector(`.tab-content[data-tab="${tabName}"]`); - activeContent.classList.remove('hidden'); - }); + }); + + // Form submission handling + const form = document.getElementById("scoutingForm"); + if (form) { + form.addEventListener("submit", async function (e) { + e.preventDefault(); + + // Update path data before submission + updatePathData(); + + // const teamNumber = form.querySelector('input[name="team_number"]').value; + // const eventCode = form.querySelector('input[name="event_code"]').value; + // const matchNumber = form.querySelector('input[name="match_number"]').value; + // const currentId = form.querySelector('input[name="current_id"]')?.value; + + // try { + // const response = await fetch(`/scouting/check_team?team=${teamNumber}&event=${eventCode}&match=${matchNumber}¤t_id=${currentId}`); + // const data = await response.json(); + + // if (data.exists) { + // alert(`Team ${teamNumber} already exists in match ${matchNumber} for event ${eventCode}`); + // return; + // } + + // form.submit(); + // } catch (error) { + // console.error('Error checking team:', error); + // form.submit(); + // } + form.submit(); + }); + } + + // Tab switching functionality + const tabButtons = document.querySelectorAll(".tab-button"); + const tabContents = document.querySelectorAll(".tab-content"); + + tabButtons.forEach((button) => { + button.addEventListener("click", () => { + // Remove active class from all buttons and contents + tabButtons.forEach((btn) => { + btn.classList.remove("active", "border-blue-500", "text-blue-600"); + btn.classList.add("border-transparent", "text-gray-500"); + }); + tabContents.forEach((content) => content.classList.add("hidden")); + + // Add active class to clicked button and show corresponding content + button.classList.add("active", "border-blue-500", "text-blue-600"); + button.classList.remove("border-transparent", "text-gray-500"); + const tabName = button.getAttribute("data-tab"); + const activeContent = document.querySelector( + `.tab-content[data-tab="${tabName}"]`, + ); + activeContent.classList.remove("hidden"); }); + }); }); // Add the updateUIControls function at the end of the file function updateUIControls(color, thickness) { - if (color) { - // Update color picker if it exists - const colorPicker = document.getElementById('pathColorPicker'); - if (colorPicker) { - colorPicker.value = color; - // Update Coloris field and button - const clrField = colorPicker.closest('.clr-field'); - if (clrField) { - clrField.style.color = color; - const button = clrField.querySelector('button'); - if (button) { - button.style.backgroundColor = color; - } - } + if (color) { + // Update color picker if it exists + const colorPicker = document.getElementById("pathColorPicker"); + if (colorPicker) { + colorPicker.value = color; + // Update Coloris field and button + const clrField = colorPicker.closest(".clr-field"); + if (clrField) { + clrField.style.color = color; + const button = clrField.querySelector("button"); + if (button) { + button.style.backgroundColor = color; } + } } - - if (thickness) { - // Update thickness slider if it exists - const thicknessSlider = document.getElementById('pathThickness'); - const thicknessDisplay = document.getElementById('pathThicknessValue'); - if (thicknessSlider) { - thicknessSlider.value = thickness; - if (thicknessDisplay) { - thicknessDisplay.textContent = thickness; - } - } + } + + if (thickness) { + // Update thickness slider if it exists + const thicknessSlider = document.getElementById("pathThickness"); + const thicknessDisplay = document.getElementById("pathThicknessValue"); + if (thicknessSlider) { + thicknessSlider.value = thickness; + if (thicknessDisplay) { + thicknessDisplay.textContent = thickness; + } } -} \ No newline at end of file + } +} diff --git a/app/static/js/scout/list.js b/app/static/js/scout/list.js index 39c5ae3..2a6d6eb 100644 --- a/app/static/js/scout/list.js +++ b/app/static/js/scout/list.js @@ -133,11 +133,11 @@ function exportToCSV() { const autoFuel = row.querySelector('td:nth-child(4)').textContent.trim(); const autoClimb = row.querySelector('td:nth-child(5)').textContent.trim(); const transitionFuel = row.querySelector('td:nth-child(6)').textContent.trim(); - const teleopFuel = row.querySelector('td:nth-child(7)').textContent.trim(); - const endgameFuel = row.querySelector('td:nth-child(8)').textContent.trim(); - const climb = row.querySelector('td:nth-child(9)').textContent.trim(); - const defense = row.querySelector('td:nth-child(11)').textContent.trim(); - const robotDisabled = row.querySelector('td:nth-child(12) span').textContent.trim(); + const teleopFuel = row.querySelector('td:nth-child(8)').textContent.trim(); + const endgameFuel = row.querySelector('td:nth-child(9)').textContent.trim(); + const climb = row.querySelector('td:nth-child(10)').textContent.trim(); + const defense = row.querySelector('td:nth-child(12)').textContent.trim(); + const robotDisabled = row.querySelector('td:nth-child(13) span').textContent.trim(); const notes = (row.dataset.notes || '').replace(/,/g, ';').replace(/\n/g, ' '); const {scouter} = row.dataset; const {eventCode} = row.closest('.event-section').dataset; @@ -199,6 +199,21 @@ document.addEventListener('DOMContentLoaded', function() { }); } - // Initialize Coloris - Coloris.init(); + // Configure Coloris + Coloris({ + theme: 'polaroid', + themeMode: 'light', + alpha: false, + formatToggle: false, + swatches: [ + '#2563eb', // Default blue + '#000000', + '#ffffff', + '#db4437', + '#4285f4', + '#0f9d58', + '#ffeb3b', + '#ff7f00' + ] + }); }); \ No newline at end of file diff --git a/app/team/forms.py b/app/team/forms.py index 8231579..e05a6fe 100644 --- a/app/team/forms.py +++ b/app/team/forms.py @@ -12,7 +12,7 @@ class CreateTeamForm(FlaskForm): class Meta: csrf = True - team_number = IntegerField( + team_number: IntegerField = IntegerField( "Team Number", validators=[ DataRequired(message="Team number is required"), @@ -22,7 +22,7 @@ class Meta: ], ) - team_name = StringField( + team_name: StringField = StringField( "Team Name", validators=[ DataRequired(message="Team name is required"), @@ -32,14 +32,14 @@ class Meta: ], ) - description = TextAreaField( + description: TextAreaField = TextAreaField( "Description", validators=[ Length(max=500, message="Description must be less than 500 characters") ], ) - logo = FileField( + logo: FileField = FileField( "Team Logo", validators=[ FileAllowed(["jpg", "png"], "Only JPG and PNG images are allowed!"), @@ -49,7 +49,7 @@ class Meta: ], ) - def validate(self, extra_validators=None): + def validate(self, extra_validators=None) -> bool: """Override validate method to add custom validation and logging""" initial_validation = super().validate(extra_validators=extra_validators) logger.debug(f"Form initial validation: {initial_validation}") diff --git a/app/team/routes.py b/app/team/routes.py index cd018a5..91dd80d 100644 --- a/app/team/routes.py +++ b/app/team/routes.py @@ -1,7 +1,7 @@ from __future__ import annotations -from io import BytesIO from datetime import datetime +from io import BytesIO from bson import ObjectId from flask import (Blueprint, current_app, flash, jsonify, redirect, @@ -11,17 +11,17 @@ from werkzeug.utils import secure_filename from app.team.team_utils import TeamManager -from app.utils import (allowed_file, async_route, error_response, +from app.utils import (allowed_file, async_route, error_response, get_gridfs, handle_route_errors, limiter, save_file_to_gridfs, - success_response, get_gridfs) + success_response) from .forms import CreateTeamForm team_bp = Blueprint("team", __name__) -team_manager = None +team_manager: TeamManager | None = None @team_bp.record -def on_blueprint_init(state): +def on_blueprint_init(state) -> None: global team_manager app = state.app @@ -63,8 +63,6 @@ async def join(): return render_template("team/join.html") - - except Exception as e: current_app.logger.error(f"Error in join_team_page: {str(e)}", exc_info=True) flash("Unable to process your request. Please try again later.", "error") @@ -505,7 +503,7 @@ async def update_team_logo(team_number): if file.filename == '': return error_response("No file selected") - new_logo_id = await save_file_to_gridfs(file, team_manager.db) + new_logo_id = await save_file_to_gridfs(file) if not new_logo_id: return error_response("Invalid file type") diff --git a/app/team/team_utils.py b/app/team/team_utils.py index 941480d..dd35cd9 100644 --- a/app/team/team_utils.py +++ b/app/team/team_utils.py @@ -7,13 +7,12 @@ from io import BytesIO from typing import Dict, Optional, Tuple, Union -import gridfs from bson.objectid import ObjectId +from flask import current_app from PIL import Image, ImageDraw, ImageFont from app.models import Assignment, Team, User -from app.utils import DatabaseManager, with_mongodb_retry, get_database_connection, get_gridfs -from flask import current_app +from app.utils import DatabaseManager, get_gridfs, with_mongodb_retry logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -27,7 +26,7 @@ class TeamManager(DatabaseManager): """Handles all team-related operations""" - def __init__(self, mongo_uri=None): + def __init__(self, mongo_uri: Optional[str] = None) -> None: # Use the singleton connection super().__init__(mongo_uri) self._ensure_collections() @@ -130,7 +129,7 @@ async def create_team(self, team_number: int, creator_id: str, return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def join_team(self, user_id: str, team_join_code: str): + async def join_team(self, user_id: str, team_join_code: str) -> TeamResult: """Add a user to a team using the join code""" try: @@ -165,7 +164,7 @@ async def join_team(self, user_id: str, team_join_code: str): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def leave_team(self, user_id: str, team_number: int): + async def leave_team(self, user_id: str, team_number: int) -> TeamResult: """Remove a user from a team and remove their admin status""" try: @@ -202,7 +201,7 @@ async def leave_team(self, user_id: str, team_number: int): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def get_team_members(self, team_number: int): + async def get_team_members(self, team_number: int) -> list[User]: """Get all members of a team""" try: @@ -294,7 +293,7 @@ async def remove_admin( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def remove_user(self, team_number: int, user_id: str, admin_id: str): + async def remove_user(self, team_number: int, user_id: str, admin_id: str) -> TeamResult: """Remove a user from a team (admin action)""" try: @@ -335,7 +334,7 @@ async def remove_user(self, team_number: int, user_id: str, admin_id: str): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def create_or_update_assignment(self, team_number: int, assignment_data: dict, creator_id: str): + async def create_or_update_assignment(self, team_number: int, assignment_data: dict, creator_id: str) -> TeamResult: """Create or update an assignment""" try: @@ -366,8 +365,10 @@ async def create_or_update_assignment(self, team_number: int, assignment_data: d {"$addToSet": {"assignments": str(result.inserted_id)}}, ) - from app.notifications.notification_manager import NotificationManager import asyncio + + from app.notifications.notification_manager import \ + NotificationManager async def send_notifications(): try: @@ -402,7 +403,7 @@ async def send_notifications(): @with_mongodb_retry(retries=3, delay=2) def update_assignment_status( self, assignment_id: str, user_id: str, new_status: str - ): + ) -> Tuple[bool, str]: """Update the status of an assignment""" try: @@ -427,7 +428,7 @@ def update_assignment_status( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def get_team_assignments(self, team_number: int): + async def get_team_assignments(self, team_number: int) -> list[Assignment]: """Get all assignments for a team""" try: @@ -583,7 +584,7 @@ async def update_assignment( return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def reset_user_team(self, user_id: str): + async def reset_user_team(self, user_id: str) -> bool: """Reset user's team number to None""" try: @@ -599,7 +600,7 @@ async def reset_user_team(self, user_id: str): return False @with_mongodb_retry(retries=3, delay=2) - async def validate_user_team(self, user_id: str, team_number: int): + async def validate_user_team(self, user_id: str, team_number: int) -> TeamResult: """Validate that a user's team exists and update if it doesn't""" try: @@ -621,7 +622,7 @@ async def validate_user_team(self, user_id: str, team_number: int): return False, "An internal error has occurred." @with_mongodb_retry(retries=3, delay=2) - async def update_team_logo(self, team_number: int, new_logo_id) -> Tuple[bool, str]: + async def update_team_logo(self, team_number: int, new_logo_id) -> TeamResult: """Update team logo and clean up old one""" try: # Get current team data @@ -671,7 +672,7 @@ def cleanup_gridfs(self): return False @with_mongodb_retry(retries=3, delay=2) - async def update_team_info(self, team_number: int, updates: dict) -> Tuple[bool, str]: + async def update_team_info(self, team_number: int, updates: dict) -> TeamResult: """Update team information""" try: # Filter out None values @@ -730,7 +731,7 @@ def create_default_team_logo(self, team_number: int) -> bytes: return buffer.getvalue() @with_mongodb_retry(retries=3, delay=2) - async def transfer_ownership(self, team_number: int): + async def transfer_ownership(self, team_number: int) -> TeamResult: """Transfer team ownership to next admin or member""" try: diff --git a/app/templates/auth/settings.html b/app/templates/auth/settings.html index 43b26cb..089c742 100644 --- a/app/templates/auth/settings.html +++ b/app/templates/auth/settings.html @@ -1,317 +1,805 @@ -{% extends "base.html" %} +{% extends "base.html" %} {% block content %} +
-{% block content %}
-
-
-
-
-

Profile Settings

- - - - - -
+
+
+
+
+

Profile Settings

+ + + + + +
+
+ +
+
+ +
+
+ Profile picture + +
+
+

+

+ Supported formats: PNG, JPG, JPEG. Maximum file size: 6MB +

+
+
+ + +
+
+

+ + + + Basic Information +

-
- - -
-
- Profile picture - - -
-
-

-

Supported formats: PNG, JPG, JPEG. Maximum file size: 6MB

-
+
+
+ +
+
+ + +
+ +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + - -
-
-

- - - - Basic Information -

- -
-
- -
-
- - - -
- -
-
-
- -
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-
-
- - {% if current_user.teamNumber %} -
-

- - - - Team Information -

-
- Current Team: - - Team {{ current_user.teamNumber }} - -
-
- {% endif %} - -
-

- - - - Danger Zone -

-
-
Delete Account
-

- Once you delete your account, there is no going back. Please be certain. -

- -
-
- -
- -
+ + + + +
- + +
+
+
+ + {% if current_user.teamNumber %} +
+

+ + + + Team Information +

+
+ Current Team: + + Team {{ current_user.teamNumber }} + +
+
+ {% endif %} + + +
+ +
+
+ + + +
+
+

+ + + + Change Password +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+

+ + + + Danger Zone +

+
+
Delete Account
+

+ Once you delete your account, there is no going back. Please + be certain. +

+ +
+
+
+
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/lighthouse.html b/app/templates/lighthouse.html index afdcd5b..f129cab 100644 --- a/app/templates/lighthouse.html +++ b/app/templates/lighthouse.html @@ -1,8 +1,8 @@ {% extends "base.html" %} {% block head %} - - - + + + {% endblock %} {% block content %} diff --git a/app/templates/lighthouse/auton.html b/app/templates/lighthouse/auton.html index 44bc6ce..b98a9bd 100644 --- a/app/templates/lighthouse/auton.html +++ b/app/templates/lighthouse/auton.html @@ -1,128 +1,315 @@ -{% extends "base.html" %} -{% block head %} +{% extends "base.html" %} {% block head %} Autonomous Path Comparison - + - - - -{% endblock %} - -{% block content %} + + + +{% endblock %} {% block content %}
-

Autonomous Path Comparison

- - - - -
- -
-
-

Search Teams

-
-
- - -
-
- - -
+

Autonomous Path Comparison

+ + + + +
+ +
+
+

Search Teams

+
+
+ + +
- - -
-
-

Path Comparison

- - -
- -
- -

- Pan: Shift+Drag or Middle Mouse Button | Zoom: Scroll Wheel -

- - -
-
- - -
-
- - -

Selected Paths (0/6)

-

You can add up to 6 paths with a maximum of 3 teams per alliance.

-
-
- Search for a team and select paths to compare -
-
+ + +
+
+ + +
+
+

Path Comparison

+ + +
+
+ +

+ Pan: Shift+Drag or Middle Mouse Button | Zoom: Scroll Wheel +

+ + +
+
+ + + + + + +
+
+ + +

+ Selected Paths (0/6) +

+

+ You can add up to 6 paths with a maximum of 3 teams per alliance. +

+
+
+ Search for a team and select paths to compare +
+
+
+
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/scouting/add.html b/app/templates/scouting/add.html index 352f586..430691a 100644 --- a/app/templates/scouting/add.html +++ b/app/templates/scouting/add.html @@ -1,553 +1,1011 @@ -{% extends "base.html" %} -{% block head %} - - - - -{% endblock %} - -{% block content %} +{% extends "base.html" %} {% block head %} + + + + +{% endblock %} {% block content %}
- -
-
-
-

Add Match Scouting Data

-

Enter match scouting information for a team

+ +
+
+
+

+ Add Match Scouting Data +

+

+ Enter match scouting information for a team +

+
+ +
+
+ + +
+ + + + +
+ +
+

+ Basic Information +

+
+
+ + +
+
+ + +
+
+ + + +
+
+
+ + +
+

Auto Path

+

+ Pan: Shift+Drag or Middle Mouse Button | Zoom: Scroll Wheel +

+
+ +
+ +
+ + +
+ +
+
+ +
+
+ + + 3 +
+
+ +
-
- - - - - Back to Home - + + +
+ +
+ + + + + + + + + + + + + +
+ + +
+ + + + + + + +
+
+ + +
-
- -
- - - +
+ + +
+
- - -
-

Basic Information

-
-
- - -
-
- - + +
+
+
+
+ +
+
+
+ + +
+
+ +
+

+ Auto Fuel +

+
+
+ +
+
+ + +
-
- - - + +
+ + +
+
+
- -
-

Auto Path

-

- Pan: Shift+Drag or Middle Mouse Button | Zoom: Scroll Wheel -

-
- -
- -
- - -
- -
-
- -
-
- - - 3 -
-
- -
-
- - -
- -
- - - - - - - - - -
+ +
+

+ Auto Climb +

+
+ +
+
+
+
- -
- - - - - - - -
-
+ +